Sunday, June 17, 2012

Flossing, Seatbelts, And Dynamic Type Checking


"It's all fun and games until someone loses an eye."

Researchers, many of whom were really really smart, deduced that you should floss, and wear seatbelts, and eat a balanced diet.

In software, similarly intelligent researchers determined that strong typing was like flossing and the wearing of seatbelts: a very minor inconvenience that saved you a lot of trouble later on.

Now the trend is increasingly towards dynamic languages that discover type mismatches at runtime.

You know who does that discovering?

Your users.

The only reason your users aren't already at your gates with torches and pitchforks is that browsers turn off JavaScript errors by default.

Is this really the best we can do? The argument against static type checking boils down to "I'm a very careful driver", which is what every driver thinks right up until they get in an accident.

Languages with static type checking allow programmers to opt out (even Ada has unchecked conversion). That's analogous to allowing passengers to not put on their seatbelts if they're crazy enough not to want to wear them. JavaScript doesn't allow programmers to opt into static type checking. That's analogous to a driver taking all of the seatbelts out of the car, even those for the passengers, including children.

Sunday, June 10, 2012

Javascript Slideshow With Fade

I wanted a simple slideshow for our Analog Perfection™ website (don't bother going there--we won't have it up until just before launching the Kickstarter project).

The requirements were:
  • Free
  • Source code provided
  • Fade transitions
  • Small file size
  • Fast initial load (implying lazy loading of thumbnails)
  • Plain old Javascript (no dependencies on frameworks such as JQuery, MooTools, Dojo, Flash, etc.)
Nothing quite matched the requirements, so I wrote one, cobbling pieces together from answers on Stack Overflow, some help from Tyler Riding, Priyajeet Hora, Fro Rosqueta, and Doug Crossley (colleagues at Intuit), and a couple of key insights from the Tigra Fader sample code.

The result is under 4k, non-minified.

The code puts all of the slide thumbnails in one <a> element, as imgs, on top of each other, but with different z-ordering, and then adjusts the opacity of the current and next thumbnails.

You can see the pairwise opacity changes by looking at the elements in your browser's developer-tools console.

The HTML for all but the first thumbnail is generated dynamically, when the thumbnail lazy loads.

When the next thumbnail has crossed a threshold, the href is updated to point to that thumbnail's corresponding full-size picture.

Mousing into the thumbnail region accelerates the fading to 100%, then pauses the program. Mousing out of the thumbnail region resumes the program (as does clicking into the thumbnail).

I've only tested in on IE9 and Chrome so far, but have tried not to use anything quirksmode says won't work on older browsers. Eventually this will be deployed on a live site, and then I can start using something like browsershots to test, and then the cursing begins.

I embed the Javascript in my HTML like this:

<div id="slides">
<a id="slideLarge" href="./images/FrontView.jpg" target="_blank">
<img id="slideSmall0" src="./images/FrontViewSmall.jpg" alt="[unable to display picture]"/>
<!--INCLUDE("slides.js")INCLUDE-->
</a>
</div>

and use the Includer to expand the contents before deployment. If you don't want to do that, just lexically replace the INCLUDE line with the contents at the bottom of this post, or use a regular script src include.

Note that for browsers that have Javascript disabled, the first thumbnail and its associated full-size picture will still display correctly.

There are a number of arguments to the main function:
  • imgdir: Directory containing the thumbnails and full-size pictures--must end with '/'. If current directory, use "./".
  • fullid: HTML ID of the full-size picture.
  • idprefix: Shared prefix for the HTML IDs of all of the thumbnails.
  • width: Width in px (but without "px") of the thumbnails.
  • holdtime: How long in milliseconds to hold one thumbnail image constant before starting to fade.
  • fadetime: How long in milliseconds to take to perform a fade.
  • steps: How many steps to use to perform a fade. More is smoother but uses more CPU.
  • fastfadetime: How long in milliseconds to take to perform a fade when the mouse is over the thumbnail.
  • faststeps: How many steps to use to perform a fade when the mouse is over the thumbnail. More is smoother but uses more CPU.
  • flip: Percent opacity (as a number from 0.0 to 1.0) next thumbnail must have before clicking on it brings up its full-size picture instead of the current thumbnail's full-size picture.
  • names: Array of arrays where the inner arrays each have two elements: [the simple name of a thumbnail, the simple name of the full-size picture corresponding to the thumbnail]. The names of the first thumbnail and first full-size picture must not be included (they are extracted from the non-generated HTML during initialization).
