Reimplementation of Focus + Context visualization, using SVG’s viewBox
attribute for the zoom/pan instead.
This approach uses SVG’s viewBox to achieve the following effects:
focus
and one in context
, with the one in focus
getting re-rendered dynamically on every view change (zoom/pan), and the one in context
only getting rendered once.focus
is three SVGs deep.SVG#aperture
is at the same position and size of the clipping rectangle<g>
element named focus
is replaced by an <svg>
element named ‘focus’SVG#focus
is nested within SVG#aperture
SVG#focus
is at the same position and size of <g#focus>
‘s bounding box<g>
element named context
is replaced by an <svg>
element named ‘context’SVG#context
is at the same position and size of the <g#context>
‘s bounding boxThe layout is something like this:
<svg width=960 height=500>
<SVG#aperture width=900 height=370 viewBox="0 0 900 370" preserveAspectRatio="none">
<SVG#focus width=900 height=370>
<svg#context width=900 height=40 viewBox="0 0 900 370">
<use xlink:href="#focus">
The topmost SVG is the container for everything else. Its width
and height
attributes do two things:
The aperture
SVG sets a width and height for its physical dimensions within the topmost SVG. We set its viewBox
attribute to define its internal (or user) coordinate system. We set its preserveAspectRatio
attribute to none
so that its contents will stretch to fit the entire viewport.
The focus
SVG sets a width and height identical to its parent, thus it fully covers its parent aperture
. This sets both its internal and external dimensions. Since the area chart doesn’t change, the focus
SVG will always show the full area graph. We will use SVG#aperture
‘s viewBox
attribute to display the requested portions of the focus
SVG.
Finally the context
SVG is where the width/height and viewBox will diverge: the width is the same, but the height is the height of the actual SVG#context
(40 in this example). The internal coordinates are set to the same as the the focus
SVG. In order to show the contents of SVG#focus
, we use the <use>
call which shows an internal clone of SVG#focus
. Thus, SVG#focus
is simply duplicated and squished to fit the dimensions of SVG#context
. Since context
‘s internal coordinates are the same as focus
‘s dimensions, it will fully fit. Note that context
will also show any animations/transitions/changes that you put on focus
due to being a clone.
The brush on SVG#context
will now be the same dimensions as SVG#focus
, since both coordinate systems are identical. This means that whatever the size of this rectangle is after brushing/zooming will map directly to the new viewBox value in aperture
to implement the actual zoom/pan effect on focus
. Basically aperture
is like a window/lens on top of focus
and we can adjust how close in or how far out we want to look through that window/lens to see which bits of focus
below.
The axes are contained within the top-level SVG because they need to exist outside of all the zooming/panning happening, and they get updated in the same way as before.
The brush and zoom functions are updated to adjust the aperture’s viewBox as well as the focus
x-axis.
<!DOCTYPE html>
<meta charset="utf-8">
<style>
.area {
fill: steelblue;
}
.zoom {
cursor: move;
fill: none;
pointer-events: all;
}
</style>
<svg width="960" height="500"></svg>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script>
var svg = d3.select("svg"),
margin = {top: 20, right: 20, bottom: 110, left: 40},
margin2 = {top: 430, right: 20, bottom: 30, left: 40},
width = +svg.attr("width") - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
height2 = +svg.attr("height") - margin2.top - margin2.bottom;
var parseDate = d3.timeParse("%b %Y");
var x = d3.scaleTime().range([0, width]),
x2 = d3.scaleTime().range([0, width]),
y = d3.scaleLinear().range([height, 0]),
y2 = d3.scaleLinear().range([height2, 0]);
var xAxis = d3.axisBottom(x),
xAxis2 = d3.axisBottom(x2),
yAxis = d3.axisLeft(y);
var brush = d3.brushX()
.extent([[0, 0], [width, height]])
.on("brush end", brushed);
var zoom = d3.zoom()
.scaleExtent([1, Infinity])
.translateExtent([[0, 0], [width, height]])
.extent([[0, 0], [width, height]])
.on("zoom", zoomed);
var area = d3.area()
.curve(d3.curveMonotoneX)
.x(function(d) { return x(d.date); })
.y0(height)
.y1(function(d) { return y(d.price); });
var aperture = svg.append("svg")
.attr("id", "aperture")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height].join(' '))
.attr("preserveAspectRatio", "none")
;
var focus = aperture.append("svg")
.attr("id", "focus")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
;
var context = svg.append("svg")
.attr("id", "context")
.attr("x", margin2.left)
.attr("y", margin2.top)
.attr("width", width)
.attr("height", height2)
.attr("viewBox", [0, 0, width, height].join(' '))
.attr("preserveAspectRatio", "none")
;
d3.csv("sp500.csv", type)
.then(function (data) {
x.domain(d3.extent(data, function(d) { return d.date; }));
y.domain([0, d3.max(data, function(d) { return d.price; })]);
x2.domain(x.domain());
y2.domain(y.domain());
focus.append("path")
.datum(data)
.attr("class", "area")
.attr("d", area)
;
svg.append("g")
.attr("class", "axis axis--x focus")
.attr("transform", "translate(" + margin.left + "," + (height + margin.top) + ")")
.call(xAxis)
;
svg.append("g")
.attr("class", "axis axis--y focus")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(yAxis)
;
context.append("use")
.attr("xlink:href", "#focus")
;
svg.append("g")
.attr("class", "axis axis--x context")
.attr("transform", "translate(" + margin.left + "," + (height + height2 + margin.top + height2) + ")")
.call(xAxis2)
;
context.append("g")
.attr("class", "brush")
.call(brush)
.call(brush.move, x.range())
;
focus.append("rect")
.attr("class", "zoom")
.attr("width", width)
.attr("height", height)
.call(zoom);
});
function brushed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
var sel = d3.event.selection || x2.range()
, vb = [sel[0], 0, sel[1] - sel[0], height]
;
aperture.attr("viewBox", vb.join(' '));
x.domain(sel.map(x2.invert, x2));
svg.select(".axis--x.focus").call(xAxis);
}
function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;
x.domain(t.rescaleX(x2).domain());
var rect = x.range().map(t.invertX, t);
aperture.attr("viewBox", [rect[0], 0, rect[1] - rect[0], height].join(' '))
svg.select(".axis--x.focus").call(xAxis);
context.select(".brush").call(brush.move, rect);
}
function type(d) {
d.date = parseDate(d.date);
d.price = +d.price;
return d;
}
</script>
date,price
Jan 2000,1394.46
Feb 2000,1366.42
Mar 2000,1498.58
Apr 2000,1452.43
May 2000,1420.6
Jun 2000,1454.6
Jul 2000,1430.83
Aug 2000,1517.68
Sep 2000,1436.51
Oct 2000,1429.4
Nov 2000,1314.95
Dec 2000,1320.28
Jan 2001,1366.01
Feb 2001,1239.94
Mar 2001,1160.33
Apr 2001,1249.46
May 2001,1255.82
Jun 2001,1224.38
Jul 2001,1211.23
Aug 2001,1133.58
Sep 2001,1040.94
Oct 2001,1059.78
Nov 2001,1139.45
Dec 2001,1148.08
Jan 2002,1130.2
Feb 2002,1106.73
Mar 2002,1147.39
Apr 2002,1076.92
May 2002,1067.14
Jun 2002,989.82
Jul 2002,911.62
Aug 2002,916.07
Sep 2002,815.28
Oct 2002,885.76
Nov 2002,936.31
Dec 2002,879.82
Jan 2003,855.7
Feb 2003,841.15
Mar 2003,848.18
Apr 2003,916.92
May 2003,963.59
Jun 2003,974.5
Jul 2003,990.31
Aug 2003,1008.01
Sep 2003,995.97
Oct 2003,1050.71
Nov 2003,1058.2
Dec 2003,1111.92
Jan 2004,1131.13
Feb 2004,1144.94
Mar 2004,1126.21
Apr 2004,1107.3
May 2004,1120.68
Jun 2004,1140.84
Jul 2004,1101.72
Aug 2004,1104.24
Sep 2004,1114.58
Oct 2004,1130.2
Nov 2004,1173.82
Dec 2004,1211.92
Jan 2005,1181.27
Feb 2005,1203.6
Mar 2005,1180.59
Apr 2005,1156.85
May 2005,1191.5
Jun 2005,1191.33
Jul 2005,1234.18
Aug 2005,1220.33
Sep 2005,1228.81
Oct 2005,1207.01
Nov 2005,1249.48
Dec 2005,1248.29
Jan 2006,1280.08
Feb 2006,1280.66
Mar 2006,1294.87
Apr 2006,1310.61
May 2006,1270.09
Jun 2006,1270.2
Jul 2006,1276.66
Aug 2006,1303.82
Sep 2006,1335.85
Oct 2006,1377.94
Nov 2006,1400.63
Dec 2006,1418.3
Jan 2007,1438.24
Feb 2007,1406.82
Mar 2007,1420.86
Apr 2007,1482.37
May 2007,1530.62
Jun 2007,1503.35
Jul 2007,1455.27
Aug 2007,1473.99
Sep 2007,1526.75
Oct 2007,1549.38
Nov 2007,1481.14
Dec 2007,1468.36
Jan 2008,1378.55
Feb 2008,1330.63
Mar 2008,1322.7
Apr 2008,1385.59
May 2008,1400.38
Jun 2008,1280
Jul 2008,1267.38
Aug 2008,1282.83
Sep 2008,1166.36
Oct 2008,968.75
Nov 2008,896.24
Dec 2008,903.25
Jan 2009,825.88
Feb 2009,735.09
Mar 2009,797.87
Apr 2009,872.81
May 2009,919.14
Jun 2009,919.32
Jul 2009,987.48
Aug 2009,1020.62
Sep 2009,1057.08
Oct 2009,1036.19
Nov 2009,1095.63
Dec 2009,1115.1
Jan 2010,1073.87
Feb 2010,1104.49
Mar 2010,1140.45