diff -r efd9c589177a -r c0b4a8b5a012 toolkit/javascript/d3/src/chart/horizon.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/toolkit/javascript/d3/src/chart/horizon.js Thu Apr 10 14:20:23 2014 +0200 @@ -0,0 +1,203 @@ +// Implements a horizon layout, which is a variation of a single-series +// area chart where the area is folded into multiple bands. Color is used to +// encode band, allowing the size of the chart to be reduced significantly +// without impeding readability. This layout algorithm is based on the work of +// J. Heer, N. Kong and M. Agrawala in "Sizing the Horizon: The Effects of Chart +// Size and Layering on the Graphical Perception of Time Series Visualizations", +// CHI 2009. http://hci.stanford.edu/publications/2009/heer-horizon-chi09.pdf +d3.chart.horizon = function() { + var bands = 1, // between 1 and 5, typically + mode = "offset", // or mirror + interpolate = "linear", // or basis, monotone, step-before, etc. + x = d3_chart_horizonX, + y = d3_chart_horizonY, + w = 960, + h = 40, + duration = 0; + + var color = d3.scale.linear() + .domain([-1, 0, 1]) + .range(["#d62728", "#fff", "#1f77b4"]); + + // For each small multiple… + function horizon(g) { + g.each(function(d, i) { + var g = d3.select(this), + n = 2 * bands + 1, + xMin = Infinity, + xMax = -Infinity, + yMax = -Infinity, + x0, // old x-scale + y0, // old y-scale + id; // unique id for paths + + // Compute x- and y-values along with extents. + var data = d.map(function(d, i) { + var xv = x.call(this, d, i), + yv = y.call(this, d, i); + if (xv < xMin) xMin = xv; + if (xv > xMax) xMax = xv; + if (-yv > yMax) yMax = -yv; + if (yv > yMax) yMax = yv; + return [xv, yv]; + }); + + // Compute the new x- and y-scales. + var x1 = d3.scale.linear().domain([xMin, xMax]).range([0, w]), + y1 = d3.scale.linear().domain([0, yMax]).range([0, h * bands]); + + // Retrieve the old scales, if this is an update. + if (this.__chart__) { + x0 = this.__chart__.x; + y0 = this.__chart__.y; + id = this.__chart__.id; + } else { + x0 = d3.scale.linear().domain([0, Infinity]).range(x1.range()); + y0 = d3.scale.linear().domain([0, Infinity]).range(y1.range()); + id = ++d3_chart_horizonId; + } + + // We'll use a defs to store the area path and the clip path. + var defs = g.selectAll("defs") + .data([data]); + + var defsEnter = defs.enter().append("svg:defs"); + + // The clip path is a simple rect. + defsEnter.append("svg:clipPath") + .attr("id", "d3_chart_horizon_clip" + id) + .append("svg:rect") + .attr("width", w) + .attr("height", h); + + defs.select("rect").transition() + .duration(duration) + .attr("width", w) + .attr("height", h); + + // The area path is rendered with our resuable d3.svg.area. + defsEnter.append("svg:path") + .attr("id", "d3_chart_horizon_path" + id) + .attr("d", d3_chart_horizonArea + .interpolate(interpolate) + .x(function(d) { return x0(d[0]); }) + .y0(h * bands) + .y1(function(d) { return h * bands - y0(d[1]); })) + .transition() + .duration(duration) + .attr("d", d3_chart_horizonArea + .x(function(d) { return x1(d[0]); }) + .y1(function(d) { return h * bands - y1(d[1]); })); + + defs.select("path").transition() + .duration(duration) + .attr("d", d3_chart_horizonArea); + + // We'll use a container to clip all horizon layers at once. + g.selectAll("g") + .data([null]) + .enter().append("svg:g") + .attr("clip-path", "url(#d3_chart_horizon_clip" + id + ")"); + + // Define the transform function based on the mode. + var transform = mode == "offset" + ? function(d) { return "translate(0," + (d + (d < 0) - bands) * h + ")"; } + : function(d) { return (d < 0 ? "scale(1,-1)" : "") + "translate(0," + (d - bands) * h + ")"; }; + + // Instantiate each copy of the path with different transforms. + var u = g.select("g").selectAll("use") + .data(d3.range(-1, -bands - 1, -1).concat(d3.range(1, bands + 1)), Number); + + // TODO don't fudge the enter transition + u.enter().append("svg:use") + .attr("xlink:href", "#d3_chart_horizon_path" + id) + .attr("transform", function(d) { return transform(d + (d > 0 ? 1 : -1)); }) + .style("fill", color) + .transition() + .duration(duration) + .attr("transform", transform); + + u.transition() + .duration(duration) + .attr("transform", transform) + .style("fill", color); + + u.exit().transition() + .duration(duration) + .attr("transform", transform) + .remove(); + + // Stash the new scales. + this.__chart__ = {x: x1, y: y1, id: id}; + }); + d3.timer.flush(); + } + + horizon.duration = function(x) { + if (!arguments.length) return duration; + duration = +x; + return horizon; + }; + + horizon.bands = function(x) { + if (!arguments.length) return bands; + bands = +x; + color.domain([-bands, 0, bands]); + return horizon; + }; + + horizon.mode = function(x) { + if (!arguments.length) return mode; + mode = x + ""; + return horizon; + }; + + horizon.colors = function(x) { + if (!arguments.length) return color.range(); + color.range(x); + return horizon; + }; + + horizon.interpolate = function(x) { + if (!arguments.length) return interpolate; + interpolate = x + ""; + return horizon; + }; + + horizon.x = function(z) { + if (!arguments.length) return x; + x = z; + return horizon; + }; + + horizon.y = function(z) { + if (!arguments.length) return y; + y = z; + return horizon; + }; + + horizon.width = function(x) { + if (!arguments.length) return w; + w = +x; + return horizon; + }; + + horizon.height = function(x) { + if (!arguments.length) return h; + h = +x; + return horizon; + }; + + return horizon; +}; + +var d3_chart_horizonArea = d3.svg.area(), + d3_chart_horizonId = 0; + +function d3_chart_horizonX(d) { + return d[0]; +} + +function d3_chart_horizonY(d) { + return d[1]; +}