The arguments are not passed in using name/value pairs, but are instead just positionally associated.

The thumbnails all have to be the same size (width and height) for the overlaying to work.

Using a naming convention for the shared ID prefix was done on purpose. If you don't like it, you can modify the code to add the prefix to the names as another element in each array, for all but the first thumbnail, and get rid of idprefix entirely. (But to do that, you need to obtain the ID of the 0th thumbnail by walking the DOM starting at the fullid. It's not difficult, but seemed like overkill.)

I considered using a naming convention to eliminate the need to pass in an array of arrays, and just pass in an array of names. But then what if the extensions differ between the two sizes of files?

Things left to do:
  • Add a row of numbers (or circles, or some other kind of icon) that indicates which slide is currently active, and to allow direct navigation to that slide by clicking on the number/icon, similar to how National Geographic and menucool do it. We don't really need it for our site, but it's an interesting exercise. To keep the layout clean, it could perhaps only display the row when the mouse is over the thumbnail, and display it over the thumbnail, at the bottom (not outside the thumbnail region), similar to how Dynamic Drive does it.
  • Add drag support, so the user can slide the thumbnails left or right by mouse down followed by mouse move. Don't treat the eventual mouse up as a click that opens the large picture.
  • Figure out how to get the width from the 0th slide, instead of having to pass it in, which is annoying. I tried style.clientWidth, style.offsetWidth, and various other suggestions online. Nothing worked, perhaps because at the point where the width is needed, the page hasn't been rendered yet, so the viewport size isn't known. If someone knows how to solve this, please post the solution!
Misc notes:
  • I tried a different approach before this one, but couldn't get it to work: manipulate the opacity of the link's background image and one img, and once whichever one of those is set to full opacity, replace the other one with a new image, then repeat the seesawing.
Here's the code, plus the CSS I used. (To get the pictures to line up, they need to share a margin, and to do that the margins have to be set on the elements--the CSS will help you get it working.)

