Focus + context is a technique that allows the viewer to inspect an interesting portion of the data in detail (the focus) without losing global context—the global view is preserved at reduced detail, highlighting the focused region. In this visualization, a three-year window is focused within a time series spanning three decades. The focus region can be dynamically resized and repositioned.
Next: Grid Intensity
<html>
<head>
<title>Focus + Context</title>
<link type="text/css" rel="stylesheet" href="ex.css?3.1"/>
<script type="text/javascript" src="../protovis-d3.1.0.js"></script>
<script type="text/javascript" src="zoom.js"></script>
<style type="text/css">
#fig {
width: 860px;
height: 390px;
}
</style>
</head>
<body><div id="center"><div id="fig">
<div style="text-align:right;padding-right:20;">
<input checked id="scale" type="checkbox" onchange="vis.render()">
<label for="scale">Scale to fit</label>
</div>
<script type="text/javascript+protovis">
/* Scales and sizing. */
var w = 810,
h1 = 300,
h2 = 30,
d1 = new Date(start + year * 5),
d2 = new Date(start + year * 8),
da, db, dc, dd,
x = pv.Scale.linear(start, end).range(0, w),
y = pv.Scale.linear(0, pv.max(data, function(d) d.y)).range(0, h2);
/* Root panel. */
var vis = new pv.Panel()
.width(w)
.height(h1 + 20 + h2)
.bottom(20)
.left(30)
.right(20)
.top(5);
/* Focus panel (zoomed in). */
var focus = vis.add(pv.Panel)
.def("d", function() data.slice(
Math.max(0, pv.search.index(data, d1, function(d) d.x) - 1),
pv.search.index(data, d2, function(d) d.x) + 1))
.def("x", function() pv.Scale.linear(d1, d2).range(0, w))
.def("y", function() pv.Scale.linear(scale.checked
? [0, pv.max(this.d(), function(d) d.y)]
: y.domain()).range(0, h1))
.top(0)
.height(h1);
/* Y-axis ticks. */
focus.add(pv.Rule)
.data(function() focus.y().ticks())
.bottom(function(d) Math.round(focus.y()(d)) - .5)
.strokeStyle(function(d) d ? (this.index % 2) ? "#eee" : "#aaa" : "#000")
.anchor("left").add(pv.Label)
.visible(function(d) d && !(this.index % 2))
.text(function(d) focus.y().tickFormat(d));
/* X-axis ticks. */
focus.add(pv.Rule)
.data(function() focus.x().ticks().map(function(t) new Date(t)))
.left(function(d) Math.round(focus.x()(d)) - .5)
.strokeStyle("#eee")
.anchor("bottom").add(pv.Label)
.text(function(d) d.format("%m/%Y"));
/* Focus area chart. */
focus.add(pv.Panel)
.overflow("hidden")
.add(pv.Area)
.data(function() focus.d())
.left(function(d) focus.x()(d.x))
.bottom(0)
.height(function(d) focus.y()(d.y))
.fillStyle("lightsteelblue")
.anchor("top").add(pv.Line)
.fillStyle(null)
.strokeStyle("steelblue")
.lineWidth(2);
/* Context panel (zoomed out). */
var context = vis.add(pv.Panel)
.bottom(0)
.height(h2);
/* Y-axis ticks. */
context.add(pv.Rule)
.bottom(-.5)
.strokeStyle("#000");
/* X-axis ticks. */
context.add(pv.Rule)
.data(pv.range(0, 21, 2).map(function(x) new Date(1990 + x, 0, 0)))
.visible(function(d) d > 0)
.left(function(d) Math.round(x(d)) - .5)
.strokeStyle("#eee")
.anchor("bottom").add(pv.Label)
.text(function(d) d.format("%Y"));
/* Context area chart. */
context.add(pv.Area)
.data(data)
.left(function(d) x(d.x))
.bottom(0)
.height(function(d) y(d.y))
.fillStyle("lightsteelblue")
.anchor("top").add(pv.Line)
.fillStyle(null)
.strokeStyle("steelblue")
.lineWidth(2);
/* Handle mousedown to start new selection. */
context.add(pv.Bar)
.fillStyle("rgba(255,255,255,.01)")
.cursor(function() dc ? "ew-resize" : "crosshair")
.event("mousedown", function() {
dd = x.invert(vis.mouse().x);
vis.render();
});
/* Handle mousedown to start selection drag. */
context.add(pv.Bar)
.left(function() Math.round(x(d1)) + .5)
.width(function() Math.round(x(d2) - x(d1)))
.fillStyle("rgba(255,128,128,.4)")
.cursor(function() dd ? "crosshair" : "ew-resize")
.event("mousedown", function() {
da = d1.getTime();
db = d2.getTime();
dc = x.invert(vis.mouse().x);
vis.render();
});
/* Handle mousemove to update selection. */
window.onmousemove = function() {
vis.index = 0;
if (dc) { // drag
var d = x.invert(vis.mouse().x) - dc;
if ((da + d) < start) d = start - da;
else if ((db + d) > end) d = end - db;
d1 = new Date(da + d);
d2 = new Date(db + d);
} else if (dd) { // resize
db = x.invert(vis.mouse().x);
if (dd == db) return;
d1 = new Date(Math.max(start, Math.min(dd, db)));
d2 = new Date(Math.min(end, Math.max(dd, db)));
} else {
return;
}
vis.render();
};
/* Handle mouseup to stop update. */
window.onmouseup = function() {
da = db = dc = dd = null;
vis.render();
};
vis.render();
</script>
</div></div></body>
</html>
var start = new Date(1990, 0, 1).getTime();
var year = 1000 * 60 * 60 * 24 * 365;
var data = pv.range(0, 20, .02).map(function(x) {
return {x: new Date(start + year * x),
y: (1 + .1 * (Math.sin(x * 2 * Math.PI))
+ Math.random() * .1) * Math.pow(1.18, x)
+ Math.random() * .1};
});
var end = data[data.length - 1].x;