Skip to content

Network

The network() engine renders node–link diagrams through a dedicated GPU-instanced rendering lane: nodes are instanced points, links are instanced lines, and directed edges get triangle arrowheads. It is built for large graphs — the same lane scales toward millions of elements, with an adaptive level-of-detail cut that keeps per-frame work proportional to what’s actually on screen. It also renders maps of networks (a provided module hierarchy drives the cut), and state (memory) networks — toggling a state view against the derived physical view, where overlapping-module physical nodes draw as pie-chart glyphs.

This example builds an LFR benchmark network — power-law node degrees and power-law community sizes with a tunable mixing parameter, the standard benchmark for community detection — and lets d3gl’s in-library force layout (Barnes-Hut) place the nodes; no coordinates are supplied. The layout is seeded by multilevel coarsening: the graph is collapsed into a hierarchy of ever-smaller graphs (each node merged with a neighbour, hubs and their leaves absorbed together so power-law graphs still shrink geometrically), the tiny coarsest one is laid out first, and positions are projected back down and refined — far faster convergence and fewer tangles than a cold start. Every level is laid out at the force model’s own equilibrium scale (a disc of radius √(repulsion·N/centering); coarse nodes weigh as many nodes as they stand for), so the refinement starts where it will end up — no blow-up past the final extent, no collapse. With layout({ backend: "worker" }) the entire solve runs in a Web Worker and streams positions back — the multilevel seed while it forms (each finer level projected onto every node, already at the final extent, so a large graph shows up in a fraction of the seed’s time), then a frame about every display frame — so the layout converges progressively on screen while the main thread stays responsive; pan and zoom while it settles. A multilevel-seeded CPU layout (worker or force) cools over its iterations budget and stops once it has converged, so there iterations is a safety cap and the length of the cooling (a larger budget cools more slowly), not a tick count to hit. The GPU layout stops by the same test, decided on the GPU once per tick, so where it stops does not depend on the frame rate. A cold start keeps full heat until it settles on its own, which on a large graph can take longer than the default budget, so the example gives cold starts (Seeding set to cold, on any backend) a budget that shrinks as the graph grows. The GPU layout seeds multilevel too: a worker coarsens the graph (the same heavy-edge matching) while the GPU solver’s programs compile, side by side and off the main thread, so a first visit with a cold shader cache does not freeze the page; the GPU then lays out every coarse level on that one solver, coarsest first, inside the same per-frame GPU budget as the refinement. Until its first frame the equilibrium-sized disc stays on screen; that frame already shows the global arrangement, at the same scale, and a seeded GPU layout cools over its budget like the worker’s. The seed is paced by animation frames, so that first frame comes later than a disc start’s: on web-NotreDame about 0.8 s later at 120 Hz, and about twice that on a 60 Hz display. On web-NotreDame (325k nodes) the seeded GPU layout meets the CPU layouts’ convergence rule by tick 149, with shorter edges than a disc start reaches in its whole 300-tick budget, which never meets it (Seeding “Cold” — multilevel: false — starts the GPU layout from the disc, with or without a module hierarchy). layout({ backend: "gpu" }) (the Backend control) runs the solve in WebGL2 where the device can: float render targets, float blending (EXT_float_blend) and textures large enough for the graph. The main thread never waits for it: each frame gives the layout at most min(10 ms, 0.6 × the frame interval) of GPU time (a tick larger than that is split across frames in row bands), and positions come back through an asynchronous, fenced readback. So a pan, a zoom or a node drag waits behind at most about 20 ms of queued layout work (two 60 Hz frames’ budget), never a whole batch of ticks, and the page keeps its frame rate while hundreds of thousands of nodes settle (measured in Chromium; Firefox and Safari are not measured yet). The layout repaints at most 20 times a second. With LOD on, the LOD tree stays off the main thread as with the worker layout: a worker coarsens the graph while the GPU solver’s programs compile, and refits the tree to every frame the GPU reads back (with lod({ source: "spatial" }) it rebuilds the spatial tree for every frame instead), so a frame is painted (positions and aggregates together) one worker round trip after it was read back (the top-right readout’s LOD tree shows worker). On a device without those capabilities it falls back to the worker with one console warning naming the reason, and the fallback is the run backend: "worker" would start: it honours multilevel (Seeding) and streams the LOD tree from the worker. The GPU layout lives on the WebGL backend’s device, so switching the render backend (the example’s auto / webgl / canvas / svg switch, or setBackend()) or losing the WebGL context moves it: it frees its GPU memory and continues where it was — the same positions, the ticks it had left, the heat it had cooled to — on the GPU of the new WebGL backend, or on the worker for Canvas and SVG. A layout that had already settled continues as an idle run — on the GPU of a new WebGL backend, on the worker for Canvas and SVG — so dragging a node still reflows it. layout({ backend: "auto" }) (Auto) asks for “the GPU where it works”: it takes the GPU wherever "gpu" would and the worker everywhere else, without a warning, since there the worker is an expected outcome. Everything else — fit, drag reheat, nested layouts, state networks, LOD streaming — then behaves exactly as on the backend it resolved to, and net.layoutTransport (the top-right readout) says which one runs. It is opt-in: omitting backend behaves as before. (On a cross-origin-isolated page positions are shared zero-copy via SharedArrayBuffer; otherwise they are posted as snapshots — the top-right readout shows whether the environment supports it and whether this layout is actually using it.) The Nodes slider scales from 10 to 1,000,000; Seeding compares multilevel against a cold start; Size switches uniform vs degree-weighted radius; Sizing switches world vs screen units; LOD / Declutter control the level-of-detail cut (below); and Edges turns the links off entirely (next section). Drag to pan, scroll to zoom. Dragging a node moves it, so hold ⌘ (Ctrl on Windows/Linux) to pan over nodes. See Dragging nodes.