CODE:
<script type="text/javascript" id="slidesjs">
<!--
// Copyright © 2012 jimandlisa.com. All rights reserved.
function slides(imgdir, fullid, idprefix, width, holdtime, fadetime, steps, fastfadetime, faststeps, flip, names) {

    "use strict";

    var full, thumbs, pos, cur, next, initial, paused, stepsize, steptime, curstep, step, up, down, flipped, dofast;

    function getname(url) {
        return url.substring(url.lastIndexOf('/') + 1);
    }

    function setsteps(fast) {
        if (fast) {
            stepsize = 1.00 / faststeps;
            steptime = Math.round(fastfadetime / faststeps);
            dofast = true;
        } else {
            stepsize = 1.00 / steps;
            steptime = Math.round(fadetime / steps);
            dofast = false;
        }
    }

    function setopac(style, opac) {
        style.opacity = opac;
        style.filters = "alpha(opacity=" + Math.round(opac * 100) + ')';
    }

    function fade() {

        if (paused) {
            if (!dofast) {
                setsteps(true);
            }
        }

        step = curstep * stepsize;
        up = (step > 1.000) ? 1.000 : step;
        down = ((up === 1.000) || ((1.000 - step) < 0.000)) ? 0.000 : (1.000 - step);
        up = (down === 0.000) ? 1.000 : step;
        curstep += 1;

        setopac(cur.style, down);
        setopac(next.style, up);

        if (!flipped && (up > flip)) {
            full.href = imgdir + names[pos][1];
            full.target = names[pos][1];
            flipped = true;
        }

        if (up === 1.000) {
            return;
        }

        setTimeout(function () { fade(); }, steptime);
    }

    function run() {

        if (!initial && !paused) {
            cur = thumbs[pos];
            pos = (pos + 1) % names.length;
            if (pos === thumbs.length) {
                next = document.createElement("img");
                next.id = idprefix + pos;
                next.src = imgdir + names[pos][0];
                next.alt = "[unable to display picture]";
                full.appendChild(next);
                setopac(next.style, 0.000);
                next.style.marginLeft = "-" + width + "px";
                next.style.zIndex = names.length - pos - 1;
                thumbs.push(next);
            } else {
                next = thumbs[pos];
            }
            curstep = 1;
            flipped = false;
            setsteps(false);
            fade();
        }

        initial = false;

        setTimeout(function () { run(); }, holdtime);
    }

    function noprop(e) {
        if (!e) {
            e = window.event;
        }
        e.cancelBubble = true;
        if (e.stopPropagation) {
            e.stopPropagation();
        }
    }
    function doAddHandler(obj, name, newhandler) {
        var curhandler = obj[name];
        if (curhandler === null) {
            obj[name] = newhandler;
        } else {
            obj[name] = function () {
                curhandler();
                newhandler();
            };
        }
    }

    function pause(e) {
        noprop(e);
        if (paused) {
            return;
        }
        paused = true;
    }

    function resume(e) {
        noprop(e);
        if (!paused) {
            return;
        }
        paused = false;
    }

    function init() {

        pos = 0;

        full = document.getElementById(fullid);
        full.target = names[pos][1];

        cur = document.getElementById(idprefix + '0');
        names.splice(0, 0, [getname(cur.src), getname(full.href)]);
        //width = cur.clientWidth;
        thumbs = [];
        setopac(cur.style, 1.000);
        cur.style.zIndex = names.length - 1;
        thumbs.push(cur);

        next = null;
        initial = true;
        paused = false;
        setsteps(fadetime, steps);

        doAddHandler(full, "onmouseover", pause);
        doAddHandler(full, "onmouseout", resume);

        run();
    }

    doAddHandler(window, "onload", init);
}

slides("./images/", "slideLarge", "slideSmall", 650, 5200, 1750, 80, 700, 70, 0.6, [["SideViewSmall.jpg", "SideView.jpg"], ["RearViewSmall.jpg", "RearView.jpg"]]);
//-->
</script>

CSS:
#slides {
    float:left;
    width:650px;
    padding:0px;
    margin-left:100px;
    display:inline;
}

#slides img {
    float:left;
    border:0px;
    position:relative;
}

Saturday, June 2, 2012

A Utility To Inline Shared HTML

A website typically has sections that are shared among multiple pages. For example, a common header and footer. It makes sense to factor the shared sections into separate included files. But the include mechanism is not standard: some servers support SSI, other servers require using PHP includes or JSP, still others have no server-side support for includes and it has to be done with JS writes. None of these approaches seem satisfactory. When using a not-universally supported server-side mechanism, files may require rewriting when changing hosting providers. When using a client-side mechanism, there are more requests to the server. This utility eliminates this problem by inlining included HTML, CSS, and JavaScript before deployment:
/**
 * Copyright (c) 2012 jimandlisa LLC. All rights reserved.
 * This program is made available under the terms of the
 * Eclipse Public License v1.0, which is available at
 * http://www.opensource.org/licenses/eclipse-1.0.php.
 */

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Given a directory of source for a website, recursively navigates the directory structure,
 * inlining included files (which may in turn include files), and saving the result to a staging
 * directory. This eliminates the need for include-file solutions such as SSI, PHP, or Javascript.
 * It also speeds up page loads and eliminates requests. Included files must be located in a directory
 * called "includes" located in the root of the directory containing the source. Include directives
 * must have the prefix and suffix pairs defined by INCLUDES_DELIMITERS, and the name of the included
 * file must be between the left and right parentheses defined by the prefix and suffix. Included files
 * must have simple names (no directories in the names), and must contain text. Does not process individual
 * files, only the entire site; for large sites this would not be acceptable, but for our application
 * it only takes ~30 ms. Included files must form a directed acyclic graph (no cycles); the utility does
 * not perform cycle detection, but by going into an infinite loop it's pretty obvious when there is a cycle,
 * and the log output makes it easy to determine the cause.
 * @author Jim Showalter
 */
