Declutter
A scatter of labelled nodes that thins itself out when the view gets crowded. Each node is a constant-pixel circle with a name label beside it; importance drives the radius, the color, and the priority. Scroll to zoom, drag to pan — zoom out and the anchors crowd together, so low-priority circles and overlapping labels drop out; zoom back in and they return. The Declutter slider is the literal collision radius in pixels (0 turns it off); the Nodes slider grows the cloud up to ~262k to stress the per-zoom collision pass.
Labelled scatter
Section titled “Labelled scatter”import { interpolateBlues } from "d3-scale-chromatic";import { plot, h } from "@mapequation/d3gl/map";import { LabelLayer, type LabelAnchor } from "@mapequation/d3gl/labels";import type { ImperativeSetup } from "../types.js";import { makeNodes, type Node } from "./data.js";
const GAP = 4; // px between a node's edge and its label
/** Importance → fill (darker = more important), kept off the pale end for contrast on white. */const color = (importance: number): string => interpolateBlues(0.35 + 0.6 * importance);
/** Hover-tooltip body: the node's name, importance, radius, and priority — so the channel that * drives decluttering (importance/priority) is visible. Built with d3gl's `h` hyperscript. */function nodeTooltip(d: Node): HTMLElement { const row = (key: string, value: string): HTMLElement => h("tr", null, [ h("td", { class: "pr-2 opacity-60" }, key), h("td", { class: "text-right tabular-nums" }, value), ]); return h("div", null, [ h("div", { class: "mb-1 font-semibold" }, d.label), h("table", { class: "border-collapse" }, [ row("importance", d.importance.toFixed(3)), row("radius", `${d.radius.toFixed(1)} px`), row("priority", d.importance.toFixed(3)), ]), ]);}
/** * A scatter built to showcase d3gl's **declutter** API. Each node is a constant-pixel circle * (`sizeMode: "screen"`) pinned to a world `anchor`, with a name label in an HTML `LabelLayer` * overlay. Two collision systems run on every zoom/pan: * * 1. Glyph declutter — `layer({ anchor, sizeMode: "screen", declutter: <px> })` hides any * circle whose anchor lands within `<px>` of an already-kept one. Ties break by INPUT * ORDER, so sorting the data by importance descending makes the big nodes win. * 2. Label culling — `LabelLayer` drops labels whose boxes overlap, highest `priority` first. * * Both are screen-space: zoom OUT crowds the anchors → low-priority nodes and overlapping * labels drop out; zoom IN spreads them → everything returns. The **Declutter** slider is the * literal `declutter` radius in pixels (0 = off); the **Nodes** slider grows the cloud to 262k. * * Glyph declutter is an O(n) spatial-grid pass that scales well; the HTML LabelLayer reprojects * every anchor per zoom and does not. The **Labels** toggle drops the label layer so the d3gl * declutter + GPU path can be stress-tested on its own at high node counts. * * Pure d3gl; the harness owns the controls, backend, export, and the screen size. */export const setup: ImperativeSetup = (host, { width, height, backend }) => { const W = width, H = height;
const chart = plot(host, { width: W, height: H, backend, tooltipClass: "rounded border border-border bg-card/95 px-1.5 py-0.5 text-xs text-foreground", });
// HTML label overlay over the canvas (host is positioned `relative` by the harness). const labelEl = document.createElement("div"); labelEl.className = "absolute inset-0 pointer-events-none overflow-hidden text-[11px] leading-[14px] text-[#333]"; host.appendChild(labelEl); const labels = new LabelLayer(labelEl, (a) => a.text);
// Label anchors are rebuilt by `render`; the zoom handler keeps them aligned with the GPU // geometry. The transform starts at identity and is never reset, so a control change keeps // the current zoom/pan. let anchors: LabelAnchor[] = []; let view = { k: 1, x: 0, y: 0 }; const updateLabels = (t = view): void => { view = t; labels.update(anchors, t, { width: W, height: H }); }; chart.enableZoom([0.3, 40], (t) => updateLabels(t)); // scroll to zoom, drag to pan (deep range)
return { engine: chart, // (Re)build the node layer when a control changes. Never touches the transform, so the // current zoom/pan survives growing the cloud or dragging the declutter radius. render: (options) => { const count = 2 ** ((options.nodes as number) ?? 6); const declutterPx = (options.declutter as number) ?? 30; const showLabels = (options.labels ?? "on") !== "off";
// Sort by importance DESC: declutter keeps the earliest-listed of any overlapping group, // so listing the big nodes first makes them win. (Glyph priority = input order.) const nodes = makeNodes(count, W, H).sort((a, b) => b.importance - a.importance);
chart.layer("nodes", nodes, { draw: (ctx, d) => { ctx.moveTo(d.x + d.radius, d.y); ctx.arc(d.x, d.y, d.radius, 0, 2 * Math.PI); ctx.closePath(); }, fill: (d: Node) => color(d.importance), stroke: "#ffffff", lineWidth: 1, anchor: (d: Node) => [d.x, d.y], // pin each node; screen mode keeps it constant-size sizeMode: "screen", // The star of the example: 0 disables, any +px hides circles whose anchors collide. declutter: declutterPx > 0 ? declutterPx : undefined, id: (d: Node) => d.id, hover: { stroke: "#111", lineWidth: 2 }, tooltip: (d: Node) => nodeTooltip(d), });
// One label per node, placed just past its right edge. `priority` drives label culling: // higher importance survives a collision (matching the glyph order above). The LabelLayer // reprojects every anchor on each zoom/pan, so at the high end of the Nodes slider it // dominates; the Labels toggle drops it entirely (empty anchors → all label DOM culled) // to isolate the d3gl glyph-declutter + GPU path. anchors = showLabels ? nodes.map((d) => ({ id: d.id, refX: d.x, refY: d.y, text: d.label, width: d.label.length * 6.2 + 4, height: 14, priority: d.importance, offset: [d.radius + GAP, -7], // right of the circle, vertically centred })) : [];
chart.render(); updateLabels(); // re-place labels at the CURRENT transform (preserved across changes) }, dispose: () => labels.destroy(), };};export interface Node { id: string; x: number; y: number; /** Importance in [0, 1] — drives the radius, the color, AND the declutter priority, * so the "biggest = most important = survives crowding" story reads off one channel. */ importance: number; radius: number; label: string;}
/** Deterministic PRNG (mulberry32) so the scatter — and its screenshots — are stable. */function mulberry32(seed: number): () => number { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
const HEAD = ["Al", "Be", "Ca", "Da", "El", "Fi", "Go", "Ha", "Io", "Ju", "Ka", "Lo", "Ma", "Ne", "Or", "Pa", "Qu", "Ro", "Sa", "Ti", "Um", "Ve"];const TAIL = ["ron", "vik", "mar", "dos", "lyn", "ter", "nia", "sk", "por", "gen", "tal", "wyn"];
const R_MIN = 3;const R_MAX = 16;
/** `count` nodes scattered uniformly over the canvas. Each gets a random importance in * [0, 1] (radius R_MIN…R_MAX, sequential color, and declutter priority), and a short * pronounceable name. The importance is skewed (squared) so a few nodes are clearly the * "big" ones that should win when the view gets crowded. */export function makeNodes(count: number, width: number, height: number): Node[] { const rnd = mulberry32(count); // seed by count so each size is its own stable layout const nodes: Node[] = new Array(count); for (let i = 0; i < count; i++) { const importance = rnd() ** 2; // skew toward small; a handful are large nodes[i] = { id: `n${i}`, x: rnd() * width, y: rnd() * height, importance, radius: R_MIN + importance * (R_MAX - R_MIN), label: `${HEAD[Math.floor(rnd() * HEAD.length)]}${TAIL[Math.floor(rnd() * TAIL.length)]}`, }; } return nodes;}How it works
Section titled “How it works”Two independent collision systems run on every zoom/pan, both in screen space — they compare on-screen pixel distances, so what survives depends on the current zoom, not the data extent:
- Glyph declutter is built into
plot. A layer with ananchorandsizeMode: "screen"can setdeclutter: <px>; after each transform the engine hides any anchored glyph whose anchor lands within<px>of an already-kept one. It only toggles visibility flags — no geometry is rebuilt — so it is cheap enough to run on every zoom frame. - Label culling is done by
LabelLayer(an HTML overlay). On eachupdateit maps anchors through the transform, drops labels outside the viewport, then greedily places the rest, skipping any whose box overlaps one already placed. Only the surviving labels exist in the DOM.
chart.layer("nodes", nodes, { draw: (ctx, d) => { ctx.arc(d.x, d.y, d.radius, 0, 2 * Math.PI); ctx.closePath(); }, anchor: (d) => [d.x, d.y], // pin each node in world coords sizeMode: "screen", // constant pixel size — declutter needs screen space declutter: 30, // hide anchored glyphs within 30 px of a kept one // ...fill, stroke, hover, tooltip});Prioritizing what survives
Section titled “Prioritizing what survives”The two systems express priority differently, so keep them consistent:
-
Glyphs break ties by input order — the engine keeps the earliest-listed glyph of an overlapping group. Sort the data by importance descending and the big nodes win:
const nodes = makeNodes(count).sort((a, b) => b.importance - a.importance); -
Labels take an explicit
priorityfield on each anchor (higher wins; ties fall back to input order). Set it from the same channel as the glyph order:labels.update(nodes.map((d) => ({id: d.id, refX: d.x, refY: d.y, text: d.label,width: d.label.length * 6.2 + 4, height: 14,priority: d.importance, // higher importance survives a collisionoffset: [d.radius + 4, -7], // sit just past the circle's right edge})),transform,{ width, height },);
The offset is a constant pixel gap applied to both the rendered position and the collision
box, so the label stays a fixed distance from its node at any zoom and culling reflects where the
label actually sits.
A million points
Section titled “A million points”The scatter above draws each circle as a tessellated ctx.arc path so it can carry an
anchor for declutter — but a path circle is tens of vertices, so the geometry gets heavy past a
few hundred thousand. For large clouds, use analytic points instead: chart.points(...)
draws each circle on the GPU from a single quad (~4 vertices), and a point’s anchor defaults to
its own center, so declutter works with no extra wiring. That lifts the Nodes slider to
~1M on the same per-zoom cull.
This example drops the label overlay (an HTML LabelLayer can’t follow a million nodes) and sets
pickable: false (a per-point hit index would dwarf the geometry), so it’s a pure glyph-declutter
stress test. Scroll to zoom — the deep zoom range lets the crowd fully resolve.
import { interpolateBlues } from "d3-scale-chromatic";import { plot } from "@mapequation/d3gl/map";import type { ImperativeSetup } from "../types.js";import { makePoints, type Point } from "./data.js";
/** Importance → fill (darker = more important), kept off the pale end for contrast on white. */const color = (importance: number): string => interpolateBlues(0.35 + 0.6 * importance);
/** * The declutter example at scale: the same screen-space cull as the labelled scatter, but each * node is an **analytic GPU point** (`chart.points`, `sizeMode: "screen"`) instead of a * tessellated `ctx.arc` path. A point is ~4 vertices vs. tens for a tessellated circle, so the * **Nodes** slider goes to ~1M without the geometry-memory wall of the path version. * * `declutter` works on points the same way: each point's anchor is its center (no explicit * `anchor` needed), and the engine hides points whose projected center lands within `declutter` * px of an already-kept one — earlier data wins, so sorting by importance descending keeps the * big ones. No labels (the HTML `LabelLayer` reprojects every anchor per zoom and won't follow a * million points) and `pickable: false` (a 1M-entry hit index would dwarf the geometry), so this * is a pure glyph-declutter stress test. Scroll to zoom (deep range), drag to pan. */export const setup: ImperativeSetup = (host, { width, height, backend }) => { const W = width, H = height;
const chart = plot(host, { width: W, height: H, backend }); chart.enableZoom([0.3, 40]); // scroll to zoom, drag to pan — deep range to fully resolve crowding
return { engine: chart, render: (options) => { const count = 2 ** ((options.nodes as number) ?? 14); const declutterPx = (options.declutter as number) ?? 30;
// Sort by importance DESC so the biggest points win declutter ties (priority = input order). const points = makePoints(count, W, H).sort((a, b) => b.importance - a.importance);
chart.points("nodes", points, { x: (d: Point) => d.x, y: (d: Point) => d.y, radius: (d: Point) => d.radius, fill: (d: Point) => color(d.importance), sizeMode: "screen", // constant pixel size — declutter compares on-screen distances declutter: declutterPx > 0 ? declutterPx : undefined, id: (d: Point) => d.id, pickable: false, // no hover/pick: skip the per-point hit index so ~1M stays lean });
chart.render(); }, };};export interface Point { id: number; x: number; y: number; /** Importance in [0, 1] — drives the radius, the color, AND (via input order after sorting) * the declutter priority, so "biggest = most important = survives crowding" reads off one * channel. */ importance: number; radius: number;}
/** Deterministic PRNG (mulberry32) so the scatter — and its screenshots — are stable. */function mulberry32(seed: number): () => number { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
const R_MIN = 2;const R_MAX = 14;
/** `count` points scattered uniformly over the canvas, each with a skewed (squared) importance * in [0, 1] driving radius + color. No labels and numeric ids — deliberately lean so the layout * scales to ~1M points without the per-node string/label allocations of the labelled example. */export function makePoints(count: number, width: number, height: number): Point[] { const rnd = mulberry32(count); // seed by count so each size is its own stable layout const points: Point[] = new Array(count); for (let i = 0; i < count; i++) { const importance = rnd() ** 2; // skew toward small; a handful are large points[i] = { id: i, x: rnd() * width, y: rnd() * height, importance, radius: R_MIN + importance * (R_MAX - R_MIN), }; } return points;}chart.points("nodes", points, { x: (d) => d.x, y: (d) => d.y, radius: (d) => d.radius, // per-point radius, like the path version fill: (d) => color(d.importance), sizeMode: "screen", // constant pixel size — declutter compares on-screen distances declutter: 30, // anchor defaults to each point's center; no explicit anchor pickable: false, // skip the per-point hit index so ~1M stays lean});