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.
Large-scale layout in a Web Worker
Section titled “Large-scale layout in a Web Worker”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.
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 } }, };};export interface GeneratedNetwork { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge weight. All 1 unless `weighted` is set, then a power-law draw (most light, a few heavy). */ weight: Float32Array; /** Node → community id (planted ground truth; handy for colouring or validation). */ community: Int32Array;}
export interface LFROptions { /** Mixing: fraction of each node's edges that go *outside* its community (0..1). Default 0.1. */ mu?: number; /** Target mean degree. Default 12. */ avgDegree?: number; /** Max degree. Default ≈ 3·√n. */ maxDegree?: number; /** Degree power-law exponent τ₁. Default 2.5. */ degreeExponent?: number; /** Community-size power-law exponent τ₂. Default 1.5. */ communityExponent?: number; /** Smallest community. Default 20. */ minCommunity?: number; /** Largest community. Default ≈ n/8. */ maxCommunity?: number; /** Assign power-law edge weights (so links — and accumulated LOD super-edges — vary). Default false. */ weighted?: boolean; /** Edge-weight power-law exponent (when `weighted`). Default 2.2 (most weights near 1, a few large). */ weightExponent?: number; /** Max edge weight (when `weighted`). Default 12. */ maxWeight?: number; /** PRNG seed (deterministic output across re-renders). Default 1. */ seed?: number;}
/** Tiny deterministic PRNG (mulberry32) — keeps the generated network stable across re-renders. */function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Sample an integer in [min,max] from a power law p(x) ∝ x^(−exponent) via inverse-CDF. */function powerLaw(rand: () => number, min: number, max: number, exponent: number): number { if (max <= min) return min; const e = 1 - exponent; const lo = Math.pow(min, e); const hi = Math.pow(max, e); const x = Math.round(Math.pow(lo + rand() * (hi - lo), 1 / e)); return x < min ? min : x > max ? max : x;}
/** Fisher–Yates shuffle of `arr[from..to)` in place. */function shuffle(arr: Uint32Array, from: number, to: number, rand: () => number): void { for (let i = to - 1; i > from; i--) { const j = from + Math.floor(rand() * (i - from + 1)); const tmp = arr[i]!; arr[i] = arr[j]!; arr[j] = tmp; }}
/** * A clean-room **LFR-style benchmark network** (Lancichinetti–Fortunato–Radicchi): power-law node * degrees and power-law community sizes, with a mixing parameter `mu` controlling the fraction of * inter-community edges. Edges are wired with a configuration model — intra-community stubs paired * within each community, inter-community stubs paired globally across communities — so the planted * communities are real, nested structure the force layout resolves and LOD coarsening can recover. * * This is an approximation tuned for visualization (it allows the occasional multi-edge and skips * the reference algorithm's exact degree-sequence rewiring), not a bit-faithful reproduction of the * published LFR generator. Pass `weighted` for power-law edge weights, so links vary in width/colour * and an LOD super-edge reads as the (summed) weight of the edges it subsumes. */export function generateLFR(n: number, opts: LFROptions = {}): GeneratedNetwork { const mu = opts.mu ?? 0.1; const avgDegree = opts.avgDegree ?? 12; const degExp = opts.degreeExponent ?? 2.5; const comExp = opts.communityExponent ?? 1.5; const maxDeg = Math.min(n - 1, opts.maxDegree ?? Math.round(3 * Math.sqrt(n))); // Mean of a bounded power law ≈ minDeg·(γ−1)/(γ−2); invert to hit the target average degree. const minDeg = Math.max(2, Math.round((avgDegree * (degExp - 2)) / (degExp - 1))); const minCom = Math.max(2, Math.min(opts.minCommunity ?? 20, n)); const maxCom = Math.max(minCom, Math.min(opts.maxCommunity ?? Math.round(n / 8), n)); const rand = mulberry32(opts.seed ?? 1);
// 1) Planted communities as contiguous node ranges with power-law sizes covering all n nodes. const community = new Int32Array(n); const comStart: number[] = []; let assigned = 0; for (let c = 0; assigned < n; c++) { let size = powerLaw(rand, minCom, maxCom, comExp); if (assigned + size > n) size = n - assigned; comStart.push(assigned); community.fill(c, assigned, assigned + size); assigned += size; } comStart.push(n); // sentinel end
// 2) Per-node degree (power law), split into intra/inter targets. Intra is capped at the // community size so a small community can satisfy it without excessive multi-edges. const intra = new Uint32Array(n); const inter = new Uint32Array(n); let sumIntra = 0; let sumInter = 0; for (let c = 0; c + 1 < comStart.length; c++) { const start = comStart[c]!; const end = comStart[c + 1]!; const cap = end - start - 1; // most intra-neighbours available for (let u = start; u < end; u++) { const deg = powerLaw(rand, minDeg, maxDeg, degExp); let ai = Math.round((1 - mu) * deg); if (ai > cap) ai = cap < 0 ? 0 : cap; intra[u] = ai; inter[u] = deg - ai; sumIntra += ai; sumInter += inter[u]!; } }
// Edge buffers, sized at the stub-pair upper bound (each undirected edge consumes two stubs). const cap = Math.ceil(sumIntra / 2) + Math.ceil(sumInter / 2) + 1; const source = new Uint32Array(cap); const target = new Uint32Array(cap); let ne = 0;
// 3) Intra-community edges: pair shuffled intra-stubs within each community (configuration model). const intraStubs = new Uint32Array(sumIntra); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < intra[u]!; k++) intraStubs[p++] = u; } let seg = 0; // running start of the current community's stub segment (stubs are in node-id order) for (let c = 0; c + 1 < comStart.length; c++) { let segEnd = seg; for (let u = comStart[c]!; u < comStart[c + 1]!; u++) segEnd += intra[u]!; shuffle(intraStubs, seg, segEnd, rand); for (let i = seg; i + 1 < segEnd; i += 2) { const a = intraStubs[i]!; const b = intraStubs[i + 1]!; if (a !== b) { source[ne] = a; target[ne] = b; ne++; } } seg = segEnd; }
// 4) Inter-community edges: pair shuffled inter-stubs globally, skipping same-community/self pairs. const interStubs = new Uint32Array(sumInter); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < inter[u]!; k++) interStubs[p++] = u; } shuffle(interStubs, 0, sumInter, rand); for (let i = 0; i + 1 < sumInter; i += 2) { const a = interStubs[i]!; const b = interStubs[i + 1]!; if (a !== b && community[a] !== community[b]) { source[ne] = a; target[ne] = b; ne++; } }
// 5) Edge weights: a power-law draw per edge (most ≈ 1, a few heavy) so links vary — and an LOD // super-edge, summing the weights it subsumes, reads as genuinely thicker/heavier. Unweighted ⇒ 1. const weightExp = opts.weightExponent ?? 2.2; const maxWeight = opts.maxWeight ?? 12; const weight = new Float32Array(ne); for (let e = 0; e < ne; e++) weight[e] = opts.weighted ? powerLaw(rand, 1, maxWeight, weightExp) : 1;
return { nodeCount: n, source: source.subarray(0, ne), target: target.subarray(0, ne), weight, community };}Nodes only: turning links off
Section titled “Nodes only: turning links off”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 allnet.style({ linkWidth: 0 }); // same skip, via a constant zero widthnet.style({ linkStyle: "line" }); // …and back; nothing was lost, so this is instantThe 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.
Sizing nodes by degree (or any metric)
Section titled “Sizing nodes by degree (or any metric)”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/graphare there for anything custom; { by, scale }— feed a metric through any scale, wherebyis"degree"(neighbour count),"strength"(weighted degree — summed incident edge weights),"flow"(an app-provided per-node value, see below), or your own(index, graph) => valueaccessor;- a
Float32Arrayof 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.
Flow borders and half-arrows
Section titled “Flow borders and half-arrows”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).byis any node metric —"degree","strength","flow"or your own(index, graph) => value. (nodeFillalso takes one colour, or a per-node(index, graph) => colouraccessor for categorical colours.)flowBorder—flowis a per-node value (yourFloat32Arrayof enter/exit flow, or a built-in metric like"strength");scalemaps it to ring width andcolormay 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, withmoduleFlow, per module (below). Withshare: true(and noscale) the flow border covers the share of the node’s flow that leaves it instead: its exit flow over its flow (the graph’snodeFlow), 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 fitslinkStroke—scaleSqrt().range([light, dark])interpolates RGBA, alpha included), or{ by, scale }likenodeRadius(byis"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. ThelinkStrokecolour is read once perstyle()(ordata()) 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), callstyle()again to apply it.linkBend— the ⟂ bow as a fraction of the link’s length (as forlinelinks), 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).
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 }); }, };};/** * The exact `mapequation/network-rendering` `example.svg` network — the "map of networks" glyph style, * **without** LOD. Two directed nodes joined by reciprocal links, with a planted **flow** model: * * - node **flow** (total) → fill colour + radius * - node **enter/exit flow** (`outFlow`) → flow-border ring width + colour * - link **flow** (the per-edge weight) → half-arrow width + colour * * The numbers below are the reference's: nodes at (100,100)/(300,180), the same flow values, so the * d3 scales in `draw.ts` reproduce the reference's radii (20–30), border widths (3–6) and palette. */
/** Node fill colour range (low→high flow): the reference's two oranges. */export const NODE_FILL_RANGE: [string, string] = ["#EF7518", "#D75908"];/** Flow-border ring colour range (low→high enter/exit flow): the reference's light oranges. */export const NODE_BORDER_RANGE: [string, string] = ["#FFAE38", "#f9a327"];/** Half-arrow link colour range (low→high link flow): the reference's two blues. */export const LINK_RANGE: [string, string] = ["#71B2D7", "#418EC7"];
export interface ReplicaGraph { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge link flow → half-arrow width + colour. */ weight: Float32Array; positions: Float32Array; /** Per-node total flow → fill colour + radius. */ flow: Float32Array; /** Per-node enter/exit (boundary) flow → ring width + colour. */ outFlow: Float32Array;}
/** The two-node reference network: node 0 carries more flow than node 1; the 0→1 link is heavier. */export function buildReplica(): ReplicaGraph { return { nodeCount: 2, source: Uint32Array.from([0, 1]), target: Uint32Array.from([1, 0]), weight: Float32Array.from([0.5, 0.3]), // link flow: 0→1 heavier than 1→0 positions: Float32Array.from([100, 100, 300, 180]), flow: Float32Array.from([0.6, 0.4]), // node total flow → radius 30 / 20 outFlow: Float32Array.from([0.3, 0.2]), // enter/exit flow → border 6 / 3 };}
/** World bounds of the reference layout (its 400×300 frame), for fitting the initial view. */export const REPLICA_BOUNDS = { minX: 40, maxX: 360, minY: 50, maxY: 230 };Level of detail (LOD)
Section titled “Level of detail (LOD)”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 elementWhat 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 likeflow— 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, aFloat32Array, or"order". It defaults to the node-size metric, so the biggest glyph wins an overlap. A glyph is hidden when its centre lies withindeclutterSpacing·(rᵢ + rⱼ)of a kept glyph. The defaultdeclutterSpacing: 2keeps 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: 1hides only real overlaps, anddeclutter: falsehides 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: truerestores 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: falsesuppresses them under LOD only; to drop links everywhere — LOD on or off — usestyle({ linkStyle: "none" })instead. expandPxis 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).crossFadesmooths level transitions: over a band around the expand threshold (a fraction ofexpandPx, 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)Spatial LOD: grouping by position
Section titled “Spatial LOD: grouping by position”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 (
crossLevelEdgesmakes 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, andlod(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.
Modular level of detail
Section titled “Modular level of detail”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.
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 }); }, };};/** * An **undirected Sierpinski gasket** with a planted module hierarchy — a clean test bed for * **modular-aware LOD**: nodes aggregate into their parent module as you zoom out, and a module's * glyph (and all its leaves) share one categorical colour. * * A recursive 3-ary gasket: each smallest triangle is a 3-node community joined to its siblings by * sparse corner bridges, with distinct nodes (no shared corners), so every node has one unambiguous * module `path`. The subdivision tree *is* the module hierarchy, emitted in Infomap's JSON `nodes` * shape and fed to `net.data(graph, { modules })`. depth D → 3^D leaf triangles → 3^(D+1) nodes. */
export interface SierpinskiGraph { nodeCount: number; source: Uint32Array; target: Uint32Array; weight: Float32Array; /** Gasket coordinates [x, y, …], apex up. */ positions: Float32Array; /** Infomap JSON `nodes`: { id, path } per node — the provided module hierarchy. `path[0]` is the top module. */ modules: { id: number; path: number[] }[];}
type Pt = readonly [number, number];const mid = (a: Pt, b: Pt): Pt => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const SCALE = 1000;const HEIGHT = (SCALE * Math.sqrt(3)) / 2;const SHRINK = 0.22; // pull leaf nodes off the shared corners so bridges have length// All edges carry weight 1 (an unweighted network). Inter-module bridges don't aggregate in this// gasket — each module pair is joined by exactly one bridge — so super-edges stay weight 1 and links// are uniform; zooming in just reveals more (still unit-weight) bridges.const INTRA = 1;const BRIDGE = 1;
/** Generate the undirected Sierpinski gasket at the given `depth` (≥ 1). Deterministic. */export function generateSierpinski(depth: number): SierpinskiGraph { const positions: number[] = []; const paths: number[][] = []; const idByPath = new Map<string, number>(); const key = (p: number[]): string => p.join(":");
const emitLeaf = (prefix: number[], q0: Pt, q1: Pt, q2: Pt): void => { const gx = (q0[0] + q1[0] + q2[0]) / 3; const gy = (q0[1] + q1[1] + q2[1]) / 3; [q0, q1, q2].forEach((c, r) => { const id = paths.length; paths.push([...prefix, r + 1]); idByPath.set(key(paths[id]!), id); // Apex-up: flip y within the gasket height. positions.push(c[0] + (gx - c[0]) * SHRINK, HEIGHT - (c[1] + (gy - c[1]) * SHRINK)); }); }; const subdivide = (prefix: number[], p0: Pt, p1: Pt, p2: Pt): void => { if (prefix.length === depth) return emitLeaf(prefix, p0, p1, p2); subdivide([...prefix, 1], p0, mid(p0, p1), mid(p0, p2)); subdivide([...prefix, 2], mid(p0, p1), p1, mid(p1, p2)); subdivide([...prefix, 3], mid(p0, p2), mid(p1, p2), p2); }; subdivide([], [0, 0], [SCALE, 0], [SCALE / 2, HEIGHT]);
const nodeCount = paths.length; const cornerId = (prefix: number[], c: number): number => { const p = [...prefix]; while (p.length < depth) p.push(c); p.push(c); return idByPath.get(key(p))!; };
const src: number[] = []; const tgt: number[] = []; const w: number[] = []; const edge = (a: number, b: number, weight: number): void => void (src.push(a), tgt.push(b), w.push(weight));
for (let leaf = 0; leaf < nodeCount; leaf += 3) { edge(leaf, leaf + 1, INTRA); edge(leaf + 1, leaf + 2, INTRA); edge(leaf, leaf + 2, INTRA); } const addBridges = (prefix: number[]): void => { if (prefix.length === depth) return; const c1 = [...prefix, 1]; const c2 = [...prefix, 2]; const c3 = [...prefix, 3]; edge(cornerId(c1, 2), cornerId(c2, 1), BRIDGE); edge(cornerId(c2, 3), cornerId(c3, 2), BRIDGE); edge(cornerId(c1, 3), cornerId(c3, 1), BRIDGE); addBridges(c1), addBridges(c2), addBridges(c3); }; addBridges([]);
return { nodeCount, source: Uint32Array.from(src), target: Uint32Array.from(tgt), weight: Float32Array.from(w), positions: Float32Array.from(positions), modules: paths.map((path, id) => ({ id, path })), };}
/** The gasket's fixed world bounds (depth-independent) — for fitting the initial view. */export const SIERPINSKI_BOUNDS = { minX: 0, maxX: SCALE, minY: 0, maxY: HEIGHT };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).
Picking links
Section titled “Picking links”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.
Frontier labels
Section titled “Frontier labels”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.
A directed map of modules
Section titled “A directed map of modules”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.
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 }); } }, };};import { randomWalkFlow, type ModuleLink } from "@mapequation/d3gl/network";import { generateLFR } from "../network/data.js";
/** * A **directed modular map** generated at runtime (so the Nodes slider can resize it): a directed LFR * planted-partition network with authoritative random-walk **flow** (matched to Infomap's convention). * Each undirected LFR edge is split into a reciprocal a→b / b→a pair with **asymmetric** weights, so a * half-arrow pair carries genuinely different flow each way. * * - `linkFlow` — per directed edge → half-arrow width + colour (used as the graph's edge weight, so LOD * super-edges accumulate flow automatically). * - `nodeFlow` — per-node visit rate → node radius (and a flow read-out). * - `enterExit` — per-node flow crossing its module boundary → the flow-border ring. * - `moduleEnterExit` — each module's own flow across its boundary, by Infomap path (`"1:100"`) → a * collapsed module's ring. Smaller than its members' sum for a super-module: flow between its own * submodules stays inside it. * - `moduleEnter` — each module's enter flow alone, by the same path → an opened module's boundary width. * - `community` — the planted partition → the (ragged) module hierarchy (see {@link raggedModulePrefix}). */export interface ModularMapData { nodeCount: number; communities: number; source: Uint32Array; target: Uint32Array; linkFlow: Float32Array; nodeFlow: Float32Array; enterExit: Float32Array; /** Module enter + exit flow keyed by the module's Infomap path joined with ":" (e.g. `"1:100"`). */ moduleEnterExit: Map<string, number>; /** Module enter flow alone, keyed like {@link moduleEnterExit}. */ moduleEnter: Map<string, number>; community: Int32Array; /** Infomap-shape module records for `data(graph, { modules })`: a **ragged** hierarchy — see {@link raggedModulePrefix}. */ modulePaths: { id: number; path: number[] }[];}
/** * Ragged module prefix for community `c` — the ancestor chain above its leaf-module (the module's own * child-index + the node rank are appended by {@link makeModularMap}). Instead of a flat one-level * partition (every community a top module, every leaf at depth 1), a few communities are promoted into * **super-modules** so branches reach different depths — the ragged hierarchy the module-aware GPU seed * (#180 N8.2) resolves top-down. Grouping is by `super = ⌊c / 4⌋`; a community's position within its * group sets its depth: * - position 0 → a **top-level** community (depth 1): `[10000 + c]` * - positions 1–2 → nested one level under the super-module (depth 2): `[1 + super, 100 + c]` * - position 3 → nested two levels (depth 3): `[1 + super, 500 + super, 200 + c]` * The value ranges are disjoint so sibling child-indices never collide; every community stays a single * coherent leaf-module (its nodes share the full prefix), just at a varying depth. */function raggedModulePrefix(c: number): number[] { const GROUP = 4; const superId = Math.floor(c / GROUP); switch (c % GROUP) { case 0: return [10000 + c]; // top-level community (depth 1) case 3: return [1 + superId, 500 + superId, 200 + c]; // super → sub-group → community (depth 3) default: return [1 + superId, 100 + c]; // super → community (depth 2) }}
/** Deterministic PRNG (mulberry32) for reproducible asymmetric edge weights across re-renders. */function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** * Build a directed modular map with `nodeCount` nodes. Deterministic (seeded), so resizing via the Nodes * slider and back gives the same map. Flow is computed at runtime with {@link randomWalkFlow} — cheap at * the sizes the slider offers, and it keeps the example a true *flow* map at every size. */export function makeModularMap(nodeCount: number): ModularMapData { const lfr = generateLFR(nodeCount, { mu: 0.1, avgDegree: 10, minCommunity: 18, seed: 42 }); const n = lfr.nodeCount;
// Directed: each undirected edge → a reciprocal pair with **asymmetric** weights, so the two half-arrows // of a pair carry genuinely different flow (a→b ≠ b→a) and the map reads as directed. const rng = mulberry32(7); const draw = () => 0.3 + 4 * rng() * rng(); // skewed [0.3, ~4.3): most light, some heavy const m = lfr.source.length; const source = new Uint32Array(2 * m); const target = new Uint32Array(2 * m); const weight = new Float32Array(2 * m); for (let e = 0; e < m; e++) { const a = lfr.source[e]!; const b = lfr.target[e]!; source[2 * e] = a; target[2 * e] = b; weight[2 * e] = draw(); source[2 * e + 1] = b; target[2 * e + 1] = a; weight[2 * e + 1] = draw(); }
// Authoritative random-walk flow (Infomap convention), then per-node boundary (enter/exit) flow. const { nodeFlow, linkFlow } = randomWalkFlow({ nodeCount: n, source, target, weight }, { tau: 0.15 }); const community = lfr.community; const enterExit = new Float32Array(n); for (let e = 0; e < source.length; e++) { const a = source[e]!; const b = target[e]!; if (community[a] !== community[b]) { enterExit[a]! += linkFlow[e]!; // exit flow from a enterExit[b]! += linkFlow[e]!; // enter flow to b } }
// Infomap path shape: the enclosing module's (ragged) ancestor chain, then the node's rank within its // module. The community is always the leaf-module (its colour + LOD aggregate); the per-community rank // distinguishes leaves. Depths vary 1–3 (see {@link raggedModulePrefix}). const rank = new Map<number, number>(); const modulePaths = Array.from(community, (c, id) => { const r = (rank.get(c) ?? 0) + 1; rank.set(c, r); return { id, path: [...raggedModulePrefix(c), r] }; });
const communities = new Set(Array.from(community)).size; const { enterExit: moduleEnterExit, enter: moduleEnter } = moduleBoundaryFlow(source, target, linkFlow, community); return { nodeCount: n, communities, source, target, linkFlow, nodeFlow, enterExit, moduleEnterExit, moduleEnter, community, modulePaths };}
/** * Each module's enter + exit flow (what Infomap reports per module): the flow of every link with exactly * one endpoint inside it. A link between two communities leaves every module on the source's path below * the modules the two paths share, and enters every such module on the target's path. */function moduleBoundaryFlow(source: Uint32Array, target: Uint32Array, linkFlow: Float32Array, community: Int32Array): { enterExit: Map<string, number>; enter: Map<string, number> } { const keysOf = new Map<number, string[]>(); // community → its module path keys, top down const keys = (c: number): string[] => { let k = keysOf.get(c); if (!k) { const prefix = raggedModulePrefix(c); k = prefix.map((_, d) => prefix.slice(0, d + 1).join(":")); keysOf.set(c, k); } return k; }; const flow = new Map<string, number>(); const enter = new Map<string, number>(); const add = (key: string, f: number) => flow.set(key, (flow.get(key) ?? 0) + f); for (let e = 0; e < source.length; e++) { const ca = community[source[e]!]!; const cb = community[target[e]!]!; if (ca === cb) continue; const ka = keys(ca); const kb = keys(cb); let shared = 0; while (shared < ka.length && shared < kb.length && ka[shared] === kb[shared]) shared++; for (let d = shared; d < ka.length; d++) add(ka[d]!, linkFlow[e]!); // exit for (let d = shared; d < kb.length; d++) { add(kb[d]!, linkFlow[e]!); // enter enter.set(kb[d]!, (enter.get(kb[d]!) ?? 0) + linkFlow[e]!); } } return { enterExit: flow, enter };}
/** * The same map as an Infomap **`.ftree`** carries it (#199): the graph keeps only the links **inside** * each bottom module (leaf → leaf), and every other link becomes a **module link** between the two * sibling modules where its endpoints' paths part — its flow summed per pair, like an `.ftree`'s * `*Links` rows. Both come from the same links, so no flow is invented, and none is counted twice. */export function asFtree(d: ModularMapData): { source: Uint32Array; target: Uint32Array; linkFlow: Float32Array; moduleLinks: ModuleLink[] } { const source: number[] = []; const target: number[] = []; const linkFlow: number[] = []; const links = new Map<string, ModuleLink>(); for (let e = 0; e < d.source.length; e++) { const a = d.source[e]!; const b = d.target[e]!; const flow = d.linkFlow[e]!; if (d.community[a] === d.community[b]) { source.push(a); target.push(b); linkFlow.push(flow); continue; } // Different bottom modules: the sibling modules under their deepest shared module. const pa = d.modulePaths[a]!.path; const pb = d.modulePaths[b]!.path; let l = 0; while (pa[l] === pb[l]) l++; const key = `${pa.slice(0, l + 1)}>${pb.slice(0, l + 1)}`; const link = links.get(key); if (link) link.flow += flow; else links.set(key, { source: pa.slice(0, l + 1), target: pb.slice(0, l + 1), flow }); } return { source: Uint32Array.from(source), target: Uint32Array.from(target), linkFlow: Float32Array.from(linkFlow), moduleLinks: [...links.values()] };}export interface GeneratedNetwork { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge weight. All 1 unless `weighted` is set, then a power-law draw (most light, a few heavy). */ weight: Float32Array; /** Node → community id (planted ground truth; handy for colouring or validation). */ community: Int32Array;}
export interface LFROptions { /** Mixing: fraction of each node's edges that go *outside* its community (0..1). Default 0.1. */ mu?: number; /** Target mean degree. Default 12. */ avgDegree?: number; /** Max degree. Default ≈ 3·√n. */ maxDegree?: number; /** Degree power-law exponent τ₁. Default 2.5. */ degreeExponent?: number; /** Community-size power-law exponent τ₂. Default 1.5. */ communityExponent?: number; /** Smallest community. Default 20. */ minCommunity?: number; /** Largest community. Default ≈ n/8. */ maxCommunity?: number; /** Assign power-law edge weights (so links — and accumulated LOD super-edges — vary). Default false. */ weighted?: boolean; /** Edge-weight power-law exponent (when `weighted`). Default 2.2 (most weights near 1, a few large). */ weightExponent?: number; /** Max edge weight (when `weighted`). Default 12. */ maxWeight?: number; /** PRNG seed (deterministic output across re-renders). Default 1. */ seed?: number;}
/** Tiny deterministic PRNG (mulberry32) — keeps the generated network stable across re-renders. */function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Sample an integer in [min,max] from a power law p(x) ∝ x^(−exponent) via inverse-CDF. */function powerLaw(rand: () => number, min: number, max: number, exponent: number): number { if (max <= min) return min; const e = 1 - exponent; const lo = Math.pow(min, e); const hi = Math.pow(max, e); const x = Math.round(Math.pow(lo + rand() * (hi - lo), 1 / e)); return x < min ? min : x > max ? max : x;}
/** Fisher–Yates shuffle of `arr[from..to)` in place. */function shuffle(arr: Uint32Array, from: number, to: number, rand: () => number): void { for (let i = to - 1; i > from; i--) { const j = from + Math.floor(rand() * (i - from + 1)); const tmp = arr[i]!; arr[i] = arr[j]!; arr[j] = tmp; }}
/** * A clean-room **LFR-style benchmark network** (Lancichinetti–Fortunato–Radicchi): power-law node * degrees and power-law community sizes, with a mixing parameter `mu` controlling the fraction of * inter-community edges. Edges are wired with a configuration model — intra-community stubs paired * within each community, inter-community stubs paired globally across communities — so the planted * communities are real, nested structure the force layout resolves and LOD coarsening can recover. * * This is an approximation tuned for visualization (it allows the occasional multi-edge and skips * the reference algorithm's exact degree-sequence rewiring), not a bit-faithful reproduction of the * published LFR generator. Pass `weighted` for power-law edge weights, so links vary in width/colour * and an LOD super-edge reads as the (summed) weight of the edges it subsumes. */export function generateLFR(n: number, opts: LFROptions = {}): GeneratedNetwork { const mu = opts.mu ?? 0.1; const avgDegree = opts.avgDegree ?? 12; const degExp = opts.degreeExponent ?? 2.5; const comExp = opts.communityExponent ?? 1.5; const maxDeg = Math.min(n - 1, opts.maxDegree ?? Math.round(3 * Math.sqrt(n))); // Mean of a bounded power law ≈ minDeg·(γ−1)/(γ−2); invert to hit the target average degree. const minDeg = Math.max(2, Math.round((avgDegree * (degExp - 2)) / (degExp - 1))); const minCom = Math.max(2, Math.min(opts.minCommunity ?? 20, n)); const maxCom = Math.max(minCom, Math.min(opts.maxCommunity ?? Math.round(n / 8), n)); const rand = mulberry32(opts.seed ?? 1);
// 1) Planted communities as contiguous node ranges with power-law sizes covering all n nodes. const community = new Int32Array(n); const comStart: number[] = []; let assigned = 0; for (let c = 0; assigned < n; c++) { let size = powerLaw(rand, minCom, maxCom, comExp); if (assigned + size > n) size = n - assigned; comStart.push(assigned); community.fill(c, assigned, assigned + size); assigned += size; } comStart.push(n); // sentinel end
// 2) Per-node degree (power law), split into intra/inter targets. Intra is capped at the // community size so a small community can satisfy it without excessive multi-edges. const intra = new Uint32Array(n); const inter = new Uint32Array(n); let sumIntra = 0; let sumInter = 0; for (let c = 0; c + 1 < comStart.length; c++) { const start = comStart[c]!; const end = comStart[c + 1]!; const cap = end - start - 1; // most intra-neighbours available for (let u = start; u < end; u++) { const deg = powerLaw(rand, minDeg, maxDeg, degExp); let ai = Math.round((1 - mu) * deg); if (ai > cap) ai = cap < 0 ? 0 : cap; intra[u] = ai; inter[u] = deg - ai; sumIntra += ai; sumInter += inter[u]!; } }
// Edge buffers, sized at the stub-pair upper bound (each undirected edge consumes two stubs). const cap = Math.ceil(sumIntra / 2) + Math.ceil(sumInter / 2) + 1; const source = new Uint32Array(cap); const target = new Uint32Array(cap); let ne = 0;
// 3) Intra-community edges: pair shuffled intra-stubs within each community (configuration model). const intraStubs = new Uint32Array(sumIntra); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < intra[u]!; k++) intraStubs[p++] = u; } let seg = 0; // running start of the current community's stub segment (stubs are in node-id order) for (let c = 0; c + 1 < comStart.length; c++) { let segEnd = seg; for (let u = comStart[c]!; u < comStart[c + 1]!; u++) segEnd += intra[u]!; shuffle(intraStubs, seg, segEnd, rand); for (let i = seg; i + 1 < segEnd; i += 2) { const a = intraStubs[i]!; const b = intraStubs[i + 1]!; if (a !== b) { source[ne] = a; target[ne] = b; ne++; } } seg = segEnd; }
// 4) Inter-community edges: pair shuffled inter-stubs globally, skipping same-community/self pairs. const interStubs = new Uint32Array(sumInter); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < inter[u]!; k++) interStubs[p++] = u; } shuffle(interStubs, 0, sumInter, rand); for (let i = 0; i + 1 < sumInter; i += 2) { const a = interStubs[i]!; const b = interStubs[i + 1]!; if (a !== b && community[a] !== community[b]) { source[ne] = a; target[ne] = b; ne++; } }
// 5) Edge weights: a power-law draw per edge (most ≈ 1, a few heavy) so links vary — and an LOD // super-edge, summing the weights it subsumes, reads as genuinely thicker/heavier. Unweighted ⇒ 1. const weightExp = opts.weightExponent ?? 2.2; const maxWeight = opts.maxWeight ?? 12; const weight = new Float32Array(ne); for (let e = 0; e < ne; e++) weight[e] = opts.weighted ? powerLaw(rand, 1, maxWeight, weightExp) : 1;
return { nodeCount: n, source: source.subarray(0, ne), target: target.subarray(0, ne), weight, community };}Module boundaries
Section titled “Module boundaries”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 atransition. 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 isexpandPxacross on screen, rings or not. widthis in the activesizeMode’s units, so inscreenmode it is a constant pixel stroke around a ring that scales with the map. Rings fade with their members undercrossFade, draw above the links and under the nodes, so a dense module’s links never hide where it ends, and export withtoSVG()/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. WithcrossLevelEdgeson, 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")andselection()see the same id, datum andmembers(), and the datum addsopen: 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. Withinteractive()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. Withinteractive({ 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) });Rings styled per module
Section titled “Rings styled per module”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: itsnodeFillmodule colour, oraggregateFill.width: (path) => number | undefinedsizes each ring by the module’s Infomappath([2, 1]is sub-module 1 of top module 2), in the activesizeMode’s units.undefinedtakes the constant default, as does every aggregate of a tree that is not a module hierarchy (it has no paths), and0draws no ring.flowBorderfrom module flow alone. WithmoduleFlowand noflow, 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()orlod()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 overiterationsuntil 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 withfitthe 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
transitionstreams 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, withfitkeeping 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
fitthe 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 endsnested: { 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 atransitionit 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: 600eases 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.transitionalso eases a"positions"or"force"layout; a newlayout(),data(),stopLayout()ordestroy()stops it where it is, and grabbing a node finishes it.fit: trueeases 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 callsetTransform) — it never pulls the view away from you. Stopping the layout (stopLayout(), a newlayout()ordata()) 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. Withoutfitthe 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.
State networks: physical ↔ state views
Section titled “State networks: physical ↔ state views”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 modulepath. (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 (yournodeRadius), 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. InsizeMode: "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).
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, ); }, };};/** * Synthetic **state (memory) network** generator for the state-network example (#171). * * There is no bundled Infomap in the browser, so we synthesise a state network whose structure mirrors * what a higher-order community detection would find, in two stages: * * 1. **Physical network** — an LFR-inspired benchmark: planted communities, a power-law degree * distribution, and a mixing parameter `mu` (the fraction of a node's edges that leave its * community). Enough community structure that a partition is meaningful, without the full * Lancichinetti–Fortunato–Radicchi degree/community-size realisation. * * 2. **State network via node2vec trigrams** — a state node is a directed physical edge `(i→j)` * ("at `j`, came from `i`"), so its physical node is the head `j`. For every consecutive pair of * edges `i→j→k` (a trigram) we add a state edge `(i,j) → (j,k)`, weighted by the node2vec 2nd-order * transition bias from `j` given we came from `i`: `1/p` to return (`k = i`), `1` to a neighbour of * `i` (a **triangle-closing** step), `1/q` otherwise. With `p = 2, q = 3` the walk stays local and * favours closing triangles, giving realistic memory structure. * * The **module of a state node `(i,j)` is the community of its *previous* node `i`** — memory separates * flow through a node by where it came from. So an interior physical node (all predecessors in one * community) is single-module (a solid disc), while a **bridge** node (predecessors in several * communities) spans multiple modules — exactly the overlapping membership the physical view draws as a * pie chart. * * Fully deterministic (seeded PRNG, no `Math.random`) so the example and its tests are reproducible. */import { buildStateGraph, type StateNetworkGraph } from "@mapequation/d3gl/network";import type { ModulePathNode } from "@mapequation/d3gl/network";
/** Deterministic PRNG (mulberry32): a seed → a `() => float in [0,1)` stream. */function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
export interface LFROptions { /** Number of physical nodes. Default 240. */ nodeCount?: number; /** Number of planted communities. Default 6. */ communityCount?: number; /** Mixing parameter: fraction of a node's edges that cross its community. Default 0.15. */ mu?: number; /** Mean target degree (power-law mean-ish). Default 8. */ avgDegree?: number; /** Power-law degree exponent. Default 2.5. */ gamma?: number; /** PRNG seed. Default 1. */ seed?: number;}
export interface PhysicalNetwork { nodeCount: number; /** Undirected edges, each listed once (i < j). */ edges: Array<[number, number]>; /** Per-node community id in `[0, communityCount)`. */ community: Int32Array; /** Neighbour set per node (undirected), for adjacency tests. */ neighbors: Set<number>[];}
/** Sample an integer degree from a truncated power law `p(k) ∝ k^-γ` on `[kmin, kmax]`. */function powerLawDegree(rand: () => number, kmin: number, kmax: number, gamma: number): number { const g1 = 1 - gamma; const lo = Math.pow(kmin, g1); const hi = Math.pow(kmax, g1); const k = Math.pow(lo + rand() * (hi - lo), 1 / g1); return Math.max(kmin, Math.min(kmax, Math.round(k)));}
/** Generate an LFR-inspired physical network with planted communities and a mixing parameter. */export function generateLFR(opts: LFROptions = {}): PhysicalNetwork { const nodeCount = opts.nodeCount ?? 240; const communityCount = opts.communityCount ?? 6; const mu = opts.mu ?? 0.15; const avgDegree = opts.avgDegree ?? 8; const gamma = opts.gamma ?? 2.5; const rand = mulberry32(opts.seed ?? 1);
// Assign nodes to communities in contiguous, roughly equal blocks, and index the members per community. const community = new Int32Array(nodeCount); const members: number[][] = Array.from({ length: communityCount }, () => []); for (let i = 0; i < nodeCount; i++) { const c = Math.floor((i / nodeCount) * communityCount); community[i] = c; members[c]!.push(i); }
const neighbors: Set<number>[] = Array.from({ length: nodeCount }, () => new Set<number>()); const kmin = Math.max(2, Math.round(avgDegree / 2)); const kmax = Math.max(kmin + 1, Math.round(Math.sqrt(nodeCount) * 2)); const addEdge = (a: number, b: number): void => { if (a === b) return; neighbors[a]!.add(b); neighbors[b]!.add(a); }; const pick = (pool: number[], not: number): number => { // A few tries to avoid self; pools are large enough that this rarely loops. for (let t = 0; t < 8; t++) { const x = pool[Math.floor(rand() * pool.length)]!; if (x !== not) return x; } return pool[0]!; };
for (let i = 0; i < nodeCount; i++) { const target = powerLawDegree(rand, kmin, kmax, gamma); const own = members[community[i]!]!; while (neighbors[i]!.size < target) { if (rand() < mu && communityCount > 1) { // Cross-community edge: pick from a different community. let c = Math.floor(rand() * communityCount); if (c === community[i]!) c = (c + 1) % communityCount; addEdge(i, pick(members[c]!, i)); } else { addEdge(i, pick(own, i)); } if (own.length <= 1 && communityCount === 1) break; // degenerate guard } }
const edges: Array<[number, number]> = []; for (let i = 0; i < nodeCount; i++) for (const j of neighbors[i]!) if (i < j) edges.push([i, j]); return { nodeCount, edges, community, neighbors };}
export interface SyntheticStateNetwork { /** The assembled state graph (state + derived physical views), ready to render. */ graph: StateNetworkGraph; /** Per-**state-node** module records ({id, path:[community+1, rank]}) for colours / pie wedges. */ stateModules: ModulePathNode[]; /** Physical node labels: 1-based ids as strings (`"1"`, `"2"`, …), indexed by physical id. */ physicalNames: string[]; /** State node labels: `"(i,j)"` with `i`,`j` the 1-based physical ids of the state node's * (previous, current) endpoints, indexed by state-node id. */ stateNames: string[]; /** The underlying physical network (communities, edges) for reference / physical-view colouring. */ physical: PhysicalNetwork;}
export interface StateNetworkOptions extends LFROptions { /** node2vec return parameter (higher ⇒ less backtracking). Default 2. */ p?: number; /** node2vec in-out parameter (higher ⇒ more triangle-closing, less exploration). Default 3. */ q?: number;}
/** * Build a synthetic {@link StateNetworkGraph} from an LFR-inspired physical network via node2vec * trigrams. State nodes are the directed physical edges; state edges are node2vec-weighted trigrams; * state-node modules are the previous node's community (so bridge physical nodes overlap modules). */export function generateStateNetwork(opts: StateNetworkOptions = {}): SyntheticStateNetwork { const p = opts.p ?? 2; const q = opts.q ?? 3; const physical = generateLFR(opts); const { nodeCount: physicalCount, neighbors, community } = physical;
// State nodes = directed physical edges (i→j); physical of (i,j) is the head j. Index them densely. const stateId = new Map<number, number>(); // key = i*physicalCount + j → state-node id const prev: number[] = []; // state id → i (previous physical node) const curr: number[] = []; // state id → j (current physical node = its physical id) const idOf = (i: number, j: number): number => { const key = i * physicalCount + j; let s = stateId.get(key); if (s === undefined) { s = prev.length; stateId.set(key, s); prev.push(i); curr.push(j); } return s; }; for (let i = 0; i < physicalCount; i++) for (const j of neighbors[i]!) { idOf(i, j); idOf(j, i); } const stateCount = prev.length;
// State edges = trigrams (i,j)→(j,k), weighted by node2vec's 2nd-order transition bias from j given i. const source: number[] = []; const target: number[] = []; const weight: number[] = []; for (let s = 0; s < stateCount; s++) { const i = prev[s]!; const j = curr[s]!; const iNbrs = neighbors[i]!; for (const k of neighbors[j]!) { const alpha = k === i ? 1 / p : iNbrs.has(k) ? 1 : 1 / q; // return / triangle / explore source.push(s); target.push(idOf(j, k)); weight.push(alpha); } }
// A cheap visit-rate proxy: each state node's normalised in-strength (sum of incoming trigram weights). const inStrength = new Float32Array(stateCount); for (let e = 0; e < target.length; e++) inStrength[target[e]!] = inStrength[target[e]!]! + weight[e]!; let total = 0; for (let s = 0; s < stateCount; s++) total += inStrength[s]!; const nodeFlow = new Float32Array(stateCount); for (let s = 0; s < stateCount; s++) nodeFlow[s] = total > 0 ? inStrength[s]! / total : 1 / stateCount;
const stateToPhysical = Uint32Array.from(curr); const graph = buildStateGraph({ stateCount, stateToPhysical, source, target, weight, nodeFlow, physicalCount, directed: true, });
// Module of state node (i,j) = previous node i's community (memory separates flow by origin). Two-level // path [community+1, rank] so top-level module = community (moduleColors splits the hue circle by it). const rankOf = new Map<number, number>(); // community → next rank const stateModules: ModulePathNode[] = new Array(stateCount); for (let s = 0; s < stateCount; s++) { const c = community[prev[s]!]!; const rank = (rankOf.get(c) ?? 0) + 1; rankOf.set(c, rank); stateModules[s] = { id: s, path: [c + 1, rank] }; }
// Human labels: physical nodes are 1-based ids; a state node (prev→curr) is "(i,j)" over those ids. const physicalNames = Array.from({ length: physicalCount }, (_, p) => String(p + 1)); const stateNames = new Array<string>(stateCount); for (let s = 0; s < stateCount; s++) stateNames[s] = `(${prev[s]! + 1},${curr[s]! + 1})`;
return { graph, stateModules, physicalNames, stateNames, physical };}Loading a network from a file
Section titled “Loading a network from a file”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.
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; }, };};/** * Built-in sample networks for the load-from-file example — one in each format the example * accepts, so it has something to show before you pick a file. `parseNetwork` (from * `@mapequation/d3gl/network`) dispatches on the filename: `.net` → Pajek, anything else → the * plain edge-list parser. */
/** Pajek `.net`: two friend groups bridged by a few links, with quoted vertex labels (two long ones, to show `labels({ maxWidth })` trimming). */export const SAMPLE_PAJEK = `*Vertices 121 "Alice"2 "Bob"3 "Carol"4 "Dave"5 "Erin"6 "Frank"7 "Grace"8 "Heidi"9 "Ivan"10 "Judy"11 "Mallory, who bridges the two friend groups"12 "Niaj, a long name to show the trimming"*Edges1 21 32 33 42 45 65 76 77 86 89 109 1110 1111 1210 124 58 912 1`;
/** Plain edge list: `source target [weight]`, `#` comments, whitespace-separated. */export const SAMPLE_EDGELIST = `# tiny weighted edge list — source target weightcore hub 5hub a 2hub b 2hub c 2a b 1b c 1c a 1core leaf1 3core leaf2 3leaf1 leaf2 1`;/** * Overlay control bar for the load-network example: a file picker + two built-in-sample buttons. * Plain DOM plumbing, kept out of the example's d3gl `draw.ts` so the code tab stays focused. */import { SAMPLE_PAJEK, SAMPLE_EDGELIST } from "./data.js";
const BTN = "rounded border border-[#cbd5e1] bg-white/90 px-2 py-1 text-xs text-[#334] shadow-sm hover:bg-white cursor-pointer";
/** Build the control bar; `load(text, filename)` is called with whatever the user picks/clicks. */export function makeControls(load: (text: string, filename: string) => void): HTMLElement { const bar = document.createElement("div"); bar.className = "absolute left-2 top-2 z-10 flex flex-wrap items-center gap-2";
const picker = document.createElement("label"); picker.className = BTN; picker.textContent = "Load .net / edge list…"; const input = document.createElement("input"); input.type = "file"; input.accept = ".net,.txt,.edges,.edgelist,.tsv,.csv,text/plain"; input.className = "hidden"; input.addEventListener("change", () => { const file = input.files?.[0]; if (!file) return; void file.text().then((text) => load(text, file.name)); input.value = ""; // let the same file be re-picked }); picker.appendChild(input);
const sample = (text: string, label: string, filename: string): HTMLButtonElement => { const b = document.createElement("button"); b.type = "button"; b.className = BTN; b.textContent = label; b.addEventListener("click", () => load(text, filename)); return b; };
bar.append( picker, sample(SAMPLE_PAJEK, "Sample .net", "sample.net"), sample(SAMPLE_EDGELIST, "Sample edges", "sample.txt"), ); return bar;}Dragging nodes
Section titled “Dragging nodes”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 panningOn 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 dragging | On release |
|---|---|---|
force (main thread) | a pinned ForceLayout reheats neighbours each frame | re-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 cursor | the 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 cursor | the layout is unpinned and re-cools until converged |
auto | as gpu or worker, whichever it resolved to | as 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.