public class Includer {
    
    /**
     * Reserved name of includes directory. Must be contained directly within source directory.
     * Could of course be turned into an argument to the program, but was not necessary for
     * our application.
     */
    private static final String INCLUDES_DIR = "includes";
    
    /**
     * Set of types that support includes. Must all be text format.
     */
    private static final Set<String> INCLUDING_TYPES = new HashSet<String>();
    
    static {
        // Initialize including types.
        INCLUDING_TYPES.add("html");
        INCLUDING_TYPES.add("css");
        INCLUDING_TYPES.add("js");
    }
    
    /**
     * Allowable prefixes for includes. Must match pairwise with allowable suffixes.
     */
    private static final List<String[]> INCLUDES_DELIMITERS = new ArrayList<String[]>();
    
    static {
        // Initialize includes delimiters.
        INCLUDES_DELIMITERS.add(new String[] {"<!--INCLUDE(\"", "\")INCLUDE-->"}); // HTML
        INCLUDES_DELIMITERS.add(new String[] {"/*--INCLUDE(\"", "\")INCLUDE--*/"}); // CSS
        INCLUDES_DELIMITERS.add(new String[] {"//--INCLUDE(\"", "\")INCLUDE--//"}); // JavaScript
    }
    
    /**
     * Outputs usage error and exits program with error code.
     * @param explanation Explanation of error.
     */
    private static void usage(String explanation) {
        System.out.println(explanation);
        System.out.println(Includer.class.getSimpleName() + ": <source directory> <target directory>");
        System.exit(-1);
    }
    
    /**
     * Determines longest common prefix substring for a pair of paths. Used to shorten log messages.
     * @param sourcePath Source path.
     * @param targetPath Target path.
     * @return Longest common prefix.
     */
    private static String longestCommonPrefix(String sourcePath, String targetPath) {
        
        String longerString = null;
        String shorterString = null;
        
        if (sourcePath.length() > targetPath.length()) {
            longerString = sourcePath;
            shorterString = targetPath;
        } else {
            longerString = targetPath;
            shorterString = sourcePath;
        }
        
        String longestCommonPrefix = null;
        
        for (int i = 0; i < shorterString.length(); i++) {
            if (shorterString.charAt(i) != longerString.charAt(i)) {
                longestCommonPrefix = shorterString.substring(0, i - 1);
                break;
            }
        }
        
        return longestCommonPrefix;
    }
    
    /**
     * Shortens path by removing the longest prefix it has in common with another path.
     * @param path Path to shorten.
     * @param longestCommonPrefix Longest common prefix.
     * @return Shortened path.
     */
    private static String shorten(String path, String longestCommonPrefix) {
        return path.substring(longestCommonPrefix.length(), path.length());
    }
    
    /**
     * Logs a progress message.
     * @param prefix Beginning of logged message.
     * @param source Source file or directory.
     * @param longestCommonPrefix Longest common prefix.
     */
    private static void log(String prefix, File source, File target, String longestCommonPrefix) {
        String sourcePath = shorten(source.getAbsolutePath(), longestCommonPrefix);
        String targetPath = shorten(target.getAbsolutePath(), longestCommonPrefix);
        System.out.println(prefix + " copy: " + sourcePath + " => " + targetPath);
    }

    /**
     * Determines whether file name ends with a suffix that is one of the including types.
     * @param fileName File name to check.
     * @return True if file is an including type, false otherwise.
     */
    private static boolean isIncludingType(String fileName) {
        
        int dotIndex = fileName.lastIndexOf('.');
        
        if (dotIndex == -1) {
            return false;
        }
        
        String extension = fileName.substring(dotIndex + 1);
        
        return INCLUDING_TYPES.contains(extension);
    }
    
