This notebook sets up a zoomable 2D plot using d3.zoom. It stacks multiple layers (a regl WebGL canvas, an Observable Plot, and an SVG) so that zoom and pan stay in sync across all of them. The goal is to generalize it to other interactive figures in a similar manner. This all operates within Observable Notebook Kit, in which the pagse of this site are written.
The key components are:
- Element stack: A small helper that manages multiple absolutely positioned layers within a container. Each layer can be a canvas, SVG, or any DOM element. The stack handles resizing all layers together and persists resources like WebGL contexts across reactive updates.
- Zoomable axes: A rendering-agnostic zoom controller built on d3.zoom. It tracks the current view as orthographic matrices and exposes d3 scales for coordinate conversion. When the view changes, it notifies listeners so each layer can update accordingly.
- Expandable wrapper: The plot can be expanded to fill most of the viewport by clicking the toggle button in the corner.
- Controls: Adjust shading opacity, shading contrast, and grid intensity using the sliders below. Controls are available in a floating panel when expanded.
const figure = html`<figure>
${stack.element}
<figcaption>Drag points to adjust values. Use mouse wheel to zoom, drag to pan.</figcaption>
</figure>`;
display(expandable(figure, {
width: Math.min(width, 640),
height: Math.min(480, width),
toggleOffset: [-15, -33],
controls: '.plot-controls',
onResize(el, w, h) {
stack.resize(w, h);
axes.updateScales(
stack.elements.plot.scale("x"),
stack.elements.plot.scale("y")
);
regl.dirty = true;
}
}));The code above performs final display of the figure, wrapping it in an expandable container.
The rest of this notebook walks through the configuration. This notebook doesn’t contain all of the code. The imports below pull in some modules and helper code used throughout.
import createREGL from 'npm:regl@2.1.1'
import { createElementStack } from './lib/element-stack.js'
import { reglElement, reglAxesViewport } from './lib/regl-canvas.js'
import { createZoomableAxes } from './lib/zoomable-axes.js'
import { expandable } from './lib/expandable.js'We’ll work top-down, drilling down on the particular element implementations. Next is to instantiate the stack container. This is a persistent container that will hold the plot. This awful statefulness is critical when using WebGL, because a cell dependency on the page width would trigger reinsantiation of the WebGL context, which inevitably leads to lost WebGL contexts and much gnashing of teeth.
const stack = createElementStack({
layers: [{
id: 'regl',
element: reglElement(createREGL, {
extensions: ['OES_standard_derivatives'],
attributes: { depthStencil: false, preserveDrawingBuffer: true }
})
}, {
id: 'plot',
element: ({ width, height }) => createPlot(width, height)
}, {
id: 'svg',
element: ({ current, width, height }) =>
(current ? d3.select(current) : d3.create("svg"))
.attr("width", width)
.attr("height", height)
.node()
}]
});Next, we provide a function to instantiate the Observable Plot. Since we don’t reuse it, we don’t have to be too careful. The only part we do reuse is the x and y domains, so we need to be careful to pass that back in for reuse.
function createPlot(width, height, xDomain = [-2, 2], yDomain = [-2, 2]) {
return Plot.plot({
width,
height,
marginTop: 10,
marginRight: 10,
marginLeft: 40,
marginBottom: 20,
style: { backgroundColor: "transparent", maxWidth: "none", position: "absolute" },
x: { domain: xDomain, tickSpacing: 100 },
y: { domain: yDomain, tickSpacing: 100 },
marks: [
Plot.ruleX([0], { stroke: "#0002" }),
Plot.ruleY([0], { stroke: "#0002" })
]
});
}We’ll choose to use the x- and y-axes instances from the Observable Plot as our scale instances. It feels circular, but we’ll set it up so that when the axes change, we replace the plot instance while re-injecting the previous x and y extents.
const axes = createZoomableAxes({
d3,
element: stack.elements.svg,
xScale: stack.elements.plot.scale("x"),
yScale: stack.elements.plot.scale("y"),
aspectRatio: 1,
onChange: ({ xDomain, yDomain }) => {
regl.dirty = true;
const newPlot = createPlot(stack.width, stack.height, xDomain, yDomain);
stack.elements.plot.replaceWith(newPlot);
stack.elements.plot = newPlot;
stack.dispatchEvent(new CustomEvent('update'));
}
});Next, we wire up some actual math. As a test case, we render a domain coloring of a Möbius transformation. The function maps complex numbers according to
where , , and are control points you can drag to explore the transformation. By construction, , , and . Möbius transformations are conformal (angle-preserving) and map circles to circles. Neat!
const controlPoints = {
a: { x: -1, y: 0.5 },
m: { x: 0, y: 0 },
b: { x: 1, y: -0.5 }
};Let’s create a SVG layer and wire up some D3 controls for these points. The SVG will get layered on top of everything else for interactivity.
const svg = d3.select(stack.elements.svg);
// Add clipPath for viewport
const defs = svg.selectAll('defs').data([0]).join('defs');
const clipRect = defs.selectAll('clipPath#viewport-clip')
.data([0])
.join('clipPath')
.attr('id', 'viewport-clip')
.selectAll('rect')
.data([0])
.join('rect');
// Clipped group for control points
const clippedGroup = svg.selectAll('g.clipped')
.data([0])
.join('g')
.attr('class', 'clipped')
.attr('clip-path', 'url(#viewport-clip)');
const circleData = [
{ id: 'a', point: controlPoints.a, color: 'rgb(51, 85, 238)', label: 'a' },
{ id: 'm', point: controlPoints.m, color: 'rgb(51, 85, 238)', label: 'm' },
{ id: 'b', point: controlPoints.b, color: 'rgb(51, 85, 238)', label: 'b' }
];
const circles = clippedGroup.selectAll('circle.control')
.data(circleData, d => d.id)
.join('circle')
.attr('class', 'control')
.attr('r', 6)
.attr('fill', d => d.color)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('cursor', 'move')
.call(d3.drag()
.on('start', function() { d3.select(this).attr('cursor', 'grabbing'); })
.on('drag', function(event, d) {
d.point.x = axes.xScale.invert(event.x);
d.point.y = axes.yScale.invert(event.y);
updatePositions();
regl.dirty = true;
})
.on('end', function() { d3.select(this).attr('cursor', 'move'); })
);
const labels = clippedGroup.selectAll('text.label')
.data(circleData, d => d.id)
.join('text')
.attr('class', 'label')
.attr('text-anchor', 'middle')
.attr('font-family', 'serif')
.attr('font-size', '18px')
.attr('font-style', 'italic')
.attr('fill', '#333')
.style('text-shadow', [[-1, 0, 2.5], [1, 0, 2.5], [0, 1, 2.5], [0, -1, 2.5]].map(([i, j, sh]) => `${i}px ${j}px ${sh}px white`).join(', '))
.text(d => d.label);
function updatePositions() {
// Update clip rect to match viewport
const [x0, x1] = axes.xRange;
const [y0, y1] = axes.yRange;
clipRect
.attr('x', Math.min(x0, x1))
.attr('y', Math.min(y0, y1))
.attr('width', Math.abs(x1 - x0))
.attr('height', Math.abs(y1 - y0));
circles
.attr('cx', d => axes.xScale(d.point.x))
.attr('cy', d => axes.yScale(d.point.y));
labels
.attr('x', d => axes.xScale(d.point.x) + 8)
.attr('y', d => axes.yScale(d.point.y) + 18);
}
updatePositions();
stack.addEventListener('update', updatePositions);Finally, we use the regl library to plot the complex field with some nice domain coloring.
const regl = stack.elements.regl.value;import { createPolarDomainColoringShader } from './domain-coloring.js'
const drawGradient = regl({
vert: `
precision highp float;
attribute vec2 uv;
varying vec2 z;
uniform mat4 viewInverse;
void main () {
z = (viewInverse * vec4(uv, 0, 1)).xy;
gl_Position = vec4(uv, 0, 1);
}`,
frag: `
#extension GL_OES_standard_derivatives : enable
precision highp float;
varying vec2 z;
uniform vec2 a, m, b;
uniform float pixelRatio;
uniform float uShadingOpacity, uGridIntensity, uShadingContrast;
vec2 cmul(vec2 a, vec2 b) { return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); }
vec2 cdiv(vec2 a, vec2 b) { return vec2(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / dot(b, b); }
vec2 f(vec2 z) { return cdiv(cmul(z - a, b - m), cmul(z - b, a - m)); }
${createPolarDomainColoringShader()}
void main () {
vec2 w = f(z);
vec4 color = domainColoring(
vec4(w, fwidth(w) * pixelRatio),
vec2(2.0, 2.0), // steps
vec2(0.1), // scale
vec2(uGridIntensity), // gridOpacity
vec2(uShadingOpacity * uShadingContrast), // shadingOpacity
0.25, // lineWidth
0.4, // lineFeather
vec3(0.0), // gridColor
4.0 // contrastPower
);
gl_FragColor = vec4(color.rgb, 1.0);
}`,
uniforms: {
viewInverse: regl.prop('viewInverse'),
pixelRatio: regl.context('pixelRatio'),
a: regl.prop('a'),
m: regl.prop('m'),
b: regl.prop('b'),
uShadingOpacity: () => params.shadingOpacity,
uGridIntensity: () => params.gridIntensity,
uShadingContrast: () => params.shadingContrast,
},
attributes: { uv: [-4, -4, 4, -4, 0, 4] },
depth: { enable: false },
scissor: { enable: true, box: reglAxesViewport(axes) },
viewport: reglAxesViewport(axes),
count: 3,
});Our regl loop is a bit lazy. We just tack on a mutable dirty property to the HTML element, call the render function at 60fps, and exit early if dirty is false. Good enough!
regl.dirty = true;
let loop = regl.frame(() => {
try {
if (!regl.dirty) return;
drawGradient({
viewInverse: axes.viewInverse,
a: [controlPoints.a.x, controlPoints.a.y],
m: [controlPoints.m.x, controlPoints.m.y],
b: [controlPoints.b.x, controlPoints.b.y]
});
regl.dirty = false;
} catch (e) {
loop?.cancel();
loop = undefined;
}
});
invalidation.then(() => loop?.cancel());Is this good? Bad? Overwrought? I’m not sure. It’s a lot. But then, it layers WebGL, Observable Plot, and SVG into a single interactive plot with some nice conveniences. And there’s no reason you couldn’t add any additional technology you please. It’s a healthy chunk of code, and the full source includes a few more helper function used above. What I am sure of is that the output seems, to me, humbly, really good. These things are very tedious to get right, and I’m happy so far with how this looks. It could certainly be wrapped up a bit more for convenience, at least when the full flexibility of the above system is not needed. But I might try to give this a go in a place or two.