fps 0frame 0 ms
Nodes1k
Links
Node size
Edge size
Sizing
LOD
Source
Declutter
Edges
Cross-level edges
Cross-fadeOff
Seeding
Backend
draw.ts
import { network, buildGraph, sharedMemoryAvailable, type NodeRadiusSpec, type NetworkGraph, type NetworkHit } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { generateLFR } from "./data.js";
const SIZES = [10, 100, 1_000, 10_000, 100_000, 1_000_000];
/**
* Degree-weighted node radius: a d3 `scaleSqrt` (area-proportional) over the graph's degree range,
* handed to `nodeRadius` as `{ by: "degree", scale }`. Resolved once per style() — varying per-node
* radius is a per-instance GPU attribute, so this costs nothing at draw time, even at 1M nodes.
*/
function degreeRadius(graph: NetworkGraph): NodeRadiusSpec {
let lo = Infinity;
let hi = 0;
for (const d of graph.csr.degree) {
if (d < lo) lo = d;
if (d > hi) hi = d;
}
if (hi <= lo) return 6; // uniform degree (e.g. a single clique) — nothing to scale
return { by: "degree", scale: scaleSqrt().domain([lo, hi]).range([3, 13]) };
}
/**
* An **LFR benchmark network** (power-law degrees + power-law communities with a mixing parameter —
* the standard community-detection benchmark) rendered with the `network()` engine: nodes as
* GPU-instanced points, links as instanced lines, triangle arrowheads for directed edges. Node
* positions come from d3gl's in-library **force layout** (Barnes-Hut), seeded by **multilevel
* coarsening** — no coordinates are supplied. `layout({ backend: "worker" })` runs the whole solve in
* a Web Worker and streams positions back, so the layout **converges progressively on screen** while
* the UI stays responsive. The Nodes slider scales 10 → 1,000,000; **Node size** switches a uniform
* vs **degree-weighted** radius; **Edge size** switches uniform vs **weight-scaled** links (LOD
* super-edges thicken + darken with their accumulated weight); **Sizing** switches world vs **screen**
* (constant-pixel) glyphs. The
* **LOD** toggle enables the adaptive hierarchy cut — dense communities collapse to aggregate glyphs
* and expand into their members as you zoom in — with **Declutter** (thin overlaps). **Source** picks
* the tree the cut draws: **Structure** coarsens the graph by its links, **Spatial** groups nodes by
* where the layout put them (a quadtree the worker rebuilds as the layout streams), which keeps the
* number of glyphs bounded by the screen however the layout spreads the communities. Pair LOD with
* screen sizing. **Edges** "Off" renders the network as **nodes only** via `style({ linkStyle: "none" })`
* — with LOD on or off — and the link, arrowhead and super-edge geometry is then never built or
* uploaded (not merely hidden), so switching it off on a million-edge graph *saves* work rather than
* costing it. The edges keep driving the layout, so toggling back is instant. Drag empty space to pan,
* scroll to zoom. **Hold ⌘ (Ctrl on Windows/Linux) to pan anywhere**, even over a node (#178), which is
* how you navigate a dense layout where almost every press lands on one.
* **Hover or click** a glyph to resolve the node — or the module it collapsed into — shown top-left.
* **Selecting** a node dims the rest of the graph (the `selection.others` focus, consistent with GeoMap
* + Plot) while keeping the selected node *and its outgoing links* at full strength; **hovering** a node
* recolours its outgoing links red (and, via `hover: { others }`, fades the rest). The highlight is applied
* in the GPU shader, so it stays instant even with LOD off on a million-node layout.
* **Drag a node or a collapsed module** to move it: it tracks the cursor with no lag while the off-thread
* worker layout reheats around it and re-cools on release (grab a module to drag its whole subtree).
* **Backend** switches the force solve between `"worker"` (CPU Barnes-Hut in a Web Worker) and `"gpu"`
* (WebGL2 Barnes-Hut grid-pyramid). Where the device can't run it (no float render targets or float blending,
* or textures too small for the graph) `"gpu"` falls back to `"worker"` with a console warning, keeping
* Seeding and LOD streaming. `"auto"` picks for you: the GPU where it works, else the worker, silently —
* the top-right readout shows which transport it resolved to.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
net.enableZoom([0.002, 200]); // wide range: zoom right out to the aggregate map, in to single nodes
// Node-drag (#140): grab a node or a collapsed module and drag it — it tracks the cursor with no lag
// while the off-thread worker layout **reheats** around it and re-cools on release. Grab a selected
// node to drag the whole selection; grab a module aggregate to drag its whole subtree. Plain drag on
// empty space still pans, and ⌘/Ctrl-drag pans even over a node (#178; built in, no option). Hover/click
// also light a ring via the same interactive() opt-in.
// #162: the selection/hover highlight is applied in the GPU shader from per-instance flags + uniforms,
// so hovering across a million-node LOD-off layout is a uniform change — no per-hover geometry rebuild.
// `selection.others` (set explicitly here, though 0.3 is the default) dims the rest of the graph on
// selection while the selected node + its outgoing links stay full; `hover: { others }` opts into the
// same fade on hover. Highlight colour is red (rings + link recolour); the recolour preserves link weight.
net.interactive({
selectable: { multi: true },
draggable: true,
selection: { others: { opacity: 0.3 } },
hover: { others: { opacity: 0.5 } }, // enable hover + fade the rest on hover (mirrors selection.others)
});
// Picking (#105 N7a): hover/click resolve the node or aggregate under the cursor via the engine's
// CPU hit-test over the LOD cut frontier — bounded by the visible set, so it stays cheap at 1M. The
// same on("hover"/"click") API the GeoMap/Plot examples use; network just teaches pick() to see the
// instanced glyphs. Shown in a small overlay so the resolution is visible (no GPU readback needed).
const readout = document.createElement("div");
readout.className = "absolute top-2 left-2 pointer-events-none rounded bg-white/85 px-2 py-1 font-mono text-[12px] leading-tight text-[#333]";
const describe = (hit: { id: string | number; datum: unknown } | null): string => {
if (!hit) return "hover a node or module · ⌘/Ctrl+drag to pan";
const d = hit.datum as NetworkHit;
return d.aggregate ? `module · ${d.count.toLocaleString()} nodes` : `node ${hit.id}`;
};
readout.textContent = describe(null);
host.appendChild(readout);
net.on("hover", (hit) => { readout.textContent = describe(hit); });
net.on("click", (hit) => { if (hit) readout.textContent = `clicked ${describe(hit)}`; });
// Transport readout (#163 + N8): layout transport (gpu / shared / copy / none), the environment's SAB
// *capability* (`sharedMemoryAvailable()`), whether SAB is *in use*, and where the LOD tree comes from
// (`lodSource`: "worker" = built and refit off the main thread — by the worker layout, or with the GPU
// layout by its LOD worker, #377). For a `backend:"gpu"` or `"auto"` layout the transport resolves
// asynchronously (it is "copy" until the device promise settles), so we also refresh it after the layout
// settles.
const sab = document.createElement("div");
sab.className =
"absolute top-2 right-2 pointer-events-none rounded bg-white/85 px-2 py-1 font-mono text-[11px] leading-tight [font-variant-numeric:tabular-nums]";
host.appendChild(sab);
const yesNo = (b: boolean): string => (b ? "yes" : "no");
const updateSab = (): void => {
const transport = net.layoutTransport;
const supported = sharedMemoryAvailable();
const inUse = transport === "shared";
const isGpu = transport === "gpu";
sab.style.color = isGpu ? "#7c3aed" : inUse ? "#1a7f37" : supported ? "#9a6700" : "#8a8a8a";
sab.innerHTML =
`<span title="Active layout transport: gpu = WebGL GPU path; shared = CPU worker, zero-copy SharedArrayBuffer; copy = CPU worker, per-frame postMessage snapshots; none = no layout running.">layout: <b>${transport}</b></span><br>` +
`<span title="Environment capability: SharedArrayBuffer needs a cross-origin-isolated page (COOP: same-origin + COEP: require-corp). Set on the dev/preview server; GitHub Pages can't send these headers — see issue #163.">SAB supported: <b>${yesNo(supported)}</b></span><br>` +
`<span title="Actual SAB transport of the running worker layout: yes = positions stream zero-copy through a SharedArrayBuffer; no = posted as per-frame snapshots (also when the worker fell back to a synchronous solve).">SAB in use: <b>${yesNo(inUse)}</b></span><br>` +
`<span title="Where the LOD tree comes from: worker = built and refit off the main thread (the worker layout, or the GPU layout's LOD worker); main = built on the main thread; none = LOD off or no tree yet.">LOD tree: <b>${net.lodSource}</b></span>`;
};
updateSab();
// Regenerate + re-lay-out only when a graph/layout input changes (nodes / links / seeding / backend);
// the cosmetic controls below just re-style, so toggling e.g. Declutter keeps your pan/zoom. `fit: true`
// frames each fresh layout as it converges — otherwise the GPU backend opens at the origin (top-left).
let layoutKey = "";
let graph: NetworkGraph | null = null;
return {
engine: net,
render: (options) => {
const count = SIZES[(options.nodes as number) ?? 1] ?? 100;
const directed = options.mode !== "Undirected";
// "Cold" disables multilevel seeding so you can watch the difference: multilevel snaps to a
// good global arrangement then settles; cold starts from a disc and untangles slowly.
const multilevel = options.seeding !== "Cold";
const layoutBackend = options.backend === "GPU" ? "gpu" : options.backend === "Auto" ? "auto" : "worker";
const key = `${count}|${directed}|${multilevel}|${layoutBackend}`;
const relayout = key !== layoutKey;
if (relayout) {
layoutKey = key;
// LFR benchmark with clear community structure (low mixing) for the layout + LOD to resolve.
// Weighted so links vary and LOD super-edges thicken/darken with their accumulated weight.
const { nodeCount, source, target, weight } = generateLFR(count, { mu: 0.1, seed: 1, weighted: true });
graph = buildGraph({ nodeCount, source, target, weight, directed });
net.data(graph);
}
if (!graph) return;
// The raw graph is unweighted (every edge weight 1); the per-edge "weight" that varies is the
// **accumulated flow of an LOD super-edge**. Encode it in both width and colour so a heavier
// super-edge reads as thicker AND darker (the same scale applies to each edge's weight, so a
// super-edge uses its summed weight). Width follows the Edge-size toggle; colour always encodes it.
const edgeWidth =
options.edge === "Uniform"
? 0.8
: { by: "weight" as const, scale: scaleSqrt().domain([1, 25]).range([0.5, 5]).clamp(true) };
// Colour by weight via a d3 colour scale: light/translucent at weight 1 → darker/opaque with
// accumulated super-edge weight (scaleSqrt interpolates the RGBA range, alpha included).
const linkStroke = {
by: "weight" as const,
scale: scaleSqrt<string>().domain([1, 25]).range(["rgba(150,165,205,0.3)", "rgba(65,95,150,0.85)"]).clamp(true),
};
net
.style({
directed,
nodeRadius: options.size === "Uniform" ? 5 : degreeRadius(graph),
nodeFill: "#4878d0",
// Edges "Off" → a nodes-only network: `linkStyle: "none"` skips *building and uploading* the
// link/arrowhead/super-edge geometry (with LOD on or off), rather than drawing it invisibly.
linkStyle: options.edges === "Off" ? "none" : "line",
linkWidth: edgeWidth, // Edge size: Uniform (0.8) or ∝ √weight in [0.5, 5]
linkStroke, // darker + more opaque with accumulated weight (arrowhead shares it)
// arrowSize left unset → defaults to a function of link width (≈ the half-arrow tip).
// "Screen" keeps glyphs a constant pixel size while you zoom (they don't vanish when
// zoomed out) — the natural register for navigating a large layout, and what LOD wants.
sizeMode: options.coords === "Screen" ? "screen" : "world",
})
// Enable the adaptive cut: it aggregates only where glyphs would overlap (a small count opens on
// every node, #426); aggregates draw a touch lighter than leaves, capped at 26px so big collapsed
// clusters stay readable in screen mode. Frontier declutter thins overlapping glyphs by
// importance. The cut tracks the layout as it converges and re-cuts on zoom.
// Configured *before* layout() so the worker builds + streams the LOD tree itself (#103) —
// the main thread then never coarsens or runs the O(N) geometry pass, only the O(visible) cut.
.lod(
options.lod === "On"
? {
// Structure: coarsen by links. Spatial: group by position — the worker rebuilds the tree
// on every streamed frame, so the glyph count stays bounded by the screen (#343).
source: options.source === "Spatial" ? "spatial" : "structure",
expandPx: options.source === "Spatial" ? undefined : 48, // spatial: its tree-adaptive default
aggregateFill: "#7f97c8",
maxAggregateRadius: 26,
declutter: options.declutter !== "Off",
// superEdges stays on: the Edges toggle now removes *all* links via linkStyle above,
// so it works with LOD off too (`superEdges` only ever governed aggregate↔aggregate links).
// Opt-in #139: also link a visible leaf to a still-collapsed module across a mixed frontier.
crossLevelEdges: options.crossLevel === "On",
// Opt-in #133: ease aggregates ↔ children across the expand threshold (slider × 0.1 = band).
crossFade: ((options.crossFade as number) ?? 0) * 0.1,
}
: false,
);
if (relayout) {
// Every layout stops once it has converged (#124; on the GPU, decided there once per tick, #376), and
// a multilevel one — the worker's, or the GPU's own seed (#353) — converges well within the default
// budget, which it keeps as a safety cap. A cold start keeps full heat until it settles, which can
// outlast that budget on a large graph, so cold starts get a budget that shrinks as the graph grows.
const iterations = !multilevel ? Math.min(250, Math.max(10, Math.round(2.5e6 / count))) : undefined;
// fit: true (#238) keeps the camera framed on the streaming layout as it converges, released on
// settle/interaction — so it opens framed rather than piling at the origin on the GPU backend.
net.layout({ backend: layoutBackend, iterations, multilevel, fit: true });
updateSab(); // immediate snapshot (gpu transport resolves async; whenSettled() refreshes it)
void net.whenSettled().then(updateSab); // refresh once the resolved transport is known
}
},
};
};

Set linkStyle: "none" to render a network as nodes only — a point cloud of the layout, with no links, arrowheads or LOD super-edges. It is a skip, not a hide: the link geometry is never built, never coloured and never uploaded to the GPU, so at a million edges turning links off saves the work instead of paying for an invisible buffer. A constant linkWidth: 0 takes the same path (a width scale that happens to reach 0 does not — that thins individual links, it does not opt out).

net.style({ linkStyle: "none" }); // nodes only — links/arrows/super-edges are not built at all
net.style({ linkWidth: 0 }); // same skip, via a constant zero width
net.style({ linkStyle: "line" }); // …and back; nothing was lost, so this is instant

The edges are still data: they keep driving the force layout and the LOD coarsening hierarchy, so the arrangement is identical with links shown or hidden, and toggling never re-lays-out the graph. Works with LOD on or off, and on all three backends (toSVG() exports the nodes alone). A graph with genuinely no edges (buildGraph({ nodeCount, source: [], target: [] })) renders the same way — every link emit is simply an empty loop; its LOD tree is then the spatial tree over the positions, since there is nothing to coarsen.

The Edges toggle in the example above is wired to exactly this.

nodeRadius accepts more than a constant — it takes a d3 scale so node size can encode a metric. The radius is a per-instance GPU attribute, so varying it per node is free at draw time (it is resolved once per style() call, never per frame), all the way to millions of nodes.

import { scaleSqrt } from "d3-scale";
// A function receives the node's degree — a bare d3 scale fits directly.
// scaleSqrt makes the *area* proportional to degree, the honest mapping for circles.
net.style({ nodeRadius: scaleSqrt().domain([1, maxDegree]).range([2, 20]) });
// Or size by a chosen metric through any scale with { by, scale }:
net.style({ nodeRadius: { by: "strength", scale: scaleSqrt().range([2, 20]) } });

nodeRadius can be:

  • a number — one constant radius for every node (the default is 4);
  • a function (degree, index, graph) => radius — the node’s degree is the first argument, so a bare d3 scale works; index/graph are there for anything custom;
  • { by, scale } — feed a metric through any scale, where by is "degree" (neighbour count), "strength" (weighted degree — summed incident edge weights), "flow" (an app-provided per-node value, see below), or your own (index, graph) => value accessor;
  • a Float32Array of per-node radii you computed yourself.

degree and strength are derived from the edge list automatically. Flow is a model quantity (e.g. an Infomap visit rate) that d3gl does not invent — supply it as buildGraph({ …, nodeFlow }) to make { by: "flow", scale } available.

The “map of networks” glyph style — independent of LOD, these are plain rendering features. A node or module reads as a disc whose fill + size is its total flow and whose border ring encodes its enter/exit flow (the flow crossing its boundary). Directed links draw as half-arrows (linkStyle: "half-arrow"): each is one filled shape that pinches to the source node’s centre and lands its barbed tip on the target node’s boundary, bowed around a shared centre curve so a reciprocal A→B / B→A pair nests instead of overlapping. This example reproduces mapequation’s network-rendering example.svg exactly — switch the backend (WebGL / Canvas / SVG): the render is equivalent across all three.

Every visual channel maps a flow quantity through a d3 scale, so the map reads quantitatively:

  • nodeRadius + nodeFill — total node flow → disc size and fill colour. Both take { by, scale }: nodeRadius: { by: "flow", scale } with a number scale, nodeFill: { by: "flow", scale } with a colour scale (a bare d3 colour scale fits). by is any node metric — "degree", "strength", "flow" or your own (index, graph) => value. (nodeFill also takes one colour, or a per-node (index, graph) => colour accessor for categorical colours.)
  • flowBorder — flow is a per-node value (your Float32Array of enter/exit flow, or a built-in metric like "strength"); scale maps it to ring width and color may be a (value, index, graph) accessor so the ring colour encodes it too. d3gl doesn’t compute enter/exit flow — you supply it (Infomap gives it), per node and, with moduleFlow, per module (below). With share: true (and no scale) the flow border covers the share of the node’s flow that leaves it instead: its exit flow over its flow (the graph’s nodeFlow), as a share of its area — see below.
  • linkWidth + linkStroke — link flow (the per-edge weight) → half-arrow width and colour. Each takes a constant, a (weight) => … scale (a bare d3 colour scale fits linkStroke — scaleSqrt().range([light, dark]) interpolates RGBA, alpha included), or { by, scale } like nodeRadius (by is "weight"/"flow"). A super-edge applies the same scale to its accumulated subsumed weight, so a heavier super-edge reads as thicker and darker. The arrowhead is part of the shape, so it always takes the link’s colour — there is no separate arrow fill. Keep range minimums ≥ 1 so nothing vanishes at low flow. The linkStroke colour is read once per style() (or data()) call and remembered per weight, so it must be a function of the weight alone: after changing a scale in place (a new domain, a theme swap), call style() again to apply it.
  • linkBend — the ⟂ bow as a fraction of the link’s length (as for line links), so a link keeps its shape at every zoom in both size modes; the bow side is derived from the link direction so reciprocal links nest automatically.
import { scaleLinear } from "d3-scale";
net.data(graph).style({
directed: true,
linkStyle: "half-arrow",
nodeRadius: { by: "flow", scale: scaleLinear().domain([lo, hi]).range([20, 30]) }, // size ∝ flow
nodeFill: { by: "flow", scale: fillColor }, // a d3 colour scale on flow
flowBorder: { flow: enterExitFlow, scale: borderWidth, color: (v) => borderColor(v) }, // ring ∝ enter/exit
linkBend: 0.14,
linkWidth: scaleLinear().domain([lo, hi]).range([7, 13]), // width ∝ link flow
linkStroke: scaleLinear().domain([lo, hi]).range(["#71B2D7", "#418EC7"]), // colour ∝ link flow
});

Under LOD, a collapsed module reads as its flow too. With { by, scale } a module aggregate is sized and filled by the same scale applied to its members’ total flow, so node and module share one legend. (A per-node nodeFill accessor gives a module its members’ mean colour instead, which is what categorical colours want.) Its ring sums its members’ enter/exit flow — but a module’s real enter/exit flow is smaller: the flow between its own submodules never leaves it. Pass the module values you have (Infomap reports them per module) as moduleFlow, keyed by the module’s Infomap path:

// moduleFlow: a Map of each module's enter/exit flow, by its Infomap path joined with ":"
// ("1:2" = sub-module 2 of top module 1) — from your Infomap result.
net.data(graph, { modules: infomapResult.nodes }).style({
nodeFill: { by: "flow", scale: scaleSequentialLog(interpolateViridis).domain([minFlow, 1]) },
flowBorder: { flow: enterExitFlow, scale: borderWidth, moduleFlow: (path) => moduleFlow.get(path.join(":")) },
});

Absolute or share. By default a flow border is scale(flow) wide, whatever the node’s size, so two nodes whose flow all leaves them can show anything from a thin border to a fully covered disc. With share: true it reads as the share of the node’s flow that leaves it: a node of radius r, flow f (the graph’s nodeFlow) and exit flow e (flow) keeps an inner disc of radius r · √(1 − e/f), so the border covers e/f of its area — all of it when e = f (common in a map with no self-links), half when e = f/2, none when e = 0, and none when f is 0 or missing. The share does not depend on the radius, so it reads the same in both size modes. An aggregate takes its members’ summed exit flow over their summed flow, a module with moduleFlow its own exit flow over its summed flow, and each view of a state network its own graph’s node flow (a physical node’s flow is the sum of its state nodes’). The Border control below switches the two modes.

net.data(graph).style({
nodeRadius: { by: "flow", scale: radius },
flowBorder: { share: true, flow: exitFlow }, // covers exitFlow / nodeFlow of each node's area
});

moduleFlow is read for the modules of the hierarchy the engine holds (data(graph, { modules }), which the default LOD source draws); a module it returns undefined for keeps the sum, and so does every aggregate that is not a module (lod({ source: "structure" }) and "spatial"). A color accessor is handed the value the ring actually draws — the module value for a module, with index -1. A spatial cell is a region of the layout rather than a unit of the metric, so, as its radius stays area-additive, it keeps its members’ mean fill and takes the highest-flow node’s ring colour. Everything is resolved once per style() call — O(nodes) scale calls, O(modules) moduleFlow calls — and a zoom frame only reads the resolved colours and widths. The directed map of modules below uses both (its Fill control).

The half-arrow renders fully instanced on the WebGL lane (the vertex shader places the foot, the shared centre curve and the barbed head per instance) and traces the identical reference path for SVG/Canvas export, so publication output matches the screen. The plain linkStyle: "line" (the default, see the large-scale example) keeps stroked lines + separate arrowheads for graphs with many edges, and linkStyle: "none" drops the links entirely (see Nodes only).

The Sizing toggle switches sizeMode. In screen mode the link decorations (width, arrow tip, bend, node radii) stay a constant pixel size as you zoom while the nodes still move — the navigation register LOD wants. Screen-mode half-arrows are recomputed per frame on the WebGL lane; for SVG/Canvas the shape is baked into world coords at the current zoom (a retained backend can’t recompute a shape spanning two moving anchors per frame), refreshed automatically on backend switch, at the end of a pan/zoom, and after a setTransform while zoom is enabled — call net.syncScreenGeometry() to refit on demand (e.g. before a programmatic export).

fps 0frame 0 ms
Bend0.14
Sizing
Border
draw.ts
import { network, buildGraph } from "@mapequation/d3gl/network";
import { scaleLinear } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { buildReplica, REPLICA_BOUNDS, NODE_FILL_RANGE, NODE_BORDER_RANGE, LINK_RANGE } from "./data.js";
const BENDS = [0, 0.07, 0.14, 0.21, 0.28]; // the Bend slider's stops (⟂ offset as a fraction of the chord)
/**
* The **flow-border + half-arrow** glyph style (the `network-rendering` look), shown on the reference
* two-node network **without LOD** — so it's clear these are plain rendering features. The planted
* **flow** model drives every channel through a d3 scale: node total flow → fill colour + radius,
* enter/exit flow → ring width + colour, link flow → half-arrow width + colour. With
* `linkStyle: "half-arrow"` each directed link is one filled shape that pinches to the source centre
* and lands its barbed tip on the target node's boundary; a reciprocal pair nests around a shared
* centre curve. Switch the **backend** (WebGL / Canvas / SVG) — the render is equivalent — export, go
* fullscreen, scroll to zoom, drag the **Bend** slider, and switch the **Border** mode.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
const { minX, maxX, minY, maxY } = REPLICA_BOUNDS;
const k = Math.min(width / (maxX - minX), height / (maxY - minY)) * 0.95;
net.setTransform({ k, x: width / 2 - ((minX + maxX) / 2) * k, y: height / 2 - ((minY + maxY) / 2) * k });
net.enableZoom([k * 0.3, k * 12]);
const g = buildReplica();
const graph = buildGraph({ nodeCount: g.nodeCount, source: g.source, target: g.target, weight: g.weight, directed: true, nodeFlow: g.flow });
net.data(graph).layout({ backend: "positions", positions: g.positions });
// d3 scales over the planted flow, with the reference's domains & ranges (range minimums ≥ 1 so
// nothing vanishes at low flow). Colour ranges interpolate in RGB, as in the reference.
const fillColor = scaleLinear<string>().domain([0.4, 0.6]).range(NODE_FILL_RANGE);
const radius = scaleLinear().domain([0.4, 0.6]).range([20, 30]);
const borderColor = scaleLinear<string>().domain([0.2, 0.3]).range(NODE_BORDER_RANGE);
const borderWidth = scaleLinear().domain([0.2, 0.3]).range([3, 6]);
const linkColor = scaleLinear<string>().domain([0.3, 0.5]).range(LINK_RANGE);
const linkWidth = scaleLinear().domain([0.3, 0.5]).range([7, 13]);
return {
engine: net,
render: (options) => {
const bend = BENDS[(options.bend as number) ?? 2] ?? 0.14;
// World (default): radii/widths are world units and scale with zoom (the reference is a fixed
// publication layout). Screen: they're constant pixels as you zoom, while the nodes still move
// apart/together — the navigation register LOD wants. (Screen-mode half-arrows are WebGL-only.)
const sizeMode = options.sizing === "Screen" ? "screen" : "world";
// Absolute: the flow border is `scale(exit flow)` wide. Share: it covers the share of the node's flow
// that leaves it (exit flow / flow) as a share of its area — here half of each node.
const color = (v: number) => borderColor(v);
const flowBorder = options.border === "Share" ? { share: true as const, flow: g.outFlow, color } : { flow: g.outFlow, scale: borderWidth, color };
net.style({
directed: true,
linkStyle: "half-arrow",
sizeMode,
nodeRadius: { by: "flow", scale: radius }, // radius ∝ total flow
nodeFill: { by: "flow", scale: fillColor }, // fill ∝ total flow
flowBorder, // ∝ enter/exit flow
linkBend: bend,
linkWidth, // half-arrow width ∝ link flow
linkStroke: linkColor, // half-arrow colour ∝ link flow
});
},
};
};

Drawing every node and edge stops scaling long before 10M. net.lod({ … }) turns on an adaptive hierarchy cut: d3gl keeps the multilevel coarsening tree around after layout and, each frame, walks it top-down for the current view — a dense region collapses to a single aggregate glyph, and expands into its members as soon as their glyphs would no longer overlap, or once its on-screen footprint grows past a threshold as you zoom in. Per frame the engine touches the visible frontier, not the whole graph.

net
.style({ sizeMode: "screen" }) // constant-pixel glyphs — the natural register for navigating
.lod({
expandPx: 48, // optional: pin the expand footprint in px (omit ⇒ tree-adaptive default)
aggregateFill: "#7f97c8",
maxAggregateRadius: 26, // cap the aggregate glyph size (pixels, in screen sizeMode)
declutter: true, // thin overlapping glyphs by importance (default: the node-size metric)
superEdges: true, // summarise connectivity between aggregates
crossLevelEdges: true, // also link a visible leaf to a still-collapsed module (opt-in, #139)
crossFade: 0.3, // ease aggregate↔children across the expand threshold (opt-in, #133)
});
net.lod(false); // back to drawing every element

What it composes:

  • Overlap decides where to aggregate. An aggregate is drawn only where its members’ glyphs would overlap: once they would clear one another at the current zoom — each at its own radius, and never smaller than half a pixel — the cut opens it, whatever its size. So a small graph shows every node at the fit view, a large one still aggregates its dense regions, and a sparse corner of a large one opens first — one rule for every graph size and every tree, with no node-count threshold. The zoom at which a node’s members clear is computed with the layout and the style (not per frame), so the cut tests one number per node it visits.
  • Aggregates carry their subtree’s centroid and a radius (area-additive √Σr², or — when sizing by an additive metric like flow — the same node scale applied to the summed child value, so a module reads as its total flow). Aggregate identity is stable, so they don’t pop as you pan.
  • Declutter drops glyphs that would overlap a kept one, keeping the highest-importance member — zoom-dependent, so more resolve as you zoom in. Importance is summed up the tree (a module’s is its members’ total) and set by style({ importance }) — a metric ("degree"/"strength"/"flow"), an accessor, a Float32Array, or "order". It defaults to the node-size metric, so the biggest glyph wins an overlap. A glyph is hidden when its centre lies within declutterSpacing·(rᵢ + rⱼ) of a kept glyph. The default declutterSpacing: 2 keeps a gap around each kept glyph, so the links between them stay visible, but in a small graph it can hide a whole module that doesn’t overlap anything; declutterSpacing: 1 hides only real overlaps, and declutter: false hides nothing.
  • Super-edges draw connectivity between visible nodes (a leaf↔leaf graph edge, or an aggregate↔aggregate coarse edge); a visible node keeps its edges even when a neighbour is off-screen. By default they connect same-level pairs (and, in a ragged module tree, a leaf to a visible deeper node holding the other end of its edge), so when you expand one region its leaves lose their links to the still-collapsed regions; crossLevelEdges: true restores them by projecting the off-frontier endpoint to its nearest visible ancestor (opt-in — zero added cost when off). Either way each underlying edge is counted once. superEdges: false suppresses them under LOD only; to drop links everywhere — LOD on or off — use style({ linkStyle: "none" }) instead.
  • expandPx is the footprint threshold, in absolute pixels — it sets how coarse the map is where members would overlap (an aggregate whose members clear opens before it). Leave it out and each tree picks its own: the cut scales the default by how many children the tree’s finest aggregates hold, so structural coarsening (2 members per aggregate) keeps the familiar ~48px while a provided module partition (30–60 members, 5–12× bigger on screen) gets a module-sized threshold — lod({ modules }) opens on a map of modules, not on raw nodes. Set a value to pin it (the meaning is unchanged).
  • crossFade smooths level transitions: over a band around the expand threshold (a fraction of expandPx, e.g. 0.3), the aggregate and its children draw together, the parent easing out as the children ease in — so a split/merge reads smoothly instead of popping. Opt-in; off (and free) by default.
  • sizeMode — "world" (glyphs scale with zoom) or "screen" (constant pixels, so nodes stay visible when zoomed out). LOD pairs naturally with "screen".

The geometry tracks the layout as it converges (LOD helps during the solve, not just after), while panning and zooming only re-run the cheap cut. On the WebGL lane the cut re-runs live every frame — once per animation frame, however many wheel or pan events arrive in it (the latest view wins). On the Canvas/SVG backends the same frontier draws as retained geometry — but a retained backend can’t re-tessellate per frame, so there the frontier is static during a gesture and re-cuts when it ends, or after a setTransform while zoom is enabled (call syncScreenGeometry() to re-cut at a chosen zoom before a programmatic export).

toSVG() exports a level-of-detail map on every backend: on Canvas/SVG from that retained frontier, and on WebGL from a vector view of the lane’s current emit, built at export time only.

With the worker backend, enable LOD before layout({ backend: "worker" }) and the worker builds the hierarchy itself — it reuses the coarsening it already computes for the multilevel seed, then streams the tree once plus its aggregate geometry each frame. The main thread then never coarsens or runs the per-frame O(N) geometry pass; it only fills the style geometry once and runs the on-screen-bounded cut. That keeps the main thread free as the network scales toward millions of nodes. The GPU backend does the same: layout({ backend: "gpu" }) after lod() starts a worker that only coarsens the graph and then refits the tree’s geometry to each frame the GPU reads back (or, for the spatial source, rebuilds the spatial tree for each one), and net.lodSource reports "worker" for both.

This holds for new data too. Until a layout has run on the graph data() set, lod() can’t know which backend comes next, so it leaves its own main-thread build to the end of the current call chain: the worker or GPU layout that follows wins (the GPU layout’s LOD worker streams the tree, #377; a worker builds the module tree for a nested layout or a GPU layout’s module seed, below), and any other path (no layout, positions, force) still has its tree before the next frame. On those paths a read that needs the tree right away — pick(), selection(), toSVG() — builds it at once. Until the worker’s tree arrives nothing is drawn, so nothing is labelled. A select(), highlight() or setStyle() made while no tree is drawn is kept and drawn when the tree lands, exactly as if it were made then — it never builds the tree itself.

net
.lod({ expandPx: 48, maxAggregateRadius: 26 }) // configure LOD first…
.layout({ backend: "worker" }); // …so the worker builds + streams the tree (or "gpu": its LOD worker)

Structural coarsening groups nodes that are tightly linked, wherever the layout puts them. On a graph whose force layout keeps communities together that reads as a map; on a web graph, where the layout spreads each group across the view, an aggregate’s members are scattered, its glyph sits at a centroid in empty space, and the cut has to open a large share of the graph at every zoom (a quarter of a 325k-node web graph at the fit view). lod({ source: "spatial" }) groups nodes by where they are instead: a quadtree over the layout, up to 8 nodes per bottom cell, so every aggregate is a compact region drawn where its members are.

net
.style({ sizeMode: "screen" })
.lod({ source: "spatial", maxAggregateRadius: 26 }) // configure LOD first…
.layout({ backend: "worker", iterations }); // …so the worker rebuilds the tree as it streams (or "gpu": its LOD worker)
  • Bounded by the screen where glyphs overlap. The footprint rule sets the frontier by the view area over the expand threshold, not by the graph: a few hundred glyphs at the fit view and up to ~2k when zoomed in, so the cut and declutter cost next to nothing at any size. The overlap rule adds the members of every cell whose own glyphs clear one another. The nodes of one cell do not overlap each other, but nodes of neighbouring cells may, so where glyphs are small next to their spacing (a sparse layout, or sizeMode: "world" zoomed in) the frontier can reach every visible node, as with LOD off. A bottom cell (up to 8 nodes) opens as soon as its members fit.
  • It follows the layout. A tree built from older positions blows up as nodes wander out of their cells, so the "worker" backend rebuilds it in the worker on every streamed frame — topology, geometry and aggregated style, handed to the main thread whole (transferred, not copied) — and stops once the layout has converged. The "gpu" backend’s LOD worker does the same for every frame the GPU reads back, so the main thread never rebuilds the tree while either streams. The other backends rebuild it on the main thread whenever a layout lands; a position transition or a drag only refits the tree it has, and rebuilds it once the nodes come to rest.
  • Links are summed from the graph. A rebuilt tree has no super-edge table (building one per frame would cost more than the layout tick); the links between drawn glyphs are summed from the graph’s own edges through the glyphs’ members. Every neighbour resolves to the one glyph that covers it, so links between glyphs at different levels are always drawn (crossLevelEdges makes no difference here), a link leaving the view points at the off-screen cell that holds its neighbour (a coarser cell the further away it is), and an undirected pair is one line carrying both directions’ flow.
  • While a worker streams, it sums them too. The "worker" backend’s layout worker, or the "gpu" backend’s LOD worker, cuts and declutters each tree it rebuilds the way the main thread will — at your view, or at the fit while the camera follows the layout — and hands over, with the tree, each kept glyph’s links summed per glyph or off-screen cell it reaches. A streamed repaint then reads those rows — bounded by what the kept glyphs link to, not by the edges under them — instead of every edge in view: about 1 ms instead of 14 ms for the cut, declutter and links at the fit view of a 325k-node, 1.5M-edge web graph (3 ms instead of 9 ms at 4× zoom), for 14-16 ms more work per rebuild in the worker (5-9 ms at 4×); on a million nodes with 10M edges, 1.5 ms instead of 225 ms at the fit view and 16 ms instead of 216 ms at 4×, for 90-190 ms more per rebuild in the worker (a force tick there takes ~1 s) and 0.4-8 MB per frame. A glyph whose row the worker’s cut cannot serve — mid-gesture, or after you zoom a settled layout — is summed on the main thread from its members’ edges, as below.
  • Elsewhere the main thread sums them. On the other backends, and for a tree the main thread rebuilt, the work is proportional to the edges of the drawn glyphs’ members, and an unchanged view re-emits them from a per-tree memo. At the fit view that is every edge in view: about 10 ms per frame for the 325k-node graph (a zoom frame in the browser: 5 ms median, 8 ms p90), but ~190 ms for a million nodes with 5.8M edges. So away from a streaming worker the spatial source is for graphs up to a few hundred thousand nodes; at millions of edges, a layout that keeps communities together, such as this example’s, draws a compact map with "structure" at a fraction of the cost.
  • Aggregates are regions. An aggregate is sized area-additively (its ink ≈ its members’ total) and reads as “this part of the map” — it is not a community. Its id changes with each rebuild; a selected or hovered aggregate is carried over to the cell in the same place (leaves keep their ids).

"structure" stays the default for a plain graph (and edge-less graphs always get the spatial tree). The coarsening tree still seeds the multilevel layout either way. For a map of communities, run a clustering and hand it in as a module hierarchy (below). The Source control in the example above switches between the two trees.

Maps of networks: a provided module hierarchy

Section titled “Maps of networks: a provided module hierarchy”

When the app already has a module hierarchy — e.g. an Infomap clustering — hand it to the engine with the graph, as data. d3gl does not cluster; the tree is computed app-side and handed in, just like externally-provided positions. The LOD cut then draws it instead of coarsening structurally: modules expand → sub-modules → leaves on zoom through the same adaptive cut.

Pass Infomap’s JSON nodes array straight through: each record’s id is the node index (aligned with buildGraph) and path is its 1-based module chain ([2, 1, 3] = top module 2 → sub-module 1 → the node ranked 3). data() checks that the records cover every node once, and throws otherwise.

import infomapResult from "./network.json"; // Infomap JSON: { nodes: [{ id, path, flow, … }], … }
net
.data(graph, { modules: infomapResult.nodes })
.style({ sizeMode: "screen" })
.lod({}) // no expandPx ⇒ the module-sized default; it opens as a map
.layout({ backend: "positions", positions });
net.lodSource; // "modules"

The engine owns the hierarchy, not the LOD options, so every module feature reads it in any LOD mode:

  • the LOD cut draws it by default; lod({ source: "structure" }) coarsens the graph structurally instead, lod({ source: "spatial" }) groups by position, and lod(false) draws every node — all keep the hierarchy, so switching back reuses the tree it built (once) rather than rebuilding it;
  • layout({ nested: true }) lays the module tree out top-down, each module’s children inside its own disc — with LOD on or off (re-clustering re-lays it out warm, from the current positions);
  • the module-aware GPU seed (layout({ backend: "gpu" })) seeds over the modules;
  • every pick reports the target’s Infomap path (hit.datum.path): a module’s path for an aggregate, the node’s own path for a leaf, LOD on or off.

The module tree is built once per hierarchy, when something first needs it — and off the main thread when that can wait. A layout({ nested: true }) on the worker or gpu backend, and the GPU seed of a layout({ backend: "gpu" }), have a worker build the tree (the main thread only packs the records into typed arrays and hands them over) and start when it arrives, and a cut that draws the modules draws them from that same tree as soon as it lands — nothing is drawn until then, as while a worker streams a structural tree, and a selection made meanwhile is drawn when it lands. Everywhere else (lod() with no such layout, the force and positions backends, and wherever Web Workers are unavailable) the tree is built on the main thread before the next frame.

A new data(graph) without a hierarchy clears it. Module-level links (an Infomap .ftree’s *Links rows) go alongside: data(graph, { modules, moduleLinks }). (lod({ modules }) still works as an alias scoped to the LOD options — lod(false) drops it.) The flat force layouts (force, worker, gpu, auto) pull along module links as well as along the graph’s edges: each is a spring between the centroids of its two ends’ members (a leaf end is the leaf itself), as strong as the leaf links of mean flow it stands for (its flow over the graph’s mean edge weight), and every member of a module shares its pull, so the module moves as one body — during the layout and when you drag it. Module links are summed with the graph’s edges, for the forces as for the super-edges, so pass only the links the graph does not already have (an .ftree keeps each link in exactly one place). Each tick costs one pass up the module tree, one over the module links and one back down. Everything else about the cut (declutter, sizeMode, the on-screen-bounded per-frame work) is the same as for structural coarsening. Under LOD a module’s flow border sums its members’ enter/exit flow unless you give it its own (flowBorder.moduleFlow, see Flow borders), and its bent-half-arrow super-edges size by the accumulated weight of the edges they subsume — the same flow-border and bent-link glyphs as above, driven by the cut.

When the LOD hierarchy comes from a provided module tree (rather than structural coarsening), the adaptive cut becomes modular-aware: nodes aggregate into their parent module as you zoom out, and expand back to sub-modules and leaves as you zoom in. This example builds an undirected Sierpinski gasket whose recursive subdivision is the module hierarchy (an Infomap-style path per node), fed to net.data(graph, { modules }).

Each node is coloured by its top-level module through a categorical palette, and nodeFill accepts a per-node accessor — so a module’s aggregate glyph and all of its leaves share one colour, and you can read the planted hierarchy at every zoom level. Links are simple bent lines (linkBend), nodes get a 1px white outline (nodeBorder: { width: 1, color: "#fff" }; without a color, each node’s outline is a darker shade of its own fill, as a flow border’s ring is). Scroll to zoom and watch the three top modules expand → sub-modules → leaf triangles; the Depth slider grows the gasket from 27 to 2,187 nodes, and LOD off draws every node.

fps 0frame 0 ms
Depth243
LOD
Cross-fade0.2
draw.ts
import { network, buildGraph, moduleColors, type NetworkLinkHit } from "@mapequation/d3gl/network";
import type { ImperativeSetup } from "../types.js";
import { generateSierpinski, SIERPINSKI_BOUNDS } from "./data.js";
const DEPTHS = [2, 3, 4, 5, 6]; // 27 → 2187 nodes
/**
* **Modular-aware level of detail + aggregate inspection.** An undirected Sierpinski gasket whose
* recursive subdivision *is* a planted module hierarchy (Infomap-style `path` per node), fed to
* `net.data(graph, { modules })`. Each node is coloured by its **top-level module** (a categorical palette), so
* a module glyph and all its leaves share one colour. Zoom out and nodes **aggregate into their parent
* module**; zoom in and modules expand → sub-modules → leaf triangles — the colour stays, so you can
* read the hierarchy at any scale.
*
* `net.interactive({ selectable, hover, draggable })` opts the nodes/aggregates into selection (#105
* N7c-2): **hover** shows a ring, **click** selects (shift/⌘-click adds), and `on("select")` reports each
* hit's `members()` — the **leaf node ids inside a clicked module aggregate** — shown in the caption.
* With a **multi**-selectable lane, **shift+drag** draws a **marquee** (#159) that adds every
* node/aggregate whose centre falls in the box (a CPU range query over the frontier — no extra setup);
* hold **option/alt** to **subtract** the box instead (a +/− cursor badge shows which). With **draggable** (#140), a **plain drag
* starting on a glyph moves it** instead of panning: grab a node, a whole **selection**, or a collapsed
* **module** to drag its entire subtree (these coordinates are fixed, so the drag *translates* the
* grabbed set — on a `force`/`worker` layout it also reheats the simulation). Plain drag on empty space pans,
* and holding **⌘** (Ctrl on Windows/Linux) pans from anywhere, even over a glyph (#178).
*
* `net.pickLinks()` adds **pixel-exact link picking** (#141, WebGL): the links are thin bent strips, so
* resolving "the link you see" uses a GPU-readback pass behind the same pick seam. Hover a link (or a
* super-edge between two collapsed modules) and the caption reports its endpoints — `on("hover")` gets a
* hit with `layer: "links"` and a `NetworkLinkHit` datum. Nodes are drawn on top, so they win where they overlap.
*
* `net.labels({ labelOf })` adds **frontier labels** (#105 N7b): a size badge on every visible module
* aggregate (here `labelOf` returns null for leaves, and no `max` is set — on a symmetric gasket showing
* all modules reads clearer than an arbitrary top-k), re-placed on pan/zoom. Scroll to zoom, drag to
* pan; the Depth slider grows the gasket from 27 to 2,187 nodes.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
// Frame the gasket's fixed world bounds once, BEFORE enableZoom — so d3-zoom seeds its internal
// transform from this view and the first gesture doesn't snap back to identity. render() never
// touches the transform.
const { minX, maxX, minY, maxY } = SIERPINSKI_BOUNDS;
const k = Math.min(width / (maxX - minX), height / (maxY - minY)) * 0.9;
net.setTransform({ k, x: width / 2 - ((minX + maxX) / 2) * k, y: height / 2 - ((minY + maxY) / 2) * k });
net.enableZoom([k * 0.3, k * 40]); // bracket the fit scale
// A caption overlay that reports what the current selection covers. Built into the host so the code
// tab stays pure d3gl + a tiny DOM readout. pointer-events:none so it never intercepts pan/zoom.
const caption = document.createElement("div");
caption.style.cssText = "position:absolute;left:8px;bottom:8px;max-width:calc(100% - 16px);padding:4px 8px;font:12px/1.4 ui-monospace,monospace;color:#e5e7eb;background:rgba(17,24,39,0.72);border-radius:4px;pointer-events:none;white-space:pre-wrap";
const HINT = "Hover to ring · click to select (⇧/⌘ adds) · ⇧+drag to box-select (⌥ subtracts) · drag a glyph to move it · ⌘/Ctrl+drag to pan";
caption.textContent = HINT;
host.appendChild(caption);
// Current selection summary (members() = the leaf node ids each glyph covers) — the caption falls back
// to this when nothing is being hovered, so a transient link readout restores it on pointer-out.
const selectionText = (): string => {
const hits = net.selection();
if (hits.length === 0) return HINT;
const leaves = hits.flatMap((h) => h.members?.() ?? []);
const sample = leaves.slice(0, 12).join(", ");
return `Selected ${hits.length} glyph${hits.length > 1 ? "s" : ""} covering ${leaves.length} leaf node${leaves.length > 1 ? "s" : ""}: ${sample}${leaves.length > 12 ? ", …" : ""}`;
};
// Selection + hover ring for nodes/aggregates (#105 N7c-2) + pixel-exact link picking (#141).
net
// Default ring palette: green hover/will-add, blue selection, red will-remove (consistent with the +/− marquee badges).
// draggable (#140): grab a node / selection / collapsed module and drag the grabbed set under the cursor.
.interactive({ selectable: { multi: true }, draggable: true, hover: true })
.pickLinks() // GPU-readback: hover/click now also resolves links (layer: "links"), not just nodes
.on("select", () => { caption.textContent = selectionText(); })
.on("hover", (hit) => {
// A link hit (the cursor is over a link/super-edge and not over a node, which wins): show its
// endpoints; off a link, restore the selection summary (or the hint).
if (hit?.layer === "links") {
const { source, target, aggregate } = hit.datum as NetworkLinkHit;
caption.textContent = `${aggregate ? "Super-edge" : "Link"} ${source} – ${target}`;
} else {
caption.textContent = selectionText();
}
});
// Frontier labels (#105 N7b): a size badge on EVERY visible module aggregate (no `max` — the gasket is
// symmetric, so showing all reads clearer than an arbitrary top-k; `labelOf` returns null for leaves,
// so only modules are badged). Re-placed (and re-picked) as you pan/zoom. Labels come pre-styled
// (dark 11px sans-serif + white halo); `style` tweaks individual properties inline.
net.labels({ labelOf: (id, info) => (info.aggregate ? `${info.count}` : null), style: { color: "#1f2937" } });
return {
engine: net,
dispose: () => caption.remove(),
render: (options) => {
net.select("nodes", null); // a new graph/cut invalidates prior node ids — clear the selection
const depth = DEPTHS[(options.depth as number) ?? 2] ?? 4;
const lod = options.lod !== "Off";
const { nodeCount, source, target, weight, positions, modules } = generateSierpinski(depth);
const graph = buildGraph({ nodeCount, source, target, weight });
// Hierarchical module colours: top modules split the hue circle, sub-modules vary within.
const colors = moduleColors(modules);
net
.data(graph, { modules }) // the module hierarchy is data: the LOD cut draws it by default
.style({
sizeMode: "screen",
nodeRadius: 6,
nodeFill: (i) => colors[i]!, // hierarchical module colour; LOD aggregates take their family hue
nodeBorder: { width: 1, color: "#ffffff" },
linkBend: 0.18, // bent lines (undirected — no arrowheads)
linkStroke: "rgba(90,100,120,0.55)",
// Uniform: every edge has weight 1 and bridges don't aggregate here, so links stay constant.
// (linkWidth also accepts a (weight) => width scale; super-edges then size by accumulated weight.)
linkWidth: 2.5,
})
// crossFade (#133): opt-in opacity cross-fade of a module ↔ its sub-modules across the expand
// threshold (slider × 0.1 = band half-width). The self-similar gasket has no mixed-level frontier,
// so crossLevelEdges (#139) doesn't apply here.
.lod(lod ? { expandPx: 120, maxAggregateRadius: 26, crossFade: ((options.crossFade as number) ?? 0) * 0.1 } : false)
.layout({ backend: "positions", positions });
},
};
};

The example above is also interactive: hover a node or module to ring it, and click to select (the caption reports what the selection covers). That comes from one opt-in call:

net.interactive({ selectable: { multi: true }, hover: true })
.on("select", (hits) => {
// hits[].members() lists the leaf node ids each glyph covers — one node for a leaf,
// the whole subtree for a collapsed module aggregate.
const leaves = hits.flatMap((h) => h.members?.() ?? []);
console.log(`selected ${hits.length} glyphs covering ${leaves.length} leaf nodes`);
});

interactive() opts the nodes/aggregates (which render on the GPU instanced lane, not as DOM/Scene drawables) into the same selection machinery as ordinary layers. Selection and hover are drawn as a ring overlay (a companion instanced lane drawn on top — translucent, no per-frame rebuild), and the hit handed to on("hover" | "click" | "select") (and every entry of selection()) carries a lazy members(): for an aggregate it walks the LOD subtree to its leaf node ids, for a leaf it returns just itself. Use it to read out the members of a clicked module — names, stats, a filter. members() is the same call on every engine (network aggregates, decluttered plot points, geo markers); only the glyphs that aggregate something return more than [id]. The whole selection set is observable with net.selection() and persists across pan/zoom; nothing is highlighted ⇒ zero added per-frame cost.

When the lane is multi-selectable (selectable: { multi: true }), shift+drag draws a marquee that adds every node/aggregate whose centre falls in the box to the selection — additive, like shift+click, and observable through the same on("select") / selection(). Hold option/alt while dragging to subtract instead (the Illustrator gesture) — the box then removes its glyphs from the selection, and a small badge follows the cursor showing + (add) or − (subtract). The live preview matches: adding rings the box’s glyphs in the hover colour (“will add”), subtracting rings the box’s selected glyphs yellow (“will remove”), so you can see exactly what releasing will do. No extra call: it’s built into the gesture layer and gated on multi-select (plain drag pans or drags a node; shift is reserved for the box). It’s a CPU range query over the screen-bounded frontier — exact and cheap even at millions of nodes, no GPU readback (the link picker needs the GPU because links are thin; node centres in a rect don’t).

Nodes are circles, so hit-testing them is exact and cheap on the CPU (point-in-circle over the visible frontier) — that’s the picking above, and it’s always on. Links are a different shape: thin strips, bent curves, half-arrows. To resolve the link you actually see over that geometry, opt in to GPU-readback link picking (WebGL only):

net.pickLinks(); // enable (pickLinks(false) to disable)
net.on("hover", (hit) => {
if (hit?.layer === "links") { // a link hit (vs "nodes")
const { source, target, weight, aggregate } = hit.datum; // NetworkLinkHit
// aggregate === true under LOD ⇒ a super-edge between two collapsed modules
}
});

Once enabled, on("hover" | "click") and net.pick(x, y) resolve a link as a hit with layer: "links" and a NetworkLinkHit datum ({ source, target, weight, aggregate }). With LOD off the hit is a graph edge; under LOD it’s a super-edge — its source/target are the (possibly aggregate) tree nodes and weight is the summed flow. Nodes win: a node drawn over a link resolves to the node, matching what’s on top.

How it works: the link instances are drawn into an offscreen buffer with each instance’s id encoded as colour, and the pixel under the cursor is read back to an id (the same idea as pickAt, over the instanced lane). Hover uses an asynchronous double-buffered readback — the result can lag the cursor by one pointer event, never stalling the render — so there is no per-frame readback cost; clicks read synchronously for an exact hit. It’s opt-in because it adds a per-link pick pass, so a non-interactive network pays nothing.

net.labels({ … }) puts text labels on the LOD frontier — at each visible leaf/aggregate centroid, re-placed as you pan and zoom. By default there’s no cap: every visible glyph that has a label is shown, thinned only by collision culling. Return null from labelOf to leave a glyph unlabelled — the gasket above labels only module aggregates (with their node count), not leaves:

net.labels({
labelOf: (id, info) => info.aggregate ? `${info.count}` : null, // badge modules; skip leaves
});

Labels come pre-styled — a compact dark sans-serif with a white halo, readable over busy geometry with zero CSS. Pass style (an inline CSS-properties object, merged over the default) to tweak it — style: { color: "#1f2937" } recolours and keeps the rest — or take full control with className, which skips the built-in default so your class’s CSS decides everything.

Long labels. Set maxWidth (screen px of text) to draw a long label trimmed with an ellipsis — a state network’s names, say, like {Allentown/Bethlehem/Easton,PA:LehighValleyInternational}_Albany,NY:AlbanyInternational, would cover the map and push other labels out of placement. Placement culls on the trimmed box, and with interactive({ hover: true }) the hovered glyph’s label shows in full while the pointer is on it (even where collision dropped it), on every backend:

net.interactive({ hover: true }).labels({ labelOf: (id) => names[id], maxWidth: 120 });

It is px rather than characters because the collision box is measured in px, so the cap bounds the footprint whatever the font or the text. Unset (the default), labels are drawn in full. The text is measured and trimmed once per distinct string, so a frame costs a lookup per candidate, and a hover change costs O(1): one label rewritten, no re-placement. The load-a-network example trims two long names in its sample (hover one to read it in full).

Set max to surface only the most important few on a dense map — the directed map of modules below uses max: 12, where flow varies so the top-12 by importance (the tree’s weight = summed flow) are the dominant modules/hubs.

Nothing overprints. Each label’s text is measured (once per distinct string, not per frame) and the engine places labels in importance order — importanceOf, defaulting to the tree’s weight with LOD on and node strength with it off — skipping any whose box would overlap one already placed. So a dense centre keeps the labels that matter and drops the rest instead of stacking them, at every zoom level, on every backend. Placement is grid-backed, so its cost is linear in the labels currently in view.

The engine owns the wiring — it cuts the frontier, (optionally) ranks by importance, and reconciles an HTML overlay of <div> labels over the canvas (crisp + accessible), re-placing them every pan/zoom. You supply only labelOf (node text is yours — the graph carries no names). With the LOD cross-fade on (lod({ crossFade })), labels fade in lockstep with the glyphs they sit on (per-label opacity from the same cut alpha).

Labels are rendered by the active backend: on WebGL they’re an HTML overlay (crisp + accessible, styled by the default + style/className above); on SVG/Canvas the backend draws them natively — SVG <text>, Canvas fillText (font/color/halo override the matching native defaults, since CSS can’t reach backend-drawn text). On every backend the placed labels appear in toSVG()/toPNG() export — WebGL composites them into the PNG and serializes them as <text> at export time, so a labelled export matches what you see regardless of the backend you happened to run. Switch this example to the SVG backend and Export SVG for publication-ready labelled vector output. GPU/MSDF text (to put WebGL labels on the GPU for very high counts) is #69.

This brings it together: an LFR planted-partition network rendered as a directed map of modules. Each undirected benchmark edge is split into a reciprocal pair, and the random-walk flow is computed at runtime by d3gl’s randomWalkFlow, which reproduces Infomap’s directed flow — a test cross-checks it against @mapequation/infomap (the C++/WASM reference) to ~1e-7. Flow then drives every channel: node radius ∝ visit rate, the ring ∝ enter/exit (boundary) flow, and the half-arrow width + colour ∝ link flow — so a reciprocal pair’s two arrows carry genuinely different weight. The Nodes slider resizes the generated network (500 → 20,000).

The layout is the module-aware GPU seed: the module hierarchy is supplied with the graph, data(graph, { modules }), so layout({ backend: "gpu" }) seeds the WebGL2 Barnes-Hut solve top-down over the module tree in every LOD mode — modules (including the ragged, deeper-nested super-modules) lay out as coherent regions. Because the GPU solve streams frames, fit: true keeps the camera framed on the layout as it converges (the nodes’ bounding box → the view), so the map opens framed and settles in place rather than piling at the origin. It releases to normal zoom/pan once it settles — or as soon as you zoom, pan or drag a node.

fit means the same on every layout: the camera ends on the final layout’s exact bounding box, and gets there with the layout. A streamed layout (a warm one too) is reframed frame by frame; a layout with a transition eases the camera along with the nodes (below); a layout that lands in one go ("positions", "force", a state network’s too) is framed once. A cold nested map streams top-down, each depth collapsing its unplaced nodes onto their module’s centre, so while it streams it is framed on a box the worker knows it will end inside — the root disc, then each depth’s placed discs — and the camera only zooms in as depths land, ending on the nodes’ own box like any other layout.

The Layout control switches to the nested module layout (layout({ nested })): each module’s children are laid out inside its own disc, arranged by their sibling links, so every module stays a compact region of its parent. The example runs it on the GPU (backend: "gpu"): every module at every depth is solved at once — one batched solve over the whole tree, not one per module — and a fresh map streams in as one animation of all depths converging together. On a 325,729-leaf map that takes about 2 s instead of 18 s on the CPU worker (M1 Max, Chromium). A switch goes on warm, from where the nodes are, streamed from its first frame — see below.

Nodes are coloured categorically by module (the planted communities); the Fill control switches to colour ∝ flow (nodeFill: { by: "flow", scale }), where a collapsed module is filled by its members’ total flow. A collapsed module’s ring draws its own enter/exit flow — flowBorder.moduleFlow, from the per-module flows data.ts derives from the link flows — rather than its members’ sum. The LOD control switches the cut over the same engine-held hierarchy:

  • Off — every node and half-arrow.
  • Standard — plain structural coarsening (lod({ source: "structure" })); it ignores the partition, joining aggregates with simple super-edge lines.
  • Modules — the partition drives the cut, so a module collapses to one glyph and its connectivity shows as half-arrow super-edges that thicken with the accumulated flow between modules.

In sizeMode: "screen" the glyphs stay a constant pixel size, so the map reads at every zoom — scroll out to the map of modules, in to sub-modules and individual nodes. The Boundaries control rings every module the cut has opened (see below); try it with Layout: Nested, where each ring is the module’s disc. Input: .ftree passes the links between modules as module links, the way an Infomap .ftree has them: data(graph, { modules, moduleLinks }), with only the links inside each bottom module left as graph edges; Layout: Force then pulls along those module links, so the modules still gather by their links and a dragged module pulls the ones it is linked to.

fps 0frame 0 ms
Nodes1k
Input
Layout
LOD
Sizing
Fill
Declutter
ExpandAuto
Max radius18
Labels12
Cross-level edges
Boundaries
Cross-fade0.2
draw.ts
import { network, buildGraph, moduleColors, type NetworkLODOptions } from "@mapequation/d3gl/network";
import { scaleSequentialLog, scaleSqrt, type ScaleContinuousNumeric } from "d3-scale";
import { interpolateViridis } from "d3-scale-chromatic";
import type { ImperativeSetup } from "../types.js";
import { asFtree, makeModularMap } from "./data.js";
/** Nodes slider → generated network size. Capped where the runtime random-walk flow stays snappy. */
const SIZES = [500, 1_000, 2_000, 5_000, 10_000, 20_000];
/**
* A **directed map of modules** from a runtime LFR planted partition (#104 N6). Nodes are coloured by
* their **module** (a categorical hue per community), sized by their random-walk **flow**, and ringed
* by their **enter/exit flow**; directed links are **half-arrows** whose width + colour encode link
* flow. In **screen** sizeMode the glyphs stay a constant pixel size as you zoom.
*
* A collapsed module's ring draws **its own** enter/exit flow — `flowBorder.moduleFlow(path)`, read from
* the per-module flows in `data.ts` (#445) — not its members' sum, which would also count the flow between
* its submodules. The **Fill** control switches the categorical module colours for colour ∝ flow,
* `nodeFill: { by: "flow", scale }`: a collapsed module is then filled by its members' total flow.
*
* The module hierarchy is **data**: `net.data(graph, { modules })` hands it to the engine with the
* graph (#326), so every module feature reads it whatever the LOD mode. The layout is the
* **module-aware GPU seed** (#180 N8.2): the WebGL2 Barnes-Hut solve is seeded **top-down over the
* module tree** — modules (including the ragged, deeper-nested **super-modules** in `data.ts`) lay out
* as coherent regions rather than an untangling disc. `fit: true` (#206) keeps the camera framed on the
* layout as it converges — it opens centred and view-filling and settles in place, with no jump. (Falls
* back to the CPU worker where float render targets are unavailable.)
*
* The **Layout** control switches to the **nested** module layout (#324): each module's children laid out
* inside its own disc — and with **Boundaries** on (`lod({ moduleBoundary })`, #329) every module the cut
* has opened is ringed, on its disc under the nested layout. It runs on the GPU (`backend: "gpu"`, #355):
* every module at every depth solves at once, and a fresh map streams in as one animation of all depths.
* Switching goes on **warm**, from where the nodes are (#454): `layout({ backend: "gpu", nested: true, warm:
* true, fit: true })` streams the map from the force layout on screen — the nodes glide into their modules'
* discs as it forms, with no disc restart and no hidden solve first — and switching back,
* `layout({ backend: "gpu", warm: true, fit: true })`, runs the force layout from the map: it spreads out to
* the force model's own scale as it converges, the camera framing it as it grows. (Both fall back to the CPU
* worker where the GPU layout cannot run.)
*
* **Boundaries: Module** (#471) styles each opened module's ring by its module: `color: "fill"` rings it
* in the fill it had when collapsed, and `width: (path) => …` makes it as wide as the flow *into* the
* module (its enter flow, through the same scale as the flow-border ring, which shows enter + exit flow).
* **Line** is one thin ring for every module. Either way the rings sit above the links and below the nodes.
*
* The **Input** control hands the same map over as an Infomap **`.ftree`** would (#199): the graph keeps
* only the links inside each bottom module, and the links between modules arrive as **module links**,
* `data(graph, { modules, moduleLinks })`. Then no leaf edge carries a module's connectivity once it opens
* — with **Boundaries** and **Cross-level edges** on, its links stay drawn, anchored at its ring (#329) —
* and the **Force** layout pulls along the module links instead (#455): each is a spring between its two
* modules' members, so linked modules gather and a dragged module pulls the ones it is linked to. An
* `.ftree` has each module's enter/exit flow but none per node, so the flow border is given `moduleFlow`
* alone (#471): the modules are ringed by their flow, the nodes not at all.
*
* The **Nodes** slider resizes the generated network (500 → 20,000): the map is regenerated — flow and
* all — and re-laid-out, framing itself each time. The **LOD** control switches the cut:
* **Off** draws every node + half-arrow; **Standard** is plain structural coarsening
* (`source: "structure"` ignores the planted partition — aggregates joined by simple super-edge
* lines); **Modules** (the default source once a hierarchy is set) uses the partition, so
* modules collapse to a single glyph and their connectivity shows as **half-arrow super-edges that
* thicken with the accumulated flow** between modules. Scroll to zoom: modules expand → sub-modules →
* leaves (the ragged branches nest to different depths).
*
* `net.labels({ max: 12, labelOf })` badges the **12 highest-flow glyphs in view** (#105 N7b) with their
* size — re-ranked + re-placed as you pan/zoom. Unlike the symmetric gasket, flow varies here, so a
* `max` cap meaningfully surfaces the dominant modules/hubs.
*
* `net.interactive({ selectable, hover, draggable, tooltip })` adds the selection/hover rings, a tooltip and
* node-drag (#140): hover/click rings a node or module, ⇧+drag box-selects (⌥ subtracts), and dragging a glyph — or a whole
* selection, or a collapsed module — moves it: the GPU seed reheats around it, and under the **nested**
* layout the node follows the cursor while only its module is re-laid out around it (a collapsed
* module's sibling modules move aside in their parent); dragged to its module's edge, the node takes the
* module's disc and ring along, pushing the modules around it aside, up the levels. Hold ⌘ (Ctrl on
* Windows/Linux) to pan instead, even over a glyph (#178). It shows the selection/hover ring living
* alongside the per-node **flowBorder** ring and a module's **outline**. With **Boundaries** on, a module
* the cut has opened is hovered and selected by its **ring** (#476): the tooltip names the module, and the
* highlight is drawn on its ring, exactly as for the module collapsed.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
net.enableZoom([0.1, 40]); // default view; zoom out to the module map, in to single nodes
// Selection + hover rings and node-drag (#140): hover/click rings a node or module (red), ⇧+drag
// box-selects (⌥ subtracts, yellow preview), and dragging a glyph — or a whole selected set, or a collapsed
// module — moves it (⌘/Ctrl-drag pans instead). Note how the selection/hover ring sits alongside the
// per-node flowBorder ring and a collapsed module's outline. An open module is hovered by its boundary
// ring (#476): the same module datum, with `open: true`.
net.interactive({
selectable: { multi: true },
draggable: true,
hover: true,
tooltip: (d, id) =>
d.aggregate ? `${d.path ? `Module ${d.path.join(":")}` : "Aggregate"} · ${d.count} nodes${d.open ? " (open)" : ""}` : `Node n${id}`,
});
// Labels slider → max cap; the last position is "All" (no limit).
const LABEL_CAPS = [6, 12, 20, 30, 50, 100, Infinity];
// Regenerated + re-laid-out whenever the Nodes slider changes; flow-derived scales are rebuilt with it.
let count = -1;
let input = ""; // the Input control's last value
let layoutMode = ""; // the Layout control's last value ("" = a fresh graph, nothing on screen yet)
let colors: string[] = [];
let enterExit: Float32Array<ArrayBufferLike> = new Float32Array();
let moduleEnterExit = new Map<string, number>();
let moduleEnter = new Map<string, number>();
let flowColor: (flow: number) => string;
let maxNodeFlow = 1;
let ringW: ScaleContinuousNumeric<number, number>;
let linkW: ScaleContinuousNumeric<number, number>;
let linkStroke: (w: number) => string;
return {
engine: net,
render: (options) => {
const n = SIZES[(options.nodes as number) ?? 1] ?? 1_000;
if (n !== count || options.input !== input) {
count = n;
input = options.input as string;
const d = makeModularMap(n);
enterExit = d.enterExit;
moduleEnterExit = d.moduleEnterExit;
moduleEnter = d.moduleEnter;
// Categorical colour per planted module; aggregates inherit their module's colour under LOD.
colors = moduleColors(d.modulePaths, { lightness: 62, chroma: 58 });
maxNodeFlow = d.nodeFlow.reduce((a, b) => Math.max(a, b), 0);
const maxEnter = d.enterExit.reduce((a, b) => Math.max(a, b), 0);
const maxLink = d.linkFlow.reduce((a, b) => Math.max(a, b), 0);
// Range minimums keep glyphs/links from vanishing (the ring may be 0 for interior nodes).
ringW = scaleSqrt().domain([0, maxEnter]).range([0, 6]);
// Fill "Flow": a log colour scale from the smallest node's flow to the whole map's, so a node and a
// collapsed module — filled by its members' total flow — share one legend.
const minNodeFlow = d.nodeFlow.reduce((a, b) => (b > 0 ? Math.min(a, b) : a), Infinity);
flowColor = scaleSequentialLog(interpolateViridis).domain([minNodeFlow, 1]).clamp(true);
linkW = scaleSqrt().domain([0, maxLink]).range([0.75, 6]); // thin half-arrows
// Link colour encodes flow (light → dark blue) and is semi-transparent (alpha ∝ flow) so overlaps
// read as density, not black — a reciprocal pair shows its asymmetry in both width AND colour.
// (The scale interpolates the RGBA range, alpha included.)
linkStroke = scaleSqrt<string>().domain([0, maxLink]).range(["rgba(150, 186, 221, 0.4)", "rgba(40, 90, 161, 0.9)"]).clamp(true);
// ".ftree": only the links inside bottom modules are graph edges; the rest arrive as module links.
const ftree = input === ".ftree" ? asFtree(d) : null;
const graph = buildGraph({
nodeCount: d.nodeCount,
source: ftree?.source ?? d.source,
target: ftree?.target ?? d.target,
weight: ftree?.linkFlow ?? d.linkFlow, // edge weight = flow, so LOD super-edges accumulate flow
directed: true,
nodeFlow: d.nodeFlow,
});
// The (ragged) module hierarchy — and an .ftree's module links — travel with the graph (#326), so
// both layouts read them in every LOD mode.
net.data(graph, { modules: d.modulePaths, moduleLinks: ftree?.moduleLinks });
layoutMode = "";
}
const layout = (options.layout as string) ?? "Force";
if (layout !== layoutMode) {
const fresh = layoutMode === "";
layoutMode = layout;
// The GPU force layout seeds MODULE-AWARE (#180 N8.2): it lays the map out top-down over the module
// tree, so modules — including the deeper super-modules — form coherent regions. `fit: true` keeps
// the camera framed on the layout as it converges (#206), so the map opens framed and settles in
// place, no jump.
// A switch goes on from where the nodes are (`warm`, #454): streamed from the map on screen.
if (layout === "Force") net.layout({ backend: "gpu", fit: true, iterations: 300, warm: !fresh });
// The nested map solves on the GPU (#355). A fresh graph opens framed on it, streaming all depths
// together; a switch streams it from the force layout on screen, the nodes gliding into their
// modules' discs as it forms (#454).
else net.layout({ backend: "gpu", nested: true, fit: true, warm: !fresh });
}
// Frontier labels come pre-styled (dark 11px sans-serif + white halo) — no CSS needed.
net.labels({ max: LABEL_CAPS[(options.maxLabels as number) ?? 1] ?? 12, labelOf: (id, info) => (info.aggregate ? `${info.count}` : `n${id}`) });
const sizeMode = options.sizing === "World" ? "world" : "screen";
// Expand slider; "Auto" (0) passes no expandPx, so the cut uses the tree-adaptive default —
// ~48px for the structural tree, a module-sized threshold for the partition.
const expandPx = (options.expand as number) || undefined;
const declutter = options.declutter !== "Off";
// Node-radius range top (leaf max; modules extrapolate above it via the same scale). Smaller →
// smaller glyphs → declutter keeps more → more nodes + inter-module edges visible.
const maxRadius = (options.maxRadius as number) ?? 21;
const nodeR = scaleSqrt().domain([0, maxNodeFlow]).range([3, maxRadius]);
net.style({
directed: true,
linkStyle: "half-arrow",
sizeMode, // "screen" = constant-pixel glyphs (the navigation register LOD wants); "world" scales with zoom
nodeRadius: { by: "flow", scale: nodeR }, // radius ∝ visit rate
// Fill: a categorical module colour (a collapsed module keeps its hue), or colour ∝ flow — a collapsed
// module is then filled by its members' total flow.
nodeFill: options.fill === "Flow" ? { by: "flow", scale: flowColor } : (i) => colors[i]!,
// Ring ∝ enter/exit flow; colour omitted ⇒ a darker shade of each glyph's own fill. A collapsed
// module rings by its OWN enter/exit flow (by Infomap path), not its members' sum. An .ftree has
// no per-node enter/exit flow: then only `moduleFlow` is given, and only the modules are ringed.
flowBorder: {
flow: input === ".ftree" ? undefined : enterExit,
scale: ringW,
moduleFlow: (path) => moduleEnterExit.get(path.join(":")),
},
linkBend: 0.15, // fraction of the link's length — keeps its shape at every zoom
linkWidth: linkW, // half-arrow width ∝ link flow; super-edges use accumulated flow
linkStroke, // semi-transparent blue, alpha ∝ flow
});
const mode = (options.lod as string) ?? "Modules";
// #329: one outline per module — a ring a few px outside a collapsed module's glyph (marking it as
// expandable) and, once the cut opens it, around its nested disc — so the map of modules stays
// readable as you zoom into it. (Style the collapsed ring separately with `aggregateOutline`.)
// #471 "Module": an open module's ring in its collapsed fill, as wide as the flow into it (its enter
// flow through the ring scale; `undefined` → the default width). Read once per module, not per frame.
const enterWidth = (path: readonly number[]): number | undefined => {
const flow = moduleEnter.get(path.join(":"));
return flow === undefined ? undefined : ringW(flow);
};
const moduleBoundary: NetworkLODOptions["moduleBoundary"] =
options.boundaries === "Off"
? undefined
: options.boundaries === "Line"
? { width: 1, opacity: 0.45 }
: { color: "fill", width: enterWidth, opacity: 0.85 };
// Opt-in #139: keep a visible leaf's links to a still-collapsed module across a mixed frontier.
// Opt-in #133: ease modules ↔ sub-members across the expand threshold (slider × 0.1 = fade band).
const crossLevelEdges = options.crossLevel === "On";
const crossFade = ((options.crossFade as number) ?? 0) * 0.1;
if (mode === "Off") {
net.lod(false);
} else if (mode === "Standard") {
// Structural coarsening — ignores the partition; aggregates joined by plain super-edge lines.
net.lod({ source: "structure", expandPx, declutter, moduleBoundary, crossLevelEdges, crossFade });
} else {
// The planted partition (the default source) drives the cut → directed half-arrow super-edges
// ∝ accumulated flow. No aggregate-radius cap: a module is sized by `nodeRadius` applied to its
// members' summed flow (the scale extrapolates above the leaf domain), so a module reads as its
// total flow.
net.lod({ expandPx, declutter, superEdges: true, moduleBoundary, crossLevelEdges, crossFade });
}
},
};
};

Once the cut expands a module, it is drawn only as its members — nothing marks where it ends. lod({ moduleBoundary }) draws a thin ring around every expanded module in view, so the nested hierarchy stays readable as you zoom in, the way the Network Navigator draws its map of modules:

net.lod({
moduleBoundary: { width: 1, color: "#3a3f52", opacity: 0.5 }, // or {} for these defaults
crossLevelEdges: true, // also draw module links at an expanded module's ring (below)
});
  • Where the ring goes. After layout({ nested }) the ring is that module’s disc, and it follows the module’s members through a drag or a transition. After any other layout it is centred on the members, with their extent as its radius. The disc is also what the cut sees: after a nested layout a module is culled by its disc and opens once the disc is expandPx across on screen, rings or not.
  • width is in the active sizeMode’s units, so in screen mode it is a constant pixel stroke around a ring that scales with the map. Rings fade with their members under crossFade, draw above the links and under the nodes, so a dense module’s links never hide where it ends, and export with toSVG() / toPNG() on every backend. The root, which is the whole network, gets no ring.
  • Module links stay drawn. With data(graph, { modules, moduleLinks }), as for an Infomap .ftree, a link between modules A and B exists only at their level. When A expands there is no finer link to show, so it used to disappear. With crossLevelEdges on, it is drawn from A’s ring with its own flow instead (ring to ring when both are open), and links from the graph’s own edges are unchanged, so nothing is counted twice. Where the two rings overlap, as the centroid + extent rings of a force layout often do, the link runs between their centres rather than disappearing. An expanded module whose centre is off-screen keeps the usual off-screen rule: its links are drawn toward its centre. Try it above with Input: .ftree, which hands the example’s links between modules over as module links.
  • Cost. Each frame costs one ring per expanded module in view. The cut visits those modules anyway, so it walks the tree exactly as it does without rings. Anchoring adds those modules’ own module links. It never costs the whole tree, and it is nothing when the option is off.
  • Hover and select an open module by its ring. Once a module opens, nothing is drawn on the module itself but its ring, so the ring is how you point at it. A pointer on the ring picks the module exactly as it would pick it collapsed: on("hover" | "click"), interactive({ selectable, tooltip }), on("select") and selection() see the same id, datum and members(), and the datum adds open: true. A glyph drawn over the ring wins, where rings overlap the deepest module wins, and a ring thinner than 6 px is hit over a 6 px band. Inside the disc but off the ring there is no module, so a click there still clears the selection. With interactive() the hover or selection highlight is drawn on the ring, the module’s drawn members stay undimmed (selection.others, hover.others), and a selected module keeps its highlight as it opens and collapses. With interactive({ draggable }) the ring is also a drag handle: a drag that starts on it moves the module exactly as a drag of the module collapsed does, while a press inside the disc but off the ring, or with ⌘/Ctrl held, pans. A pointer move tests the rings the frame drew and nothing else, so hovering stays cheap with every module open. Try it in the example above: hover an opened module’s ring and the tooltip names it.
net.interactive({ selectable: true, hover: true, tooltip: (d) => (d.aggregate ? `Module ${d.path?.join(":")}${d.open ? " (open)" : ""}` : null) });

A collapsed module shows its own colour, and with flowBorder a ring as wide as its flow. Once it opens, its boundary can be styled by its module too (the example above’s Boundaries: Module):

net.style({
nodeFill: (i) => moduleColor[i], // a collapsed module is filled with its members' colour
// An Infomap .ftree has each module's enter/exit flow, but none per node: give `moduleFlow` alone.
flowBorder: { scale: ringScale, moduleFlow: (path) => enterExitOf(path) },
});
net.lod({
// In its collapsed fill, as wide as the flow into the module (its enter flow, through the same scale).
moduleBoundary: { color: "fill", width: (path) => ringScale(enterOf(path)), opacity: 0.85 },
});
  • color: "fill" rings each module in its own fill, the colour it is drawn in when collapsed: its nodeFill module colour, or aggregateFill.
  • width: (path) => number | undefined sizes each ring by the module’s Infomap path ([2, 1] is sub-module 1 of top module 2), in the active sizeMode’s units. undefined takes the constant default, as does every aggregate of a tree that is not a module hierarchy (it has no paths), and 0 draws no ring.
  • flowBorder from module flow alone. With moduleFlow and no flow, no node is ringed, a module is ringed by its own value, and a module without one has no ring: nothing is summed from values that do not exist.
  • The colours and widths are resolved once per module, when the style is resolved against the tree (again after a style() or lod() call, or for a new tree). A frame only looks each ring’s colour and width up, so it costs what the constant ring does.

Switching layouts live, from where the nodes are

Section titled “Switching layouts live, from where the nodes are”

Switching a map between the force layout and the nested map of its modules should not throw away what the reader sees. warm: true makes the next layout go on from the current positions, streamed as it solves:

// Nested map → force layout: the force solve starts from the map on screen.
net.layout({ backend: "gpu", warm: true, fit: true });
// Force layout → nested map: the map streams from the force layout on screen.
net.layout({ backend: "gpu", nested: true, warm: true, fit: true });
  • A warm force layout ("force", "worker", "gpu", "auto") starts from the current positions: no seed disc, no multilevel seed, at full heat, cooling over iterations until it converges. Streamed, it glides as the nested map below does: the engine eases the nodes from where they are toward each frame as it lands. From a nested map it spreads out to the force model’s own scale, which is about three times as wide, and with fit the camera follows it as it grows, as it follows any stream. With a structure or spatial LOD tree, which a worker streams with the solve’s own geometry, its frames are painted as they land.
  • A warm nested map without a transition streams too, at its natural size (a cold map’s root disc), centred where the current map is. Its seed — every module’s children at their current arrangement, mapped into their discs — is placed so before any module is solved. The engine then eases the nodes toward each frame of the solve as it lands, on one ease of about 600 ms that starts with the first frame and is retargeted to every newer frame without a jump. On the GPU the frames are the solve’s ticks; on the worker they are the seed, then one per depth, with the modules not solved yet kept at their seeded arrangement. From a force layout the nodes gather into their modules’ discs and the map shrinks to its own size, about a third as wide: that is the change to a map of modules, and it shows live, with fit keeping it framed as it goes. From a nested map of the same size (a re-clustering) it keeps its size. While the module tree and the solve are being prepared (the tree is built on a worker), the nodes stay where they are.
  • Cost. Each frame of the ease is a transition frame (the interpolation, the LOD position pass, the re-emit, and with fit the stream’s bounding box). It replaces the streamed frame’s position copy, and it runs at display rate for the length of the ease; frames after it are ordinary streamed frames. A frame that comes late, because the main thread was busy, moves the ease on by at most 50 ms, so a stall slows the glide instead of skipping part of it.

With a transition (below), a warm nested map is computed in one go and then eased to, placed by its result. That is the path for a re-clustering whose camera must not follow the stream.

In the example above, switch Layout between Force and Nested: each switch goes on from the positions on screen, streamed from the first frame.

Re-clustering: warm re-layout and transitions

Section titled “Re-clustering: warm re-layout and transitions”

Re-running community detection gives the same nodes a new module hierarchy. A cold nested layout would throw the current map away and start again from a disc. A warm one refines it:

// Infomap re-ran (other flags, another trial…): same nodes, new tree.
net
.data(graph, { modules: next.nodes }) // positions live in `graph` — data() leaves them alone
.layout({ backend: "gpu", nested: { warm: true }, transition: 600, fit: true });
await net.whenSettled(); // resolves when the transition ends
  • nested: { warm: true } seeds each module’s children at their current centroids (instead of a spiral) and starts the solve cooler, so it keeps the arrangement the reader already knows and only refines it. The new map is placed over the old one — same centroid, same spread — so nothing jumps off-screen, and a warm start from a nested layout doesn’t drift over repeated re-clusters. With a transition it posts no per-depth frames (those would collapse every module to a point first); without one it streams from the map on screen (above). On a graph never laid out it is the cold layout.
  • backend: "gpu" solves it on the GPU — about 2 s for a 325,729-leaf map where the CPU takes about 20 s, with the main thread busy only for a one-off ~0.1 s setup — and reads back only the final layout, once. It falls back to the worker (backend: "worker", which gives the CPU layout exactly) where the GPU layout cannot run, and when a GPU solve stops early (a lost context, a non-finite layout) none of it lands: the worker lays the map out instead. backend: "auto" runs the same GPU solve wherever "gpu" would and the worker everywhere else, without the fallback warning (a GPU solve that fails still warns). The GPU solve updates siblings together where the CPU applies them one by one, so the two maps differ in detail but keep the same invariants: every child inside its parent, siblings apart, linked siblings close. The disc collision bins each child at the scale of its own size, so a module of very uneven child sizes costs time in proportion to its children’s contacts, not to their number squared: one module of 60,000 children with power-law flows lays out in about 1.4 s (the CPU: 5 minutes) without a stalled frame, and Infomap’s trees of web-NotreDame in about 2 s. Because the GPU solve applies all of a child’s springs at once, a child linked to many of its siblings (a hub) would overshoot, so its springs are relaxed just enough to keep each step stable: a hub is pulled a little less per step than on the CPU. On web-NotreDame’s directed Infomap tree, whose hubs are small low-flow pages, that is 3.5% of the children. One limit: at 1,000,000 leaves a frame that reads positions back carries up to about 17 ms of layout GPU work, over the 10 ms frame budget.
  • transition: 600 eases every node from where it is to its new place over 600 ms (cubic ease-in-out) on the main thread. The layout computes the final positions only; each animation frame is then a positions-only repaint — no more work than a frame of a streaming layout. transition also eases a "positions" or "force" layout; a new layout(), data(), stopLayout() or destroy() stops it where it is, and grabbing a node finishes it.
  • fit: true eases the camera along with the nodes: from wherever the view is to the one that frames the new map, on the same easing in the same frames, so the re-clustered map ends framed with no jump — even when it grew. The view’s visible rectangle moves in a straight line as the nodes do, so the zoom is monotonic and a node on screen at both ends stays on screen throughout. The camera waits where it is while the layout computes the new map, and it is yours again the moment you zoom, pan or drag a node (or call setTransform) — it never pulls the view away from you. Stopping the layout (stopLayout(), a new layout() or data()) stops the camera where it is, too. If the view is resized, or the node size changes, on the way, the camera re-aims from where it is and still ends framed. Its per-frame cost is constant: the new map’s box is measured once, when the transition starts. Without fit the camera stays where it is.

The re-clustering call above is the one to use when the camera must not follow the stream: the new map is computed first and then eased in, with the camera easing along in the same frames.

A state (higher-order / memory) network adds a layer to a standard network: each state node belongs to a physical node — the same location seen in different memory / context — and links run between state nodes. Because a community detection (Infomap) partitions the state nodes, one physical node’s state nodes can land in different modules, so on the physical view a physical node has an overlapping module membership.

net.stateNetwork(graph, { modules }) ingests the state network (built by buildStateGraph, which also derives the physical network) plus a per-state-node module assignment, and net.view("physical" | "state" | "both") toggles three renderings of the same data:

  • Physical — the derived physical network (flow-sized nodes + directed, flow-summed aggregated half-arrow links). A physical node whose state nodes span ≥2 modules renders as a pie chart — one wedge per module, sized by that module’s summed flow and coloured by module; a single-module node is a plain circle.
  • State — the state graph as an ordinary network with its modules: exactly what net.data(stateGraph, { modules }) gives. It has a layout of its own (the State layout control lays it out by force, or nested by module), the LOD control cuts its module tree with a ring around each open module — the modular LOD cut — and every hit names its module path. (LOD is a state-view feature; the physical/both views draw full detail.)
  • Both — a hybrid: each physical node is drawn faintly, outlined by a thin ring in a darker shade of its colour, around its state nodes, with state-level links — the memory structure in its physical context. Each physical node packs its state nodes by area, largest first, without overlaps. In sizeMode: "world" each physical node is drawn at the size the physical view gives it (your nodeRadius), and its state nodes are scaled to fit inside it, keeping the sizes the state view gives them relative to each other: switching between the physical and both views keeps every physical node’s size and place. In sizeMode: "screen" each state node is drawn at the state view’s pixel size and its physical node is sized to enclose them; both keep their pixel size at every zoom, so neighbouring physical nodes can overlap when zoomed out, as screen-sized nodes do. Each state node keeps its place in its physical node as the physical layout moves, and a state node dragged there stays inside its physical node, where you put it.

The Nodes slider scales the physical network from 10 to 10,000. The cap comes from the demo’s synthetic data, not from the layout (the Worker and GPU backends lay out far larger graphs, as in the large-scale example above): the example builds the state network on the main thread, with one state edge per node2vec trigram, Σ deg² of them over the power-law degrees. The map controls (Sizing, Declutter, Expand, Max radius, Labels, Cross-level, Cross-fade) mirror the directed map of modules and drive the state-view LOD cut.

import { buildStateGraph, network } from "@mapequation/d3gl/network";
// A state network: state-level edges + a per-state-node physical id. The engine derives the physical
// network (physical nodes = distinct physical ids; links = state edges aggregated across the boundary).
const graph = buildStateGraph({ stateCount, stateToPhysical, source, target, weight });
net
.style({ sizeMode: "screen", nodeRadius: 9 })
.stateNetwork(graph, { modules }) // modules = Infomap's per-state-node { id, path } records
.layout({ backend: "force" }); // lays out the view on screen: the physical graph
net.view("state"); // the state graph, laid out now with the same options, and kept · "physical" | "both"
// Your own module palette, in every view: state nodes, pie wedges and physical nodes (#494). With a
// nodeFill by a metric (`{ by: "flow", scale }`) the nodes follow it; pie wedges and physical nodes keep the palette.
net.style({ moduleColor: (modulePath) => palette(modulePath) });

modules is optional: a raw state network (before Infomap) switches views too, without pies. In the both view a physical node hovers, selects and drags as its state nodes; a view switch carries the selection over (a physical node ↔ its state nodes), and every hit, selection and label says its level ("physical" or "state"), so the app names an id without mapping it. Its state nodes, physical nodes and links all follow your sizeMode. Call lod() once: it holds across view switches and applies to the state view; the physical view draws in full, every pie with it, until pies are LOD-aware. Under a module palette a collapsed module takes its own colour from it.

Lumped state nodes. The map equation codes the state nodes of one physical node in one leaf module with one codeword: within that module they are one node. stateNetwork(graph, { modules, lump: true }), or net.lump(on) at any time, draws the state and both views that way, one node per (leaf module, physical node), its flow and links the sums of its state nodes’ (lumpStateGraph gives the same network on its own). Without modules every state node is in the root module, so each physical node’s state nodes are one node. The physical view is the same either way, and keeps its layout across the toggle. The state view keeps its picture: the other form starts where the current one’s nodes are (each lump at its state nodes’ centroid, or a lump’s state nodes around it), and a warm layout settles it. While lumped, a state-level hit’s id is a lump’s: net.lumped maps it to its physical node and its state nodes, and back.

The pie is a first-class instanced glyph: one GPU instance per wedge (a [start, end] angular sector of a disc — no wedge texture, no per-fragment loop), so a physical view with many overlapping nodes draws as efficiently as the circle lane and updates in place on pan/zoom. On Canvas/SVG the same wedges trace as filled arc sectors, so the physical view exports to toSVG() and renders identically across all three backends. The synthetic data (an LFR physical network + node2vec triangle-closing trigrams, with each state node’s module set to its previous node’s community) is in the state-network-data.ts tab.

Each view keeps its own layout. layout() lays out the view on screen with the backend the Backend control picks (Force, Worker or GPU, with fit: true; GPU by default): the physical graph in the physical and both views, which share that layout, and the state graph in the state view. A view shown for the first time is laid out with the app’s last layout() options, from where its nodes are — the state nodes start on a rosette around their physical node — and every view keeps its positions across switches, so the app never re-runs a layout to switch views; a layout still running when its view is left goes on where it stood when the view is back (unless stopLayout() stopped it), and each view comes back at the pan and zoom it was left at. A node drag reheats the layout of the view it is in, also after a switch; in the both view a physical node drag reheats the physical layout, its state nodes riding along. net.positions gives the positions of the view on screen (in the both view the drawn state nodes’, which no graph holds).

fps 0frame 0 ms
Nodes100
Backend
View
State layout
Links
Colours
Modules
Lump
Physical labels
State labels
Sizing
LOD
Declutter
ExpandAuto
Max radius18
Labels12
Cross-level edges
Cross-fade0.2
draw.ts
import { network, physicalPieWedges, type NetworkHit, type PhysicalPieWedges } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import { schemeTableau10 } from "d3-scale-chromatic";
import type { ImperativeSetup } from "../types.js";
import { generateStateNetwork, type SyntheticStateNetwork } from "../shared/state-network-data.js";
const NODES = [10, 100, 1_000, 10_000]; // physical node count (see the large-scale example; capped at 10k)
const LABEL_CAPS = [6, 12, 20, 30, 50, 100, Infinity];
const VIEW = { Physical: "physical", State: "state", Both: "both" } as const;
/** An app's own module palette: a colour per module path (here by top module, a shade per sub-module). */
const palette = (path: readonly number[]): string | undefined =>
path.length ? schemeTableau10[((path[0] ?? 1) - 1) % schemeTableau10.length] : undefined;
/**
* **State (higher-order / memory) networks — physical / state / both views with overlapping-module pies.**
*
* `net.stateNetwork(graph, { modules })` ingests a state network (built by `buildStateGraph`, which also
* derives the physical network) + a per-state-node module assignment; `net.view(…)` toggles three
* renderings of the *same* data:
*
* - **Physical** — the derived physical network (flow-sized nodes, bent links). A physical node whose
* state nodes span several modules is a **pie chart** (a wedge per module, sized by flow); a
* single-module node is a plain circle.
* - **State** — the state graph as an ordinary network with its modules, exactly what
* `net.data(stateGraph, { modules })` gives: laid out on its own (**State layout: Nested** lays it out by
* module), and `net.lod()` (the **LOD** control) cuts its module tree, with a ring around each open module.
* - **Both** — state nodes packed **inside** each physical node, at the state view's size (`nodeRadius` and
* `sizeMode`), with state-level links: the memory structure in its physical context. Each physical node
* encloses its state nodes. Hover, click or drag a physical node: it acts as its state nodes.
*
* **Colours** — with no palette, the engine colours modules itself; `style({ moduleColor })` takes your own
* (a colour per module path) for state nodes, pie wedges and physical nodes in every view, and the
* pie wedges keep it when `nodeFill` colours the nodes by a metric. **Modules: Off** loads the raw state
* network (no Infomap yet): the views still switch, without pies. **Lump: On** (`stateNetwork(…, { lump })`,
* `net.lump(on)`) draws each physical node's state nodes in one leaf module as one node in the State and Both
* views, as the map equation codes them (with **Modules: Off** every state node is in the root module: a node per
* physical node); `net.lumped` maps a lump to its physical node and state nodes.
* A view switch carries the selection over (a physical node ↔ its state nodes), and every hit, selection
* and label names its `level` ("physical" or "state"), so the tooltip and labels below need no id mapping.
*
* The data is synthetic (`state-network-data.ts`): an LFR physical network + node2vec trigrams, node
* labels `1,2,…` (physical) and `(i,j)` (state). **Each view keeps its own layout** (#495): `layout({ backend })`
* lays out the view on screen — the physical graph in the Physical and Both views, which share it, the state
* graph in the State view — with **Force** (main-thread, synchronous), **Worker** (off-thread, progressive) or
* **GPU** (WebGL2 Barnes-Hut, falling back to Worker when unavailable). A view shown for the first time is laid
* out with the last options, from where its nodes are (the state nodes start around their physical node), and
* every view keeps its positions across switches. `fit: true` has the camera **frame the layout** on every
* backend. Scroll to zoom, drag to pan; dragging a node reheats the layout of the view it is in, and
* ⌘/Ctrl-drag pans even over a node (#178).
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
// A state-level id names a state node, or while lumped a lump: its physical node's name.
const stateName = (g: SyntheticStateNetwork, id: number): string | null => {
const lumped = net.lumped;
return lumped ? (g.physicalNames[lumped.graph.stateToPhysical[id] ?? -1] ?? null) : (g.stateNames[id] ?? null);
};
net.enableZoom([0.05, 60]);
let data: SyntheticStateNetwork | null = null;
let wedges: PhysicalPieWedges | null = null; // per-physical module wedges, for the physical-view tooltip
let builtN = -1;
let builtBackend = ""; // re-run layout() when the Backend control changes, even without new data
let builtModules: boolean | null = null;
let builtNested = false; // the state view's layout: by module (nested) or flat
// Same interactive options as the directed map of modules: multi-select, node-drag, hover rings — plus
// a tooltip. In the physical view it shows a node's flow + its module share(s); elsewhere the node label.
net.interactive({
selectable: { multi: true },
draggable: true,
hover: true,
tooltip: (datum, id) => {
if (!data) return null;
const hit = datum as NetworkHit;
if (hit.level === "state") return stateName(data, id as number);
const p = hit.physical ?? (id as number); // a physical node, in the physical or the both view
if (net.stateView === "physical" && wedges) {
const flow = data.graph.physical.flow?.[p] ?? 0;
const rows: string[] = [];
let prev = 0;
for (let k = wedges.offset[p]!; k < wedges.offset[p + 1]!; k++) {
rows.push(`<span style="color:${wedges.color[k]}">■</span> module ${wedges.moduleKey[k]} — ${((wedges.end[k]! - prev) * 100).toFixed(0)}%`);
prev = wedges.end[k]!;
}
const el = document.createElement("div");
el.innerHTML = `<b>Node ${data.physicalNames[p]}</b> · flow ${flow.toFixed(3)}<br>${rows.join("<br>")}`;
return el;
}
return data.physicalNames[p] ?? null;
},
});
return {
engine: net,
render: (options) => {
const n = NODES[(options.nodes as number) ?? 1] ?? 100;
const view = VIEW[(options.view as keyof typeof VIEW) ?? "Physical"] ?? "physical";
const physical = view === "physical";
const halfArrow = options.links !== "Line"; // default: half-arrows (the map-of-modules glyph)
const backend = options.backend === "GPU" ? "gpu" : options.backend === "Worker" ? "worker" : "force";
const withModules = options.modules !== "Off";
const nested = withModules && options.stateLayout === "Nested";
const lump = options.lump === "On";
const dataChanged = !data || builtN !== n;
// Backend and Modules also re-load the network and re-lay it out.
const layoutChanged = dataChanged || builtBackend !== backend || builtModules !== withModules;
if (dataChanged) {
data = generateStateNetwork({ nodeCount: n, communityCount: 6, mu: 0.18, avgDegree: 8, seed: 3 });
wedges = physicalPieWedges(data.graph, data.stateModules); // for the physical-view tooltip
builtN = n;
}
const g = data!;
let hiFlow = 0;
for (const f of g.graph.physical.flow!) if (f > hiFlow) hiFlow = f;
// Without a palette the engine colours modules itself; `moduleColor` gives the app's palette to every view's
// state nodes, physical nodes and pie wedges (and keeps the pies and physical nodes in it when `nodeFill` colours nodes
// by a metric).
// The "both" view draws its state nodes at this nodeRadius and sizeMode, packed in physical nodes that enclose them.
net.style({
moduleColor: options.colors === "Palette" ? palette : undefined,
directed: true,
sizeMode: options.sizing === "World" ? "world" : "screen",
linkStyle: halfArrow ? "half-arrow" : "line",
linkBend: 0.15, // fraction of the link's length, for half-arrows and lines alike
linkStroke: physical ? "rgba(90,110,150,0.5)" : "rgba(120,132,156,0.32)",
linkWidth: physical ? { by: "weight", scale: scaleSqrt().domain([0, 8]).range([1, 6]).clamp(true) } : 1,
nodeBorder: view === "both" ? undefined : { width: 1, color: "#000000" },
nodeRadius: physical ? { by: "flow", scale: scaleSqrt().domain([0, hiFlow]).range([4, 22]) } : 5,
});
if (layoutChanged) {
// fit: true (#238, #427) frames the layout with the camera: as it converges on worker/gpu, once as
// it lands on force. Opens framed, no top-left flash.
net.stateNetwork(g.graph, { modules: withModules ? g.stateModules : undefined, view, lump }).layout({ backend, fit: true, nested });
builtBackend = backend;
builtModules = withModules;
builtNested = nested;
} else {
net.lump(lump).view(view); // a toggle keeps each view's picture; a view never shown is laid out now
// The state layout changed: lay out the state view again (the physical graph has no modules to nest).
if (nested !== builtNested && view === "state") net.layout({ backend, fit: true, nested });
if (view === "state") builtNested = nested;
}
// LOD applies to the state view (its nodes carry the module tree): the engine keeps the request across view
// switches and draws the physical (every pie) and both views in full. Default Off. "Modules" cuts the state
// view's module tree — its default, as for any network with modules — with a ring around each open module;
// "Standard" coarsens the state graph structurally.
const lodOn = options.lod !== "Off" && (withModules || options.lod !== "Modules");
net.lod(
lodOn
? {
source: options.lod === "Modules" ? "modules" : "structure",
moduleBoundary: options.lod === "Modules" ? {} : undefined,
// "Auto" (0) ⇒ omitted, i.e. the cut's tree-adaptive default.
expandPx: (options.expand as number) || undefined,
maxAggregateRadius: options.maxRadius as number,
declutter: options.declutter !== "Off",
crossLevelEdges: options.crossLevel === "On",
crossFade: ((options.crossFade as number) ?? 0) * 0.1,
}
: false,
);
// Labels: physical (1,2,…) in the physical + both views, state ((i,j)) in the state + both views.
// In the both view physical labels sit just outside each physical node (≈1:30); state labels on the state nodes.
// The built-in label style covers font/colour; `style` thins the default halo a touch.
const physOn = options.physLabels === "On";
const stateOn = options.stateLabels === "On";
const cap = LABEL_CAPS[(options.maxLabels as number) ?? 1] ?? 12;
const showState = (view === "state" || view === "both") && stateOn;
const showPhysical = (physical && physOn) || (view === "both" && physOn);
net.labels(
showState || showPhysical
? {
style: { textShadow: "0 0 3px #fff, 0 0 3px #fff" },
max: Number.isFinite(cap) ? cap : undefined,
// `info.level` names the id's graph: no per-view id mapping in the app.
labelOf: (id, info) => (info.level === "physical" ? (physOn ? g.physicalNames[id] ?? null : null) : stateOn ? stateName(g, id) : null),
physical: view === "both" && physOn ? { labelOf: (p) => g.physicalNames[p] ?? null } : undefined,
}
: false,
);
},
};
};

The same engine renders a network parsed from a file. parseNetwork(text, filename) dispatches on the name: a .net file is read as Pajek (vertex labels and coordinates, *Arcs/*Edges), and anything else as a plain edge list (source target [weight], one edge per line, # comments). Pick your own file or load a built-in sample of each format. Nodes are sized by degree (scaleSqrt) so hubs stand out, and vertex labels are engine-managed frontier labels — one net.labels({ labelOf, offset, maxWidth }) call, and they’re culled against each other and re-placed on every pan/zoom and every layout frame, with the default label style (no CSS, no overlay bookkeeping, and no reaching into position arrays). maxWidth trims the sample’s two long names with an ellipsis; hover one to read it in full.

fps 0frame 0 ms
draw.ts
import { network, buildGraph, parseNetwork } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { makeControls } from "./controls.js";
import { SAMPLE_PAJEK } from "./data.js";
// Remember the last document module-side so the harness's resize-driven setup() re-run reloads it.
let loaded = { text: SAMPLE_PAJEK, name: "sample.net" };
/**
* Load a network from a file and render it with the `network()` engine. `parseNetwork` dispatches
* on the filename — `.net` → Pajek (vertex labels, `*Arcs`/`*Edges`), anything else → the plain
* edge-list parser (`source target [weight]`, `#` comments). The off-thread worker lays it out with
* `layout({ fit: true })`, so it opens **framed** and converges live; nodes are **sized by degree** (a
* d3 `scaleSqrt`) so hubs stand out. Vertex names are drawn with engine-managed **frontier labels**
* (`net.labels({ labelOf, maxWidth })`) — they track pan/zoom (and the fit reframe) with no overlay bookkeeping;
* a long name is trimmed with an ellipsis and shows in full on hover.
* Pick a file, or load a built-in sample.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
net.enableZoom([0.1, 8]); // scroll to zoom, drag to pan; engine labels follow the transform
let disposed = false;
const load = (text: string, filename: string): void => {
if (disposed) return;
loaded = { text, name: filename };
const { nodeCount, source, target, weight, labels: names, directed } = parseNetwork(text, filename);
const graph = buildGraph({ nodeCount, source, target, weight, directed });
// Size nodes by degree so hubs stand out: a d3 `scaleSqrt` (area-proportional) over the degree
// range, handed straight to `nodeRadius` via { by: "degree", scale }. Resolved once, no draw cost.
let maxDegree = 1;
for (const d of graph.csr.degree) if (d > maxDegree) maxDegree = d;
const radius = scaleSqrt().domain([1, maxDegree]).range([4, 16]);
net
.data(graph)
.style({ directed, nodeRadius: { by: "degree", scale: radius }, nodeFill: "#4878d0", linkWidth: 1, linkStroke: "#cbd5e6" })
// Vertex names as engine-managed frontier labels: `labelOf` maps a node id → its parsed label, and
// the engine re-places them on every pan/zoom + layout frame — no manual overlay/transform tracking.
// The built-in label style (dark 11px sans-serif + white halo) covers every backend, export included.
// `maxWidth` trims a long name to 80 px with an ellipsis (placement uses the trimmed box); hovering the
// node shows it in full.
.labels({ labelOf: (id) => names?.[id] ?? null, offset: [7, -4], maxWidth: 80 })
.interactive({ hover: true })
// The worker seeds a disc centred on the viewport at the layout's own final scale (radius ≈ 32·√N at
// the default forces), so a small network like the sample opens framed at k=1 and converges in place.
// For large files add `fit: true` to keep the camera framed on the layout (see the network example).
.layout({ backend: "worker", iterations: 300 });
};
host.appendChild(makeControls(load));
load(loaded.text, loaded.name);
return {
engine: net,
dispose: () => {
disposed = true;
},
};
};

Add draggable to interactive() and a plain drag that starts on a glyph moves it — grab a node, it tracks the cursor while the layout reheats around it, and the simulation re-cools when you let go. A plain drag on empty space still pans, and shift+drag still draws the marquee; the drag only takes over when the pointer goes down on a node, so it composes with enableZoom() with no extra wiring.

net.interactive({ draggable: true, selectable: { multi: true }, hover: true });
net.enableZoom([0.002, 200]); // pan/zoom; a drag starting on a node moves it instead of panning

On a dense graph almost every press lands on a node, so there is hardly any empty space left to pan from. Hold ⌘ (on macOS/iOS; Ctrl on Windows and Linux) while you drag and the drag always pans, even when it starts on a node. No node is grabbed and the layout doesn’t reheat. The key is built in and detected once per platform, so you don’t set anything up. It applies wherever enableZoom() is on, including plot() and a flat-projection geoMap(). A ⌘/Ctrl-click still toggles the node in a multi-selection. The other modifiers keep their meaning: shift draws the marquee and option/alt subtracts from it. (Ctrl can’t be the pan key on a Mac because ctrl-click opens the context menu there.)

What moves depends on what you grab:

  • A node → that node alone.
  • A node that’s part of the current selection → the whole selection moves together (every selected node is held + translated by the cursor delta). Grabbing an unselected node first makes it the selection, then drags it alone.
  • A collapsed module aggregate (under LOD) → its whole subtree — every leaf descendant translates, and the aggregate glyph follows because its centroid re-derives from the moved leaves. (members() enumerates exactly those leaves, the same set selection uses.) With module links (data(graph, { modules, moduleLinks })) the modules linked to it follow it, on every force backend.
  • An open module by its boundary ring → the same as the module collapsed: the same leaves are held, and its ring, its drawn members and their links follow the cursor. A press inside the disc but off the ring pans.

The held node tracks the cursor with no lag on every backend, because the held positions are written on the main thread each pointer move — the layout backend only reflows the rest. The redraw itself (the LOD update, the cut and the render) runs once per animation frame, together with any zoom or streamed layout frame that lands in it, however fast the pointer moves:

layout({ backend })While draggingOn release
force (main thread)a pinned ForceLayout reheats neighbours each framere-cools until converged (at most a short tail), then stops
worker (off thread)the worker pins the held set and reflows the rest off-thread; the main thread holds them under the cursorthe worker is unpinned and re-cools until converged
gpu (on the GPU)the GPU layout pins the held set (skipped by the integrate pass) and reflows the rest on-GPU; the main thread holds them under the cursorthe layout is unpinned and re-cools until converged
autoas gpu or worker, whichever it resolved toas that backend
positions (fixed coords)the held set simply translates (no simulation to reheat)—
any of the above with nested (a map of modules)the node follows the cursor freely; its module, and the modules above it as its disc moves, re-lay out around it, on the main thread (see below)the node is let go and the module settles for a short tail (at most 90 frames), then stops

On the worker, gpu and auto backends the layout is kept alive (idle) after the initial layout converges, so a drag can reheat it instantly without re-seeding — the held nodes are pinned (skipped by the backend’s integration) while their positions are owned by the main thread, so they never rubber-band behind the cursor.

On a nested map (layout({ nested }), once it has landed) a drag never reheats the whole graph, so the map stays nested, and the module rings move with their discs:

  • A node → it goes wherever the cursor goes, and the other nodes of its module are pushed aside and settle around it, and the whole map reheats as a flat drag’s does: every level runs soft forces (the nested layout’s repulsion, gravity and springs over its module links), so sibling modules follow a moving module by their flows and make way as it approaches, up to the root. Only the dragged node is pinned; each disc follows its members, and its ring with it. The heat and the re-cool on release are the flat drag’s, and a release settles the map, so the next grab starts from rest.
  • A collapsed module, or an open one by its ring → its sibling modules move aside inside their parent, each as a whole (its own layout and ring move with it).
  • A selection → each module it spans is re-laid out the same way.
  • The whole map (its root aggregate) → it translates.

Each frame costs the re-laid-out module’s children and links, plus the nodes that actually moved, not a pass over the whole graph. The re-solve is the same on every backend: the GPU and worker nested solves are not kept alive after they land, since a module’s re-layout is far cheaper than waking a whole-map solve. While a nested layout is still streaming, a grabbed node is held over its frames and dropped where it is left.