    /**
     * Extracts name of included file from current line if it is an include directive.
     * @param line Line.
     * @return Include file name, or null if current line is not an include directive.
     */
    private static String includeFileName(String line) {
        String trimmedLine = line.trim();
        for (String[] delimiters : INCLUDES_DELIMITERS) {
            String prefix = delimiters[0];
            String suffix = delimiters[1];
            if (trimmedLine.startsWith(prefix) && trimmedLine.endsWith(suffix)) {
                return trimmedLine.substring(prefix.length(), trimmedLine.length() - suffix.length());
            }
        }
        return null;
    }

    /**
     * Copies a file that does not have includes. Does not use NIO's transferTo or other new mechanisms
     * because, depending on file size, OS, etc., performance can actually wind up worse.
     * @param sourceFile File to copy from.
     * @param targetFile File to copy to.
     * @param longestCommonPrefix Longest common prefix.
     * @throws IOException Problem encountered opening, reading, creating, writing, or closing files.
     */
    private static void simpleCopy(File sourceFile, File targetFile, String longestCommonPrefix) throws IOException {
        
        log("simple", sourceFile, targetFile, longestCommonPrefix);
        
        InputStream in = new FileInputStream(sourceFile);
        OutputStream out = new FileOutputStream(targetFile);
        byte[] buf = new byte[1024*10];
        int len;
        while ((len = in.read(buf)) > 0) {
           out.write(buf, 0, len);
        }
        in.close();
        out.close();
    }
    
    /**
     * Platform-specific line separator.
     */
    private static final String LINE_SEPARATOR = System.getProperty("line.separator");
    
    /**
     * Reads contents of include file into memory, and caches it so it doesn't have to be read again.
     * For large sites this could be too much of a memory hog, in which case an eviction strategy
     * could be implemented (for example, keep the most-often referenced files).
     * @param includesDirName Name of includes directory.
     * @param includeFileName Name of include file.
     * @param includeFiles Cache of already included files.
     * @return Contents of include file.
     * @throws IOException Problem encountered opening, reading, or closing include file.
     */
    private static StringBuilder get(String includesDirName, String includeFileName,  Map<String, StringBuilder> includeFiles) throws IOException {
        
        System.out.println("get: " + includeFileName);

        StringBuilder includeFileContents = includeFiles.get(includeFileName);
        
        if (includeFileContents == null) {
            includeFileContents = new StringBuilder();
            File includeFile = new File(includesDirName + File.separator + INCLUDES_DIR + File.separator + includeFileName);
            BufferedReader reader = new BufferedReader(new InputStreamReader(new DataInputStream(new FileInputStream(includeFile))));
            
            String line;
            while ((line = reader.readLine()) != null) {
                String subIncludeFileName = includeFileName(line);
                if (subIncludeFileName != null) {
                    StringBuilder subIncludeFileContents = get(includesDirName, subIncludeFileName, includeFiles);
                    includeFileContents.append(subIncludeFileContents.toString());
                } else {
                    includeFileContents.append(line);
                    includeFileContents.append(LINE_SEPARATOR);
                }
            }
            
            reader.close();
            includeFiles.put(includeFileName, includeFileContents);
            System.out.println("put: " + includeFileName);
        }
        
        return includeFileContents;
    }

    /**
     * Copies a file that might have includes.
     * @param sourceFile File to copy from.
     * @param targetFile File to copy to.
     * @param longestCommonPrefix Longest common prefix.
     * @param includeFiles Cache of already included files.
     * @throws IOException Problem encountered opening, reading, creating, writing, or closing files.
     */
    private static void includeCopy(File sourceFile, File targetFile, String longestCommonPrefix, Map<String, StringBuilder> includeFiles) throws IOException {
        
        log("file", sourceFile, targetFile, longestCommonPrefix);

        BufferedReader reader = new BufferedReader(new InputStreamReader(new DataInputStream(new FileInputStream(sourceFile))));
        BufferedWriter writer = new BufferedWriter(new FileWriter(targetFile));

        String line;
        while ((line = reader.readLine()) != null) {
            String includeFileName = includeFileName(line);
            if (includeFileName != null) {
                StringBuilder includeFileContents = get(sourceFile.getParent(), includeFileName, includeFiles);
                writer.write(includeFileContents.toString());
            } else {
                writer.write(line);
                writer.newLine();
            }
        }

        reader.close();
        writer.close();
    }
    
