Skip to content

Instantly share code, notes, and snippets.

@seemantk
Forked from mbostock/.block
Last active March 5, 2019 16:03
Show Gist options
  • Save seemantk/e7fb1380c6de4d07ef24dba182f57a7e to your computer and use it in GitHub Desktop.
Save seemantk/e7fb1380c6de4d07ef24dba182f57a7e to your computer and use it in GitHub Desktop.
Brush & Zoom using viewBox
license: gpl-3.0

Reimplementation of Focus + Context visualization, using SVG's viewBox attribute for the zoom/pan instead.

Advantages

This approach uses SVG's viewBox to achieve the following effects:

  • The area chart is only ever rendered once, since the underlying dataset never changes throughout this vis.
    • The original had 2 initial renders, one in 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.
  • Modern browsers implement mostly, if not fully complete, implementations of the SVG standard: thus they can all zoom/pan an SVG based on its viewBox values.
    • Thus, instead of recalculating the path on every zoom/pan change, we offload that to the browser.

Drawbacks

  • The overhead of nested SVGs. For this example, focus is three SVGs deep.
  • At high zoom, the SVG rendering breaks down.
    • The initial artifacts appear in the top-right corner (Chromium browser) at the resolution of 3-hours on the x-axis.
    • Larger artifacts start to appear on the right edge at the resolution of 1-hour on the x-axis, breaking the bounds of the container SVG
    • Artifacts appear on the left edge, at the 15-minute resolution on the x-axis, breaking the bounds of the container SVG
    • The graph becomes very jittery during the 5-minute resolution
    • The jittery and container breakouts become grotesque by the 1-minute resolution
    • Gets much worse during 45-second resolution
    • The render breaks down entirely to blank by the 1-second resolution

Implementation

  • The main SVG canvas which contains both the focus and context images remains
    • All of the axes are moved into this top-level SVG
  • The clipping rectangle is replaced by an SVG named 'aperture'
    • SVG#aperture is at the same position and size of the clipping rectangle
      • Its width and height are as calculated
      • Its viewBox is set to be "0 0 width height" so that its coordinate system and physical size match.
  • The <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
  • The <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 box

The 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">

Explanation

The topmost SVG is the container for everything else. Its width and height attributes do two things:

  1. Sets the physical dimensions of the SVG
  2. Sets the internal coordinate system of the SVG

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment