Public
Edited
Nov 17, 2022
Insert cell
Insert cell
Insert cell
{
return html`

<div class='label'>SVG (in black), Webgl (in blue)</div>

<div style="width: ${w}px; height: ${h}px; border: 2px dashed red">
<div style="position:absolute;">
${svg}
</div>
<div style="position:absolute">
${glcanv}
</div>
</div>
`;
}
Insert cell
svg = {
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, w, h])
.attr("width", w)
.attr("height", h)
.attr("id", "svg-layer")
.classed("shared-zoom", true);

const g = svg.append("g");

g.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("r", 2);

return svg.node();
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
zoomBehavior = {
// single, shared zoom state
const zoom = d3
.zoom()
.extent([
[0, 0],
[w, h]
])
.scaleExtent([1, 500])
.on("zoom", zoomed);

function zoomed() {
const { transform } = d3.event;

// (Just for display above the image.)
state.transform = transform;

// 2 different *drawing* functions.

// SVG can just have the transform applied directly to the existing elements.
d3.select(svg).selectAll("g").attr("transform", transform);

// Regl clearing outside the rendering call.
regl.clear({ color: [0, 0, 0, 0], depth: 1 });
renderer({ transform: transform });
}

return zoom;
}
Insert cell
Insert cell
zoomBehavior(d3.selectAll(".shared-zoom"))
Insert cell
md`
### WebGL approach

For webgl, the trick is that you need you need three separate 3x3 affine transformation matrices. (Although calling them affine is a little fancy; really, they do only scale and translate, but *not* rotation).

1. One that projects from the arbitrary data values into the (top-left oriented) coordinate space used by canvas and svg.
2. The 3x3 zoom matrix that can be derived from a d3 zoom state. This changes with zoom level.
3. A 3x3 matrix to project from svg/canvas coordinates into the -1, 1 space used by webgl. (Including a flip on the y axis, since webgl is centered on the bottom left.) This is the same across all zooms.

Although there are three matrices passed to webgl, they can be multiplied by each other at the base level of the webgl function--only a single multiplication is required for each individual point, which is where things might get computationally expensive.

The advantage of this is that you can easily overlay webgl elements on top of SVG or canvas ones, or vice versa. So if you want to use d3-annotate (svg only, last I checked) with a webgl visualization, you can do so at any level of zoom.
`
Insert cell
Insert cell
renderer = {
const [window_transformation_matrix, untransform_matrix] = window_transform(scales.x, scales.y, w, h).map(d => d.flat())
const parameters = {
depth: { enable: false },
stencil: { enable: false },
frag: `
precision mediump float;
varying vec4 fill;
void main() {
vec2 cxy = 2.0 * gl_PointCoord - 1.0;
float r = dot(cxy, cxy);
if (r > 1.0) discard;
gl_FragColor = fill;
}
`,
vert: `
precision mediump float;
attribute vec2 position;
// Base point size
uniform float u_size;
// The d3-scale factor.
uniform float u_k;
varying vec4 fill;

// Transform from data space to the open window.
uniform mat3 u_window_scale;
// Transform from the open window to the d3-zoom.
uniform mat3 u_zoom;
uniform mat3 u_untransform;

// We can bundle the three matrices together here for all shaders.
mat3 from_coord_to_gl = u_window_scale * u_zoom * u_untransform;

void main() {
vec3 pos2d = vec3(position.x, position.y, 1.0) * from_coord_to_gl;
gl_Position = vec4(pos2d, 1.0);
// Smooth point size scaling.
gl_PointSize = 4.0 * (exp(log(u_k)*0.5));
fill =vec4(0.3, 0.1, 0.9, 1.0);
}
`,
attributes: {
position: {
buffer: regl_buffer,
stride: 8,
offset: 0
},
},

uniforms: {
u_k: function(context, props) {
return props.transform.k;
},
u_window_scale: window_transformation_matrix,
u_untransform: untransform_matrix,
u_zoom: function(context, props) {
return [
// This is how you build a transform matrix from d3 zoom.
[props.transform.k, 0, props.transform.x],
[0, props.transform.k, props.transform.y],
[0, 0, 1],
].flat()
},
u_size: 4
},
count: 300,
primitive: "points"
}
const regl_call = regl(parameters)
regl_call({transform: d3.zoomIdentity})
return regl_call
}
Insert cell
Insert cell
Insert cell
Insert cell
function window_transform(x_scale, y_scale, width, height) {
// A function that creates the two matrices a webgl shader needs, in addition to the zoom state,
// to stay aligned with canvas and d3 zoom.
// width and height are svg parameters; x and y scales project from the data x and y into the
// the webgl space.
// Given two d3 scales in coordinate space, create two matrices that project from the original
// space into [-1, 1] webgl space.
function gap(array) {
// Return the magnitude of a scale.
return array[1] - array[0]
}
let x_mid = d3.mean(x_scale.domain())
let y_mid = d3.mean(y_scale.domain())
const xmulti = gap(x_scale.range())/gap(x_scale.domain());
const ymulti = gap(y_scale.range())/gap(y_scale.domain());
// the xscale and yscale ranges may not be the full width or height.
const aspect_ratio = width/height;

// translates from data space to scaled space.
const m1 = [
// transform by the scale;
[xmulti, 0, -xmulti * x_mid + d3.mean(x_scale.range()) ],
[0, ymulti, -ymulti * y_mid + d3.mean(y_scale.range()) ],
[0, 0, 1]
]
// translate from scaled space to webgl space.
// The '2' here is because webgl space runs from -1 to 1; the shift at the end is to
// shift from [0, 2] to [-1, 1]
const m2 = [
[2 / width, 0, -1],
[0, - 2 / height, 1],
[0, 0, 1]
]
return [m1, m2]
}
Insert cell
regl = wrapREGL(glcanv);

Insert cell
// Hide the transform updates from observable for printing.
state = new Object()

Insert cell
md`## Create the webgl canvas`
Insert cell
glcanv = {
const canvas= d3.create("canvas") .attr("width", w).attr("height", h).classed("shared-zoom", true).attr("id", "glcanv")
return canvas.node()
}
Insert cell
md`
# Scales

The data is in an arbitrary space: D3 scales map it into the window coordinate space.

In this case, it is also important to use a shared coordinate system.

`
Insert cell
scales = {
const square_box = d3.min([w, h])
const scales = {};
for (let [name, dim] of [['x', w], ['y', h]]) {
const buffer = (dim - square_box)/2
scales[name] =
d3.scaleLinear()
.domain(d3.extent(raw_data.map(d => d[name])))
.range([buffer, dim-buffer])
}
return scales
}
Insert cell
data = raw_data.map( (d, i) => {return {x: scales.x(d.x), y: scales.y(d.y), i:i}})
Insert cell
md`# Toy data

Toy data: from https://observablehq.com/@fil/phyllotaxis-explained.

`
Insert cell
import { f } from "@fil/phyllotaxis-explained"
Insert cell
raw_data = {
const d = []
for (let i = 0; i < 300; i++) {
const [x, y] = f(i)
d.push({x: x + 10, y: y + 10, i})
}
return d
}
Insert cell
wrapREGL = require('regl')
Insert cell
d3 = require('d3@5')
Insert cell
html`<style> .label { width:${w}px; } </style>`
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more