Surface plot
Drag the figure to rotate it, hover a quad to read the sample under it. The pickers swap the shape and the color ramp, the sliders reshape the surface, the checkboxes fit the ramp to the surface and strip it down to a wireframe, and the button samples new data at the same amplitude and frequency and morphs the surface into it.
A surface is a grid of quads
The figure samples a function over a field of 32 by 32 points and hands the flat point list to gridPlanes3D. The rows accessor tells the renderer how many points one row holds, and data cuts the rows into quads:
const surface = gridPlanes3D()
.scale(10)
.origin({ x: 480, y: 250 })
.rotateY(Math.PI / 4)
.rotateX(-Math.PI / 4)
.rows(32);
const quads = surface.data(points);Every quad comes back with three extra properties: a stable plane key, its centroid and the ccw flag, which tells whether the quad is turned towards the camera.
Two shapes, one sampled function
A shape is a function of the amplitude A, the frequency f and the phase φ that returns the height at x and z. The ripple is y = A · cos(2f · r + φ), the cosine of the distance r from the centre; the wave is y = A · sin(f · x + φ) · cos(f · z + φ), a sine along x times a cosine along z:
const SHAPES = {
ripple: (amplitude, frequency, phase) => (x, z) =>
amplitude * Math.cos(2 * frequency * Math.hypot(x, z) + phase),
wave: (amplitude, frequency, phase) => (x, z) =>
amplitude * Math.sin(frequency * x + phase) * Math.cos(frequency * z + phase)
};The picker chooses the shape, the amplitude slider sets A and the frequency slider sets f. Every input event samples the shape again and draws it without a transition; a new shape morphs in:
function update(duration) {
const points = sample(
SHAPES[shape.value](amplitude.valueAsNumber, frequency.valueAsNumber, phase)
);
show(points, duration);
}
shape.addEventListener('change', () => update());
amplitude.addEventListener('input', () => update(0));
frequency.addEventListener('input', () => update(0));The button leaves the sliders alone. It picks a random phase, which shifts the rings and the hills to new places at the same height and spacing, and morphs the surface into the new data:
button.on('click', () => {
phase = randomUniform(0, 2 * Math.PI)();
update();
});The frequency stops at 0.6. The field is sampled at whole units, so a faster wave gets fewer samples per hill, and the quads start to show as zig-zags.
Shading without a light
There is no lighting model in the figure. The height of a quad colors it and the ccw flag shades it: a quad that faces the camera gets the ramp at its mean height, a quad that faces away gets a darker tint of the same color, so the two sides of the surface are told apart at a glance:
function colorize(d) {
const height = (d[0].y + d[1].y + d[2].y + d[3].y) / 4;
const base = color(height);
return d.ccw ? base : rgb(base).darker(2.5).formatHex();
}The color function is the ramp the picker selects, through a color scale:
const colorScale = scaleLinear();
const { group, button, show } = surfaceFigure(
host,
'surface',
'A surface plot. Drag the figure to rotate it.',
(height) => RAMPS[ramp.value](colorScale(height))
);The fit checkbox decides what the scale spans. Fitted, it spans the heights of the current samples, so every surface runs through the whole ramp, from its lowest quad to its highest. Unfitted, it spans the whole amplitude range, from -12 to 12: a low surface stays in the middle of the ramp, so the amplitude reads in the colors as well as in the shape:
colorScale.domain(fit.checked ? extent(points, (d) => d.y) : [-12, 12]);The painter's algorithm
The quads are drawn as flat SVG paths, so their order decides which one wins. The sort utility orders the paths by the depth of their rotated centre, and the painter's algorithm does the rest:
group.selectAll('path').sort(sort);The sort runs after every draw: the drag changes the depth of every quad, so the order has to be recomputed with every frame.
One join for the whole life of the figure
The paths are joined on the stable plane key, so all 961 quads are created once and never enter or exit again. A rotation only updates the d attribute and the fill of the existing paths. The first surface fades in, and every new wave morphs the surface through the same transition:
const quads = group.selectAll('path').data(planes, (d) => d.plane);
quads
.enter()
.append('path')
.attr('class', 'quad')
.attr('fill', colorize)
.attr('opacity', 0)
.merge(quads)
.transition()
.duration(duration)
.attr('opacity', 1)
.attr('fill', colorize)
.attr('d', surface.draw);Reading the original coordinates
A transformed point keeps the properties of its input next to its rotated and projected coordinates, so d[0].y is still the height of the sample, not a screen coordinate. The tooltip needs no lookup table: one listener on the group of quads takes the quad under the pointer from the event target, reads the sample straight off its first corner and hides while a button is pressed, so it does not trail behind a drag:
function showTooltip(event) {
tooltip.hidden = event.buttons !== 0;
if (tooltip.hidden) {
return;
}
const d = select(event.target).datum();
const [left, top] = pointer(event, host);
tooltip.textContent = `x ${d[0].x}, z ${d[0].z}, y ${d[0].y.toFixed(2)}`;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.classList.toggle('is-left', left > host.clientWidth / 2);
}
group.on('pointermove', showTooltip).on('pointerleave', () => {
tooltip.hidden = true;
});The listener sits on the group, not on the quads, so the join needs no hook for it.
A wireframe is a class
The checkbox toggles a class on the figure and nothing else. A CSS rule beats the fill attribute of the paths, so the wireframe needs no redraw, and pointer-events: all keeps the hollow quads hoverable:
.surface-plot.is-wireframe .quad {
fill: none;
stroke-opacity: 0.6;
pointer-events: all;
}The whole source
The figure lives in docs/examples/surfacePlot.ts in the repository, its styles in docs/examples/surfacePlot.css. The grid of quads, the shading and the join live in docs/examples/surfaceFigure.ts, and the SVG and the drag in docs/examples/rotatingFigure.ts. Both files, ready to copy:
The grid of quads, the shading and the join:
import { rgb } from 'd3';
import type { Selection } from 'd3';
import { gridPlanes3D, sort } from '../../index';
import { rotatingFigure, turnCamera } from './rotatingFigure';
import type { GridPlane, GridPlanes3DInstance, Point3D } from '../../index';
/*
* The scaffold of the surface plot: gridPlanes3D cuts a sampled field into a
* grid of quads, the drag rotates the scene, a color function shades the
* height of each quad and the `sort` utility paints the quads back to front.
*/
const ORIGIN = { x: 480, y: 250 };
const GRID = 16;
const POINTS_PER_ROW = GRID * 2;
const START_ANGLE = Math.PI / 4;
const INTRO_DURATION = 750;
/** Samples a function over the field, from -GRID to GRID on both axes. */
export function sample(f: (x: number, z: number) => number): Point3D[] {
const points: Point3D[] = [];
for (let z = -GRID; z < GRID; z += 1) {
for (let x = -GRID; x < GRID; x += 1) {
points.push({ x, y: f(x, z), z });
}
}
return points;
}
/**
* Renders the rotating figure into `host`, see `rotatingFigure`, and returns
* the group, the button, `show`, which draws a sampled field, and the
* renderer, whose pose a figure can read or unproject with. `color` maps the
* mean height of a quad to its fill, and `onTurn` runs after every drag
* frame has redrawn the surface.
*/
export function surfaceFigure(
host: HTMLElement,
prefix: string,
label: string,
color: (height: number) => string,
onTurn?: () => void
): {
group: Selection<SVGGElement, unknown, null, undefined>;
button: Selection<HTMLButtonElement, unknown, null, undefined>;
show: (field: Point3D[], duration?: number) => void;
surface: GridPlanes3DInstance<Point3D>;
} {
const surface: GridPlanes3DInstance<Point3D> = gridPlanes3D<Point3D>()
.scale(10)
.origin(ORIGIN)
.rows(POINTS_PER_ROW);
const start = { yaw: START_ANGLE, tilt: START_ANGLE };
turnCamera(surface, 0, 0, start);
let points: Point3D[] = [];
const { group, button } = rotatingFigure(host, prefix, label, (yaw, pitch) => {
turnCamera(surface, yaw, pitch, start);
draw(surface.data(points), 0);
onTurn?.();
});
/**
* One quad: `color` at its mean height, and a darker tint when the quad
* is turned away from the camera.
*/
function colorize(d: GridPlane<Point3D>): string {
const height = (d[0].y + d[1].y + d[2].y + d[3].y) / 4;
const base = color(height);
return d.ccw ? base : rgb(base).darker(2.5).formatHex();
}
/** Joins the quads, keyed by their plane, and repaints them back to front. */
function draw(planes: GridPlane<Point3D>[], duration: number): void {
const quads = group
.selectAll<SVGPathElement, GridPlane<Point3D>>('path')
.data(planes, (d) => d.plane);
quads
.enter()
.append('path')
.attr('class', 'quad')
.attr('fill', colorize)
.attr('opacity', 0)
.merge(quads)
.transition()
.duration(duration)
.attr('opacity', 1)
.attr('fill', colorize)
.attr('d', surface.draw);
quads.exit().remove();
group.selectAll<SVGPathElement, GridPlane<Point3D>>('path').sort(sort);
}
/**
* Draws a sampled field, by default through the intro transition: the
* first field fades in, every later one morphs the surface into it.
*/
function show(field: Point3D[], duration: number = INTRO_DURATION): void {
points = field;
draw(surface.data(points), duration);
}
return { group, button, show, surface };
}The figure itself:
import {
extent,
interpolateCividis,
interpolateSpectral,
interpolateTurbo,
interpolateViridis,
pointer,
randomUniform,
scaleLinear,
select
} from 'd3';
import { sample, surfaceFigure } from './surfaceFigure';
import type { GridPlane, Point3D } from '../../index';
import './surfacePlot.css';
/*
* A surface plot: the surface figure with controls. The pickers swap the
* shape and the color ramp, the sliders reshape the wave, the checkboxes fit
* the ramp to the samples and strip the surface down to a wireframe, and the
* tooltip reads the sample under the pointer.
*/
const MAX_AMPLITUDE = 12;
const MIN_FREQUENCY = 0.05;
const MAX_FREQUENCY = 0.6;
/** The ramps of the color picker, by the name it shows. */
const RAMPS: Record<string, (t: number) => string> = {
spectral: interpolateSpectral,
viridis: interpolateViridis,
turbo: interpolateTurbo,
cividis: interpolateCividis
};
/**
* The shapes of the shape picker, by the name it shows: a radial ripple, the
* cosine of the distance from the centre, and a wave, a sine along x times a
* cosine along z. Both are `amplitude` high; the phase shifts the rings and
* the hills without changing their height or their spacing.
*/
const SHAPES: Record<
string,
(amplitude: number, frequency: number, phase: number) => (x: number, z: number) => number
> = {
ripple: (amplitude, frequency, phase) => (x, z) =>
amplitude * Math.cos(2 * frequency * Math.hypot(x, z) + phase),
wave: (amplitude, frequency, phase) => (x, z) =>
amplitude * Math.sin(frequency * x + phase) * Math.cos(frequency * z + phase)
};
/**
* Mounts the figure into `host` and returns a function that removes it.
*/
export function mountSurfacePlot(host: HTMLElement): () => void {
const colorScale = scaleLinear<number, number>();
const { group, button, show } = surfaceFigure(
host,
'surface',
'A surface plot. Drag the figure to rotate it.',
(height) => RAMPS[ramp.value](colorScale(height))
);
host.insertAdjacentHTML(
'beforeend',
`<div class="surface-controls">
<label>shape <select name="shape">${Object.keys(SHAPES)
.map((name) => `<option>${name}</option>`)
.join('')}</select></label>
<label>amplitude <input name="amplitude" type="range" min="0" max="${MAX_AMPLITUDE}" step="0.5" value="6"><output></output></label>
<label>frequency <input name="frequency" type="range" min="${MIN_FREQUENCY}" max="${MAX_FREQUENCY}" step="0.01" value="0.3"><output></output></label>
<label>colors <select name="ramp">${Object.keys(RAMPS)
.map((name) => `<option>${name}</option>`)
.join('')}</select></label>
<label><input name="fit" type="checkbox" checked> fit colors</label>
<label><input name="wireframe" type="checkbox"> wireframe</label>
</div>
<div class="surface-tooltip" hidden></div>`
);
const shape = host.querySelector('[name=shape]') as HTMLSelectElement;
const amplitude = host.querySelector('[name=amplitude]') as HTMLInputElement;
const frequency = host.querySelector('[name=frequency]') as HTMLInputElement;
const ramp = host.querySelector('[name=ramp]') as HTMLSelectElement;
const fit = host.querySelector('[name=fit]') as HTMLInputElement;
const wireframe = host.querySelector('[name=wireframe]') as HTMLInputElement;
const tooltip = host.querySelector('.surface-tooltip') as HTMLElement;
let phase = 0;
/** Shows the sample at the first corner of the quad under the pointer, unless a drag is on. */
function showTooltip(event: PointerEvent): void {
tooltip.hidden = event.buttons !== 0;
if (tooltip.hidden) {
return;
}
const d = select<Element, GridPlane<Point3D>>(event.target as Element).datum();
const [left, top] = pointer(event, host);
tooltip.textContent = `x ${d[0].x}, z ${d[0].z}, y ${d[0].y.toFixed(2)}`;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.classList.toggle('is-left', left > host.clientWidth / 2);
}
/**
* Samples the shape the controls describe, scales the ramp, draws it and
* hides the stale tooltip. Fitted, the ramp spans the current samples;
* otherwise it spans the whole amplitude range, so a low wave stays in
* the middle of the ramp.
*/
function update(duration?: number): void {
tooltip.hidden = true;
for (const input of [amplitude, frequency]) {
input.nextElementSibling!.textContent = input.value;
}
const points = sample(
SHAPES[shape.value](amplitude.valueAsNumber, frequency.valueAsNumber, phase)
);
colorScale.domain(
fit.checked
? (extent(points, (d) => d.y) as [number, number])
: [-MAX_AMPLITUDE, MAX_AMPLITUDE]
);
show(points, duration);
}
shape.addEventListener('change', () => update());
amplitude.addEventListener('input', () => update(0));
frequency.addEventListener('input', () => update(0));
ramp.addEventListener('change', () => update(0));
fit.addEventListener('change', () => update(0));
wireframe.addEventListener('change', () =>
host.classList.toggle('is-wireframe', wireframe.checked)
);
group.on('pointermove', showTooltip).on('pointerleave', () => {
tooltip.hidden = true;
});
// A new phase is new data with the amplitude and the frequency the sliders set.
button.on('click', () => {
phase = randomUniform(0, 2 * Math.PI)();
update();
});
update();
return () => {
host.innerHTML = '';
};
}