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

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more