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.
With layout({ backend: "worker" }) the entire solve runs in a Web Worker and streams positions
back, so the layout converges progressively on screen while the main thread stays responsive —
pan and zoom while it settles. (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; and LOD / Declutter /
Edges control the level-of-detail cut (below). Drag to pan, scroll to zoom.
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) and **Edges** * (super-edges between aggregates). Pair LOD with screen sizing. Drag empty space to pan, scroll to zoom. * **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, with automatic fallback to `"worker"` when float render targets are unavailable). */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. 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"; 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): three signals — layout transport (gpu / shared / copy / none), // the environment's SAB *capability* (`sharedMemoryAvailable()`), and whether SAB is *in use*. // For a `backend:"gpu"` 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>`; }; 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" : "worker"; const key = `${count}|${directed}|${multilevel}|${layoutBackend}`; if (key !== layoutKey) { layoutKey = key; // Scale per-tick work down as the graph grows so the off-thread solve stays responsive; the // worker keeps the main thread free regardless, streaming frames as it converges. const iterations = Math.min(250, Math.max(10, Math.round(2.5e6 / count))); // 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 }); // 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.data(graph).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 } 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", 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: 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" ? { expandPx: 48, aggregateFill: "#7f97c8", maxAggregateRadius: 26, declutter: options.declutter !== "Off", superEdges: options.edges !== "Off", // 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, ); }, };};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 };}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 (nodeRadius: { by: "flow", scale },nodeFilla per-node colour scale).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 per-node accessor so the ring colour encodes it too. d3gl doesn’t compute enter/exit flow — you supply it (Infomap gives it).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.linkBend— for half-arrows, an absolute world-unit ⟂ offset (the reference’sbend); 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: (i) => fillColor(graph.flow[i]), // a per-node colour scale on flow flowBorder: { flow: enterExitFlow, scale: borderWidth, color: (v) => borderColor(v) }, // ring ∝ enter/exit linkBend: 30, linkWidth: scaleLinear().domain([lo, hi]).range([7, 13]), // width ∝ link flow linkStroke: scaleLinear().domain([lo, hi]).range(["#71B2D7", "#418EC7"]), // colour ∝ link flow});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.
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 and at the end of a
pan/zoom — 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, 15, 30, 45, 60]; // the Bend slider's stops (absolute world-unit ⟂ offset)
/** * 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, and drag the **Bend** slider. */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] ?? 30; // World (default): radii/widths/bend 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"; net.style({ directed: true, linkStyle: "half-arrow", sizeMode, nodeRadius: { by: "flow", scale: radius }, // radius ∝ total flow nodeFill: (i) => fillColor(graph.flow![i]!), // fill ∝ total flow flowBorder: { flow: g.outFlow, scale: borderWidth, color: (v) => borderColor(v) }, // ring ∝ 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 only 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, // expand an aggregate once its on-screen footprint reaches ~48px 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:
- 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. - 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 only, 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). 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.
On the Canvas/SVG backends the same frontier draws as retained geometry — so toSVG() exports a
level-of-detail map — 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 (call syncScreenGeometry() to re-cut at a chosen
zoom before a programmatic export).
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.
net .lod({ expandPx: 48, maxAggregateRadius: 26 }) // configure LOD first… .layout({ backend: "worker", iterations }); // …so the worker builds + streams the treeMaps 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 — pass it as the LOD source instead of letting d3gl coarsen structurally. d3gl does not cluster; the tree is computed app-side and handed in, just like externally-provided positions. Modules then 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).
import infomapResult from "./network.json"; // Infomap JSON: { nodes: [{ id, path, flow, … }], … }
net .data(graph) .style({ sizeMode: "screen" }) .lod({ modules: infomapResult.nodes, expandPx: 48 }) .layout({ backend: "positions", positions });
net.lodSource; // "modules"The provided hierarchy takes priority over structural coarsening; everything else about the cut
(declutter, sizeMode, the on-screen-bounded per-frame work) is unchanged. Under LOD a module’s
flow border sums its members’ enter/exit flow, 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.lod({ 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). 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.lod({ 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. * * `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"; 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) .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 ? { modules, 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.lod({ 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 blue (“will add”), subtracting rings the box’s selected glyphs red (“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.
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. Ranking (a sort) runs only when max actually caps, so the default
“show all” path pays nothing for it.
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 via lod({ modules })
before layout({ backend: "gpu" }), so the WebGL2 Barnes-Hut solve is seeded top-down over the module
tree — 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 (centroid
→ view centre, extent → 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.
Nodes are coloured categorically by module (the planted communities), fed to lod({ modules }). The
LOD control switches the cut:
- Off — every node and half-arrow.
- Standard — plain structural coarsening; 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.
import { network, buildGraph, moduleColors } from "@mapequation/d3gl/network";import { scaleSqrt, type ScaleContinuousNumeric } from "d3-scale";import type { ImperativeSetup } from "../types.js";import { 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. * * The layout is the **module-aware GPU seed** (#180 N8.2): the provided module hierarchy is supplied * *before* `layout({ backend: "gpu" })`, so 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 **Nodes** slider resizes the generated network (500 → 20,000): the map is regenerated — flow and * all — and re-laid-out on the GPU, framing itself each time. The **LOD** control switches the cut: * **Off** draws every node + half-arrow; **Standard** is plain structural coarsening (it ignores the * planted partition — aggregates joined by simple super-edge lines); **Modules** 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 })` adds the selection/hover rings + 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 (translate-only here, on the `positions` backend). It shows * the selection/hover ring living alongside the per-node **flowBorder** ring and a module's **aggregateOutline**. */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 (green hover, blue // selection), ⇧+drag box-selects (⌥ subtracts, red preview), and dragging a glyph — or a whole selected // set, or a collapsed module — moves it. Note how the selection/hover ring sits alongside the per-node // flowBorder ring and a collapsed module's aggregateOutline. net.interactive({ selectable: { multi: true }, draggable: true, hover: true });
// 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 colors: string[] = []; let enterExit: Float32Array<ArrayBufferLike> = new Float32Array(); let maxNodeFlow = 1; let modulePaths: { id: number; path: number[] }[] = []; 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) { count = n; const d = makeModularMap(n); modulePaths = d.modulePaths; enterExit = d.enterExit; // 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]); 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); const graph = buildGraph({ nodeCount: d.nodeCount, source: d.source, target: d.target, weight: d.linkFlow, // edge weight = flow, so LOD super-edges accumulate flow directed: true, nodeFlow: d.nodeFlow, }); // Supply the (ragged) module hierarchy BEFORE laying out, so 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. net.data(graph); net.lod({ modules: modulePaths }); net.layout({ backend: "gpu", fit: true, iterations: 300 }); }
// 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"; const expandPx = (options.expand as number) ?? 120; 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 nodeFill: (i) => colors[i]!, // categorical module colour // Ring ∝ enter/exit flow; colour omitted ⇒ a darker shade of each glyph's own module colour. flowBorder: { flow: enterExit, scale: ringW }, linkBend: 14, // px (screen mode) 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"; // A thin outline ring, set a few px outside the glyph, marks collapsed aggregates as expandable. const aggregateOutline = { width: 1.5, gap: 3 }; // 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 — no module info; aggregates joined by plain super-edge lines. net.lod({ expandPx, declutter, aggregateOutline, crossLevelEdges, crossFade }); } else { // The planted partition 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({ modules: modulePaths, expandPx, declutter, superEdges: true, aggregateOutline, crossLevelEdges, crossFade }); } }, };};import { randomWalkFlow } 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. * - `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; community: Int32Array; /** Infomap-shape module records for `lod({ 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; return { nodeCount: n, communities, source, target, linkFlow, nodeFlow, enterExit, community, modulePaths };}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 };}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 solid disc.
- State — every state node on a golden-angle rosette around its physical node, coloured by
module, so you can see why a node splits (its memory nodes belong to different communities). The
LOD control (
net.lod({ modules })) aggregates the state nodes into their modules on zoom-out — the state nodes carry the module tree, so the modular LOD cut applies directly. (LOD is a state-view feature; the physical/both views draw full detail.) - Both — a hybrid: each physical node is a faint container disc holding its state nodes on a rosette confined inside it, drawn with state-level links — the memory structure in its physical context. Everything is world-sized so the state nodes stay inside their physical node at every zoom.
The Nodes slider scales the physical network from 10 to 10,000 (100k+ awaits the module-aware GPU layout — the physical force solve + the node2vec trigram enumeration are the current ceiling); 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 physical graph + derives rosette state positions
net.view("physical"); // pie glyphs for overlapping nodes · net.view("state") for the rosetteThe 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.
Positions here are the CPU stopgap: d3gl’s in-library force layout places the physical nodes and the
deterministic rosette fans each physical node’s state nodes onto a ring around it. The module-aware GPU
stateLayout (which will place state nodes with intra-physical structure) is tracked separately.
import { network, physicalPieWedges, type PhysicalPieWedges } from "@mapequation/d3gl/network";import { scaleSqrt } from "d3-scale";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;
/** * **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 solid disc. * - **State** — every state node on a golden-angle **rosette** around its physical node, coloured by * module. `net.lod({ modules })` (the **LOD** control) aggregates the state nodes into their modules. * - **Both** — state nodes confined **inside** each physical node's container disc, with state-level * links: the memory structure in its physical context. * * The data is synthetic (`state-network-data.ts`): an LFR physical network + node2vec trigrams, node * labels `1,2,…` (physical) and `(i,j)` (state). `layout({ backend })` lays out the physical graph — **Force** * (main-thread, synchronous), **Worker** (off-thread, progressive), or **GPU** (WebGL2 Barnes-Hut, * falling back to Worker when unavailable) — and derives the rosette from it each streamed frame (#182); * it also **scales the layout to fill the view** once settled, so it opens framed — no fit-transform. * Scroll to zoom, drag to pan. */export const setup: ImperativeSetup = (host, { width, height, backend }) => { const net = network(host, { width, height, backend }); 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
// 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 p = id as number; 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 net.stateView === "physical" ? data.physicalNames[p] ?? null : data.stateNames[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 dataChanged = !data || builtN !== n; const layoutChanged = dataChanged || builtBackend !== backend; // Backend control also re-lays out 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; // The engine owns nodeFill (module colours) and, in the "both" view, nodeRadius (dots sized to the // containers) — so this only sets the shared appearance + the physical/state node size. net.style({ directed: true, sizeMode: view === "both" || options.sizing === "World" ? "world" : "screen", linkStyle: halfArrow ? "half-arrow" : "line", linkBend: halfArrow ? 14 : 0.15, // half-arrow: world-unit bow; line: fraction of chord — both bent 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" }, ...(view === "both" ? {} : { nodeRadius: physical ? { by: "flow" as const, scale: scaleSqrt().domain([0, hiFlow]).range([4, 22]) } : 5 }), });
if (layoutChanged) { // fit: true (#238) frames the streaming physical layout as it converges (worker/gpu); the // synchronous `force` backend ignores it and frames itself. Opens framed, no top-left flash. net.stateNetwork(g.graph, { modules: g.stateModules, view }).layout({ backend, fit: true }); builtBackend = backend; } else net.view(view);
// LOD applies only to the state view (its nodes carry the module tree); default Off. "Modules" cuts // on the provided partition; "Standard" coarsens the state graph structurally. const lodOn = view === "state" && options.lod !== "Off"; net.lod( lodOn ? { modules: options.lod === "Modules" ? g.stateModules : undefined, expandPx: options.expand as number, 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 container (≈1:30); state labels on the dots. // 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, labelOf: (id) => (physical ? (physOn ? g.physicalNames[id] ?? null : null) : stateOn ? g.stateNames[id] ?? null : 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 }) 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).
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 })`) — they track pan/zoom (and the fit reframe) with no overlay bookkeeping. * 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. .labels({ labelOf: (id) => names?.[id] ?? null, offset: [7, -4] }) // The worker seeds a viewport-centred disc, so this opens framed at k=1 as it converges — no fit // needed here (fit is for the solvers that centre elsewhere, e.g. the GPU origin — see network/state). .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. */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"12 "Niaj"*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 panningWhat 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.)
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:
layout({ backend }) | While dragging | On release |
|---|---|---|
force (main thread) | a pinned ForceLayout reheats neighbours each frame | re-cools over 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 |
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 |
positions (fixed coords) | the held set simply translates (no simulation to reheat) | — |
On the worker and gpu 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.