Skip to content

Network

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

This example builds an LFR benchmark network — power-law node degrees and power-law community sizes with a tunable mixing parameter, the standard benchmark for community detection — and lets d3gl’s in-library force layout (Barnes-Hut) place the nodes; no coordinates are supplied. The layout is seeded by multilevel coarsening: the graph is collapsed into a hierarchy of ever-smaller graphs (each node merged with a neighbour, hubs and their leaves absorbed together so power-law graphs still shrink geometrically), the tiny coarsest one is laid out first, and positions are projected back down and refined — far faster convergence and fewer tangles than a cold start. 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.

fps 0frame 0 ms
Nodes1k
Links
Node size
Edge size
Sizing
LOD
Declutter
Edges
Cross-level edges
Cross-fadeOff
Seeding
Backend
draw.ts
import { network, buildGraph, sharedMemoryAvailable, type NodeRadiusSpec, type NetworkGraph, type NetworkHit } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { generateLFR } from "./data.js";
const SIZES = [10, 100, 1_000, 10_000, 100_000, 1_000_000];
/**
* Degree-weighted node radius: a d3 `scaleSqrt` (area-proportional) over the graph's degree range,
* handed to `nodeRadius` as `{ by: "degree", scale }`. Resolved once per style() — varying per-node
* radius is a per-instance GPU attribute, so this costs nothing at draw time, even at 1M nodes.
*/
function degreeRadius(graph: NetworkGraph): NodeRadiusSpec {
let lo = Infinity;
let hi = 0;
for (const d of graph.csr.degree) {
if (d < lo) lo = d;
if (d > hi) hi = d;
}
if (hi <= lo) return 6; // uniform degree (e.g. a single clique) — nothing to scale
return { by: "degree", scale: scaleSqrt().domain([lo, hi]).range([3, 13]) };
}
/**
* An **LFR benchmark network** (power-law degrees + power-law communities with a mixing parameter —
* the standard community-detection benchmark) rendered with the `network()` engine: nodes as
* GPU-instanced points, links as instanced lines, triangle arrowheads for directed edges. Node
* positions come from d3gl's in-library **force layout** (Barnes-Hut), seeded by **multilevel
* coarsening** — no coordinates are supplied. `layout({ backend: "worker" })` runs the whole solve in
* a Web Worker and streams positions back, so the layout **converges progressively on screen** while
* the UI stays responsive. The Nodes slider scales 10 → 1,000,000; **Node size** switches a uniform
* vs **degree-weighted** radius; **Edge size** switches uniform vs **weight-scaled** links (LOD
* super-edges thicken + darken with their accumulated weight); **Sizing** switches world vs **screen**
* (constant-pixel) glyphs. The
* **LOD** toggle enables the adaptive hierarchy cut — dense communities collapse to aggregate glyphs
* and expand into their members as you zoom in — with **Declutter** (thin overlaps) 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,
);
},
};
};

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

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

nodeRadius can be:

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

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

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

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

  • nodeRadius + nodeFill — total node flow → disc size and fill colour (nodeRadius: { by: "flow", scale }, nodeFill a per-node colour scale).
  • flowBorderflow is a per-node value (your Float32Array of enter/exit flow, or a built-in metric like "strength"); scale maps it to ring width and color may be a 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 fits linkStrokescaleSqrt().range([light, dark]) interpolates RGBA, alpha included), or { by, scale } like nodeRadius (by is "weight"/"flow"). A super-edge applies the same scale to its accumulated subsumed weight, so a heavier super-edge reads as thicker and darker. The arrowhead is part of the shape, so it always takes the link’s colour — there is no separate arrow fill. Keep range minimums ≥ 1 so nothing vanishes at low flow.
  • linkBend — for half-arrows, an absolute world-unit ⟂ offset (the reference’s bend); 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).

fps 0frame 0 ms
Bend30
Sizing
draw.ts
import { network, buildGraph } from "@mapequation/d3gl/network";
import { scaleLinear } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { buildReplica, REPLICA_BOUNDS, NODE_FILL_RANGE, NODE_BORDER_RANGE, LINK_RANGE } from "./data.js";
const BENDS = [0, 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
});
},
};
};

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 element

What it composes:

  • Aggregates carry their subtree’s centroid and a radius (area-additive √Σr², or — when sizing by an additive metric like flow — the same node scale applied to the summed child value, so a module reads as its total flow). Aggregate identity is stable, so they don’t pop as you pan.
  • Declutter drops glyphs that would overlap a kept one, keeping the highest-importance member — zoom-dependent, so more resolve as you zoom in. Importance is summed up the tree (a module’s is its members’ total) and set by style({ importance }) — a metric ("degree"/"strength"/"flow"), an accessor, a Float32Array, or "order". It defaults to the node-size metric, so the biggest glyph wins an overlap.
  • 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: true restores them by projecting the off-frontier endpoint to its nearest visible ancestor (opt-in — zero added cost when off).
  • crossFade smooths level transitions: over a band around the expand threshold (a fraction of expandPx, e.g. 0.3), the aggregate and its children draw together, the parent easing out as the children ease in — so a split/merge reads smoothly instead of popping. Opt-in; off (and free) by default.
  • sizeMode"world" (glyphs scale with zoom) or "screen" (constant pixels, so nodes stay visible when zoomed out). LOD pairs naturally with "screen".

The geometry tracks the layout as it converges (LOD helps during the solve, not just after), while panning and zooming only re-run the cheap cut. On the WebGL lane the cut re-runs live every frame. 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 tree

Maps of networks: a provided module hierarchy

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

When the app already has a module hierarchy — e.g. an Infomap clustering — 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.

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.

fps 0frame 0 ms
Depth243
LOD
Cross-fade0.2
draw.ts
import { network, buildGraph, moduleColors, type NetworkLinkHit } from "@mapequation/d3gl/network";
import type { ImperativeSetup } from "../types.js";
import { generateSierpinski, SIERPINSKI_BOUNDS } from "./data.js";
const DEPTHS = [2, 3, 4, 5, 6]; // 27 → 2187 nodes
/**
* **Modular-aware level of detail + aggregate inspection.** An undirected Sierpinski gasket whose
* recursive subdivision *is* a planted module hierarchy (Infomap-style `path` per node), fed to
* `net.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 });
},
};
};

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).

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

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

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

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

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

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

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

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.

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.

fps 0frame 0 ms
Nodes1k
LOD
Sizing
Declutter
Expand240
Max radius18
Labels12
Cross-level edges
Cross-fade0.2
draw.ts
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 });
}
},
};
};

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 rosette

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

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.

fps 0frame 0 ms
Nodes100
Backend
View
Links
Physical labels
State labels
Sizing
LOD
Declutter
Expand240
Max radius18
Labels12
Cross-level edges
Cross-fade0.2
draw.ts
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,
);
},
};
};

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).

fps 0frame 0 ms
draw.ts
import { network, buildGraph, parseNetwork } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { makeControls } from "./controls.js";
import { SAMPLE_PAJEK } from "./data.js";
// Remember the last document module-side so the harness's resize-driven setup() re-run reloads it.
let loaded = { text: SAMPLE_PAJEK, name: "sample.net" };
/**
* Load a network from a file and render it with the `network()` engine. `parseNetwork` dispatches
* on the filename — `.net` → Pajek (vertex labels, `*Arcs`/`*Edges`), anything else → the plain
* edge-list parser (`source target [weight]`, `#` comments). The off-thread worker lays it out with
* `layout({ fit: true })`, so it opens **framed** and converges live; nodes are **sized by degree** (a
* d3 `scaleSqrt`) so hubs stand out. Vertex names are drawn with engine-managed **frontier labels**
* (`net.labels({ labelOf })`) — 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;
},
};
};

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

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

What moves depends on what you grab:

  • A node → that node alone.
  • A node that’s part of the current selection → the whole selection moves together (every selected node is held + translated by the cursor delta). Grabbing an unselected node first makes it the selection, then drags it alone.
  • A collapsed module aggregate (under LOD) → its whole subtree — every leaf descendant translates, and the aggregate glyph follows because its centroid re-derives from the moved leaves. (members() enumerates exactly those leaves, the same set selection uses.)

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 draggingOn release
force (main thread)a pinned ForceLayout reheats neighbours each framere-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 cursorthe 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 cursorthe 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.