    /**
     * Copies a source directory to a target directory, recursively, resolving any included files it encounters.
     * @param sourceDir Directory to copy from.
     * @param targetDir Directory to copy to.
     * @param longestCommonPrefix Longest common prefix. (For logging, to shorten messages.)
     * @param includeFiles Cache of already included files.
     * @throws IOException Problem encountered opening, reading, creating, writing, or closing files.
     */
    private static void copy(File sourceDir, File targetDir, String longestCommonPrefix, Map<String, StringBuilder> includeFiles) throws IOException {
        
        log("directory", sourceDir, targetDir, longestCommonPrefix);
        
        if (!targetDir.exists()) {
            targetDir.mkdirs();
        }
        
        File[] files = sourceDir.listFiles();
        for (File file : files) {
            if (file.isDirectory()) {
                File dir = file;
                String dirName = dir.getName();
                if (dirName.compareTo(INCLUDES_DIR) != 0) {
                    File subTargetDir = new File(targetDir.getAbsolutePath() + File.separator + dirName);
                    copy(dir, subTargetDir, longestCommonPrefix, includeFiles);
                }
            } else {
                String fileName = file.getName();
                String targetFileName = targetDir.getAbsolutePath() + File.separator + fileName;
                File targetFile = new File(targetFileName);
                if (!isIncludingType(fileName)) {
                    simpleCopy(file, targetFile, longestCommonPrefix);
                } else {
                    includeCopy(file, targetFile, longestCommonPrefix, includeFiles);
                }
            }
        }
    }
    
    /**
     * Main entry point for program.
     * @param args Command-line arguments.
     * @throws IOException Problem encountered opening, reading, creating, writing, or closing files.
     */
    public static void main(String[] args) throws IOException {
        
        // Validate the args.
        
        if (args == null) {
            usage("Null args");
        }
        
        if (args.length != 2) {
            usage("Wrong number of args");
        }
        
        String sourceDirName = args[0];
        
        if (sourceDirName == null) {
            usage("Null source directory");
        }
        
        if (sourceDirName.trim().length() == 0) {
            usage("Blank source directory");
        }
        
        String targetDirName = args[1];
        
        if (targetDirName == null) {
            usage("Null target directory");
        }
        
        if (targetDirName.trim().length() == 0) {
            usage("Blank target directory");
        }
        
        if (sourceDirName.startsWith(targetDirName)) {
            usage("Source directory child of target directory");
        }
        
        if (targetDirName.startsWith(sourceDirName)) {
            usage("Target directory child of source directory");
        }
        
        File source = new File(sourceDirName);
        
        if (!source.exists()) {
            usage("Non-existent source directory");
        }
        
        if (!source.isDirectory()) {
            usage("Non-directory source directory");
        }
        
        // Initialize and perform the copying.
        
        File target = new File(targetDirName);
        
        Map<String, StringBuilder> includeFiles = new HashMap<String, StringBuilder>();
        
        long start = System.currentTimeMillis();
        
        try {
            copy(source, target, longestCommonPrefix(sourceDirName, targetDirName), includeFiles);
        } catch (Exception e) {
            System.out.println("Encountered problem copying source to target: " + e.getMessage());
            e.printStackTrace();
            System.exit(-1);
        }
        
        long end = System.currentTimeMillis();
        
        System.out.println("done in " + (end - start) + " ms");
    }
}
An example file set up to be processed by the utility looks like this:
<!--INCLUDE("top.html")INCLUDE-->

<body id="manual">

<div id="header" class="header">
<h1 class="header">User Manual</h1>
</div>

<!--INCLUDE("nav.html")INCLUDE-->

<div>
<p>TODO: Put safety instructions and user manual here.</p>
</div>

<!--INCLUDE("bottom.html")INCLUDE-->