indexzero
10/24/2012 - 8:31 AM

XKCD-style plots in d3

XKCD-style plots in d3

function xkcdplot() {

    // Default parameters.
    var width = 600,
        height = 300,
        margin = 20,
        arrowSize = 12,
        arrowAspect = 0.4,
        arrowOffset = 6,
        magnitude = 0.003,
        xlabel = "Time of Day",
        ylabel = "Awesomeness",
        title = "The most important graph ever made",
        xlim,
        ylim;

    // Plot elements.
    var el,
        xscale = d3.scale.linear(),
        yscale = d3.scale.linear();

    // Plotting functions.
    var elements = [];

    // The XKCD object itself.
    var xkcd = function (nm) {
        el = d3.select(nm).append("svg")
                    .attr("width", width + 2 * margin)
                    .attr("height", height + 2 * margin)
                .append("g")
                    .attr("transform", "translate(" + margin + ", "
                                                    + margin + ")");
        return xkcd;
    };

    // Getters and setters.
    xkcd.xlim = function () {
        if (!arguments.length) return xlim;
        xlim = arguments[0];
        return xkcd;
    };

    // Do the render.
    xkcd.draw = function () {
        // Set the axes limits.
        xscale.domain(xlim).range([0, width]);
        yscale.domain(ylim).range([height, 0]);

        // Compute the zero points where the axes will be drawn.
        var x0 = xscale(0),
            y0 = yscale(0);

        // Draw the axes.
        var axis = d3.svg.line().interpolate(xinterp);
        el.selectAll(".axis").remove();
        el.append("svg:path")
            .attr("class", "x axis")
            .attr("d", axis([[0, y0], [width, y0]]));
        el.append("svg:path")
            .attr("class", "y axis")
            .attr("d", axis([[x0, 0], [x0, height]]));

        // Laboriously draw some arrows at the ends of the axes.
        var aa = arrowAspect * arrowSize,
            o = arrowOffset,
            s = arrowSize;
        el.append("svg:path")
            .attr("class", "x axis arrow")
            .attr("d", axis([[width - s + o, y0 + aa], [width + o, y0], [width - s + o, y0 - aa]]));
        el.append("svg:path")
            .attr("class", "x axis arrow")
            .attr("d", axis([[s - o, y0 + aa], [-o, y0], [s - o, y0 - aa]]));
        el.append("svg:path")
            .attr("class", "y axis arrow")
            .attr("d", axis([[x0 + aa, s - o], [x0, -o], [x0 - aa, s - o]]));
        el.append("svg:path")
            .attr("class", "y axis arrow")
            .attr("d", axis([[x0 + aa, height - s + o], [x0, height + o], [x0 - aa, height - s + o]]));

        for (var i = 0, l = elements.length; i < l; ++i) {
            var e = elements[i];
            e.func(e.data, e.x, e.y, e.opts);
        }

        // Add some axes labels.
        el.append("text").attr("class", "x label")
                              .attr("text-anchor", "end")
                              .attr("x", width - s)
                              .attr("y", y0 + aa)
                              .attr("dy", ".75em")
                              .text(xlabel);
        el.append("text").attr("class", "y label")
                              .attr("text-anchor", "end")
                              .attr("x", aa)
                              .attr("y", x0)
                              .attr("dy", "-.75em")
                              .attr("transform", "rotate(-90)")
                              .text(ylabel);

        // And a title.
        el.append("text").attr("class", "title")
                              .attr("text-anchor", "end")
                              .attr("x", width)
                              .attr("y", 0)
                              .text(title);

        return xkcd;
    };

    // Adding plot elements.
    xkcd.plot = function (data, opts) {
        var x = function (d) { return d.x; },
            y = function (d) { return d.y; },
            cx = function (d) { return xscale(x(d)); },
            cy = function (d) { return yscale(y(d)); },
            xl = d3.extent(data, x),
            yl = d3.extent(data, y);

        // Rescale the axes.
        xlim = xlim || xl;
        xlim[0] = Math.min(xlim[0], xl[0]);
        xlim[1] = Math.max(xlim[1], xl[1]);
        ylim = ylim || yl;
        ylim[0] = Math.min(ylim[0], yl[0]);
        ylim[1] = Math.max(ylim[1], yl[1]);

        // Add the plotting function.
        elements.push({
                        data: data,
                        func: lineplot,
                        x: cx,
                        y: cy,
                        opts: opts
                      });

        return xkcd;
    };

    // Plot styles.
    function lineplot(data, x, y, opts) {
        var line = d3.svg.line().x(x).y(y).interpolate(xinterp),
            bgline = d3.svg.line().x(x).y(y),
            strokeWidth = _get(opts, "stroke-width", 3),
            color = _get(opts, "stroke", "steelblue");
        el.append("svg:path").attr("d", bgline(data))
                             .style("stroke", "white")
                             .style("stroke-width", 2 * strokeWidth + "px")
                             .style("fill", "none")
                             .attr("class", "bgline");
        el.append("svg:path").attr("d", line(data))
                             .style("stroke", color)
                             .style("stroke-width", strokeWidth + "px")
                             .style("fill", "none");
    };

    // XKCD-style line interpolation. Roughly based on:
    //    jakevdp.github.com/blog/2012/10/07/xkcd-style-plots-in-matplotlib
    function xinterp (points) {
        // Scale the data.
        var f = [xscale(xlim[1]) - xscale(xlim[0]),
                 yscale(ylim[1]) - yscale(ylim[0])],
            z = [xscale(xlim[0]),
                 yscale(ylim[0])],
            scaled = points.map(function (p) {
                return [(p[0] - z[0]) / f[0], (p[1] - z[1]) / f[1]];
            });

        // Compute the distance along the path using a map-reduce.
        var dists = scaled.map(function (d, i) {
            if (i == 0) return 0.0;
            var dx = d[0] - scaled[i - 1][0],
                dy = d[1] - scaled[i - 1][1];
            return Math.sqrt(dx * dx + dy * dy);
        }),
            dist = dists.reduce(function (curr, d) { return d + curr; }, 0.0);

        // Choose the number of interpolation points based on this distance.
        var N = Math.round(200 * dist);

        // Re-sample the line.
        var resampled = [];
        dists.map(function (d, i) {
            if (i == 0) return;
            var n = Math.max(3, Math.round(d / dist * N)),
                spline = d3.interpolate(scaled[i - 1][1], scaled[i][1]),
                delta = (scaled[i][0] - scaled[i - 1][0]) / (n - 1);
            for (var j = 0, x = scaled[i - 1][0]; j < n; ++j, x += delta)
                resampled.push([x, spline(j / (n - 1))]);
        });

        // Compute the gradients.
        var gradients = resampled.map(function (a, i, d) {
            if (i == 0) return [d[1][0] - d[0][0], d[1][1] - d[0][1]];
            if (i == resampled.length - 1)
                return [d[i][0] - d[i - 1][0], d[i][1] - d[i - 1][1]];
            return [0.5 * (d[i + 1][0] - d[i - 1][0]),
                    0.5 * (d[i + 1][1] - d[i - 1][1])];
        });

        // Normalize the gradient vectors to be unit vectors.
        gradients = gradients.map(function (d) {
            var len = Math.sqrt(d[0] * d[0] + d[1] * d[1]);
            return [d[0] / len, d[1] / len];
        });

        // Generate some perturbations.
        var perturbations = smooth(resampled.map(d3.random.normal()), 3);

        // Add in the perturbations and re-scale the re-sampled curve.
        var result = resampled.map(function (d, i) {
            var p = perturbations[i],
                g = gradients[i];
            return [(d[0] + magnitude * g[1] * p) * f[0] + z[0],
                    (d[1] - magnitude * g[0] * p) * f[1] + z[1]];
        });

        return result.join("L");
    }

    // Smooth some data with a given window size.
    function smooth(d, w) {
        var result = [];
        for (var i = 0, l = d.length; i < l; ++i) {
            var mn = Math.max(0, i - 5 * w),
                mx = Math.min(d.length - 1, i + 5 * w),
                s = 0.0;
            result[i] = 0.0;
            for (var j = mn; j < mx; ++j) {
                var wd = Math.exp(-0.5 * (i - j) * (i - j) / w / w);
                result[i] += wd * d[j];
                s += wd;
            }
            result[i] /= s;
        }
        return result;
    }

    // Get a value from an object or return a default if that doesn't work.
    function _get(d, k, def) {
        if (typeof d === "undefined") return def;
        if (typeof d[k] === "undefined") return def;
        return d[k];
    }

    return xkcd;

}
<!DOCTYPE HTML>
<html>
<head>
    <title>XKCD plots in d3</title>

    <script src="http://d3js.org/d3.v2.min.js?2.10.0"></script>
    <script src="xkcd.js"></script>

