Published
Edited
Mar 24, 2020
2 forks
Importers
28 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
zoomBehavior = {
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

// Three different *drawing* functions.
// SVG can just have the transform applied directly to the existing elements.
d3.select(svg).selectAll("g").attr("transform", transform)
// Canvas has the transform applied inside the draw
draw_canvas(transform)
// Regl clearing outside the rendering call.
regl.clear({color: [0, 0, 0, 0],depth: 1})
renderer({transform: transform})
// Apply the zoom transform to the other elements.
// This problem ends up calling the zoom behavior on all events three times per tick; it would be safer
// to manage a separate zoom behavior for each element.
d3.selectAll(".shared-zoom")
.filter(function(d) {
// avoid recursing this call on the element that generated the call.
return d3.zoomTransform(this) != transform
})
.call(zoom.transform, transform);

}
return zoom
}
Insert cell
d3.selectAll(".shared-zoom").call(zoomBehavior)

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
Insert cell
svg = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, w, h])
.attr("width", w)
.attr("id", "svg-layer")
.classed("shared-zoom", true)
.attr("height", h);

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
canvas2d = {
const canvas = d3.create("canvas").attr("width", w).attr("height", h).attr("id", "twod").classed("shared-zoom", true)
return canvas.node()
}
Insert cell
{
// initial draw.
draw_canvas(d3.zoomIdentity)
}
Insert cell
draw_canvas = function(transform) {

// This canvas approach is @fil's.
const {x, y, k} = transform;
// Would be wiser to get the context outside of this function?
const context = canvas2d.getContext("2d")
context.clearRect(0, 0, w, h)

// Point radius is variable here.
const path = d3
.geoPath()
.context(context)
.pointRadius(2 / k);
context.save();
context.translate(x, y);
context.scale(k, k)
for (let d of data) {
context.beginPath(),
path({ type: "Point", coordinates: [d.x, d.y] }),
context.fill();
}
context.restore();

}
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