Create a gist now

Instantly share code, notes, and snippets.

@mbostock /.block
Last active Jul 21, 2016

Horizon Chart
license: gpl-3.0

Horizon charts combine position and color to reduce vertical space. Start with a standard area chart, then mirror negative values (in blue) or offset them vertically. Click the + button above to increase the number of bands, turning the area into a horizon.

Implemented with the d3.horizon plugin.

(function() {
d3.horizon = function() {
var bands = 1, // between 1 and 5, typically
mode = "offset", // or mirror
interpolate = "linear", // or basis, monotone, step-before, etc.
x = d3_horizonX,
y = d3_horizonY,
w = 960,
h = 40,
duration = 0;
var color = d3.scale.linear()
.domain([-1, 0, 0, 1])
.range(["#08519c", "#bdd7e7", "#bae4b3", "#006d2c"]);
// 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, and transform.
var x1 = d3.scale.linear().domain([xMin, xMax]).range([0, w]),
y1 = d3.scale.linear().domain([0, yMax]).range([0, h * bands]),
t1 = d3_horizonTransform(bands, h, mode);
// Retrieve the old scales, if this is an update.
if (this.__chart__) {
x0 = this.__chart__.x;
y0 = this.__chart__.y;
t0 = this.__chart__.t;
id = this.__chart__.id;
} else {
x0 = x1.copy();
y0 = y1.copy();
t0 = t1;
id = ++d3_horizonId;
}
// We'll use a defs to store the area path and the clip path.
var defs = g.selectAll("defs")
.data([null]);
// The clip path is a simple rect.
defs.enter().append("defs").append("clipPath")
.attr("id", "d3_horizon_clip" + id)
.append("rect")
.attr("width", w)
.attr("height", h);
defs.select("rect").transition()
.duration(duration)
.attr("width", w)
.attr("height", h);
// We'll use a container to clip all horizon layers at once.
g.selectAll("g")
.data([null])
.enter().append("g")
.attr("clip-path", "url(#d3_horizon_clip" + id + ")");
// Instantiate each copy of the path with different transforms.
var path = g.select("g").selectAll("path")
.data(d3.range(-1, -bands - 1, -1).concat(d3.range(1, bands + 1)), Number);
var d0 = d3_horizonArea
.interpolate(interpolate)
.x(function(d) { return x0(d[0]); })
.y0(h * bands)
.y1(function(d) { return h * bands - y0(d[1]); })
(data);
var d1 = d3_horizonArea
.x(function(d) { return x1(d[0]); })
.y1(function(d) { return h * bands - y1(d[1]); })
(data);
path.enter().append("path")
.style("fill", color)
.attr("transform", t0)
.attr("d", d0);
path.transition()
.duration(duration)
.style("fill", color)
.attr("transform", t1)
.attr("d", d1);
path.exit().transition()
.duration(duration)
.attr("transform", t1)
.attr("d", d1)
.remove();
// Stash the new scales.
this.__chart__ = {x: x1, y: y1, t: t1, 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, 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_horizonArea = d3.svg.area(),
d3_horizonId = 0;
function d3_horizonX(d) {
return d[0];
}
function d3_horizonY(d) {
return d[1];
}
function d3_horizonTransform(bands, h, mode) {
return 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 + ")"; };
}
})();
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
font-family: sans-serif;
}
svg {
position: absolute;
top: 0;
}
#horizon-controls {
position: absolute;
width: 940px;
padding: 10px;
z-index: 1;
}
#horizon-bands {
float: right;
}
</style>
<div id="horizon-controls">
<input name="mode" type="radio" value="mirror" id="horizon-mode-mirror" checked><label for="horizon-mode-mirror"> Mirror</label>
<input name="mode" type="radio" value="offset" id="horizon-mode-offset"><label for="horizon-mode-offset"> Offset</label>
<span id="horizon-bands"><span id="horizon-bands-value">1</span> <button class="first">&#x2212;</button><button class="last">+</button></span>
</div>
<div id="horizon-chart"></div>
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="horizon.js?0.0.1"></script>
<script>
var width = 960,
height = 500;
var chart = d3.horizon()
.width(width)
.height(height)
.bands(1)
.mode("mirror")
.interpolate("basis");
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
d3.json("unemployment.json", function(error, data) {
if (error) throw error;
// Offset so that positive is above-average and negative is below-average.
var mean = data.rate.reduce(function(p, v) { return p + v; }, 0) / data.rate.length;
// Transpose column values to rows.
data = data.rate.map(function(rate, i) {
return [Date.UTC(data.year[i], data.month[i] - 1), rate - mean];
});
// Render the chart.
svg.data([data]).call(chart);
// Enable mode buttons.
d3.selectAll("#horizon-controls input[name=mode]").on("change", function() {
svg.call(chart.duration(0).mode(this.value));
});
// Enable bands buttons.
d3.selectAll("#horizon-bands button").data([-1, 1]).on("click", function(d) {
var n = Math.max(1, chart.bands() + d);
d3.select("#horizon-bands-value").text(n);
svg.call(chart.duration(1000).bands(n).height(height / n));
});
});
</script>
{"year":[2000,2000,2000,2000,2000,2000,2000,2000,2000,2000,2000,2000,2001,2001,2001,2001,2001,2001,2001,2001,2001,2001,2001,2001,2002,2002,2002,2002,2002,2002,2002,2002,2002,2002,2002,2002,2003,2003,2003,2003,2003,2003,2003,2003,2003,2003,2003,2003,2004,2004,2004,2004,2004,2004,2004,2004,2004,2004,2004,2004,2005,2005,2005,2005,2005,2005,2005,2005,2005,2005,2005,2005,2006,2006,2006,2006,2006,2006,2006,2006,2006,2006,2006,2006,2007,2007,2007,2007,2007,2007,2007,2007,2007,2007,2007,2007,2008,2008,2008,2008,2008,2008,2008,2008,2008,2008,2008,2008,2009,2009,2009,2009,2009,2009,2009,2009,2009,2009,2009,2009,2010,2010],"month":[1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2,3,4,5,6,7,8,9,10,11,12,1,2],"rate":[4.5,4.4,4.3,3.7,3.8,4.1,4.2,4.1,3.8,3.6,3.7,3.7,4.7,4.6,4.5,4.2,4.1,4.7,4.7,4.9,4.7,5,5.3,5.4,6.3,6.1,6.1,5.7,5.5,6,5.9,5.7,5.4,5.3,5.6,5.7,6.5,6.4,6.2,5.8,5.8,6.5,6.3,6,5.8,5.6,5.6,5.4,6.3,6,6,5.4,5.3,5.8,5.7,5.4,5.1,5.1,5.2,5.1,5.7,5.8,5.4,4.9,4.9,5.2,5.2,4.9,4.8,4.6,4.8,4.6,5.1,5.1,4.8,4.5,4.4,4.8,5,4.6,4.4,4.1,4.3,4.3,5,4.9,4.5,4.3,4.3,4.7,4.9,4.6,4.5,4.4,4.5,4.8,5.4,5.2,5.2,4.8,5.2,5.7,6,6.1,6,6.1,6.5,7.1,8.5,8.9,9,8.6,9.1,9.7,9.7,9.6,9.5,9.5,9.4,9.7,10.6,10.4]}
@dimerman

is there a way to set the y-axis domain for the horizon chart ? yMax is calculated on lines 34:35 and used in :41 but I see no interface to set it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment