Skip to content

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.

fps 0frame 0 ms
Nodes64
Declutter30 px
Labels
draw.ts
import { interpolateBlues } from "d3-scale-chromatic";
import { plot, h } from "@mapequation/d3gl/map";
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
const LABEL_STYLE = { font: "11px system-ui, sans-serif", color: "#333", textShadow: "none" };
/** 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 from the engine's
* `chart.labels(...)`. 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 — `chart.labels` drops labels whose boxes overlap, highest importance first
* (the engine measures each label's text and re-places them as you zoom).
*
* 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; label placement reprojects
* every anchor per zoom and does not. The **Labels** toggle drops the labels 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",
});
chart.enableZoom([0.3, 40]); // scroll to zoom, drag to pan (deep range); labels track the transform
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),
});
chart.render();
// One label per node, placed just past its right edge. `importanceOf` drives label culling:
// higher importance survives a collision (matching the glyph order above). The engine measures
// each label once and re-places them on every zoom/pan. The **Labels** toggle removes them
// (`false`) to isolate the d3gl glyph-declutter + GPU path at high node counts.
if (showLabels) chart.labels(nodes, {
labelOf: (d) => d.label,
anchorOf: (d) => [d.x, d.y],
importanceOf: (d) => d.importance,
offset: (d) => [d.radius + GAP, 0], // right of the circle, vertically centred by the engine
style: LABEL_STYLE,
});
else chart.labels(false);
},
};
};

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 an anchor and sizeMode: "screen" can set declutter: <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 engine-owned via chart.labels. You give it the data plus labelOf/anchorOf/importanceOf accessors; the engine measures each label’s text once, 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 reach the DOM (an HTML overlay on WebGL; native <text>/fillText on SVG/Canvas, so they survive toSVG()/toPNG() export).
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
});

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 importanceOf accessor (higher wins; ties fall back to input order). Set it from the same channel as the glyph order:

    chart.labels(nodes, {
    labelOf: (d) => d.label,
    anchorOf: (d) => [d.x, d.y],
    importanceOf: (d) => d.importance, // higher importance survives a collision
    offset: (d) => [d.radius + 4, 0], // sit just past the circle's right edge
    });

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. The engine measures each label’s text (no metric estimates) and centres it vertically on the anchor, so the offset only needs the horizontal gap.

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 labels (label placement reprojects every anchor per zoom and 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.

fps 0frame 0 ms
Nodes16,384
Declutter30 px
draw.ts
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();
},
};
};
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
});