<style>

@font-face {
    font-family: "xkcd";
    src: url('http://antiyawn.com/uploads/Humor-Sans.ttf');
}

body {
    font-family: "xkcd", sans-serif;
    font-size: 16px;
    color: #333;
    text-align: center;
    margin-top: 75px;
}

text.title {
    font-size: 20px;
}

path {
    fill: none;
    stroke-width: 2.5px;
    stroke-linecap: round;
    stroke-linejoin: round;
}

path.axis {
    stroke: black;
}

path.bgline {
    stroke: white;
    stroke-width: 6px;
}

</style>

</head>
<body>

    <script>

        // Generate some data.
        function f1 (x) {
            return Math.exp(-0.5 * (x - 1) * (x - 1)) * Math.sin(x + 0.2) + 0.05;
        }

        function f2 (x) {
            return 0.5 * Math.cos(x - 0.5) + 0.1;
        }

        var xmin = -1.0,
            xmax = 7,
            N = 100,
            data = d3.range(xmin, xmax, (xmax - xmin) / N).map(function (d) {
                return {x: d, y: f1(d)};
            })
            data2 = d3.range(xmin, xmax, (xmax - xmin) / N).map(function (d) {
                return {x: d, y: f2(d)};
            });

        // Build the plot.
        var plot = xkcdplot();
        plot("body");

        // Add the lines.
        plot.plot(data);
        plot.plot(data2, {stroke: "red"});

        // Render the image.
        plot.xlim([-1.5, 7.5]).draw();

    </script>

</body>
</html>