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.

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 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
draw.ts
import { network, buildGraph, type NodeRadiusSpec, type NetworkGraph } 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 to pan, scroll to zoom.
*/
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
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";
// 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 });
const graph = buildGraph({ nodeCount, source, target, weight, directed });
// 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
.data(graph)
.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,
)
.layout({ backend: "worker", iterations, multilevel });
},
};
};

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 } 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.** 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.
* Links are simple bent lines (undirected, no arrows). 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
return {
engine: net,
render: (options) => {
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 });
},
};
};

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 offline by d3gl’s randomWalkFlow and baked into a fixture (generate.ts). That function 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.

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
LOD
Sizing
Declutter
Expand240
Max radius18
Cross-level edges
Cross-fade0.2
draw.ts
import { network, buildGraph, moduleColors } from "@mapequation/d3gl/network";
import { scaleSqrt } from "d3-scale";
import type { ImperativeSetup } from "../types.js";
import { loadModularMap } from "./data.js";
/**
* A **directed map of modules** from a baked 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 **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-members → leaves.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
const d = loadModularMap();
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,
});
// Categorical colour per planted module; aggregates inherit their module's colour under LOD.
const colors = moduleColors(d.modulePaths, { lightness: 62, chroma: 58 });
const 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 ≥ 1 so nodes/links never vanish (the ring may be 0 for interior nodes). The node
// radius range top is slider-driven (see render) — smaller ⇒ less declutter ⇒ more nodes + edges show.
const ringW = scaleSqrt().domain([0, maxEnter]).range([0, 6]);
const linkW = scaleSqrt().domain([0, maxLink]).range([0.75, 6]); // thinner half-arrows
// Link colour encodes flow like the half-arrow example — light → dark blue with flow — and is
// semi-transparent (alpha also ∝ flow) so overlaps read as density, not black. So a reciprocal pair
// shows its asymmetry in both width AND colour. (Manual lerp keeps the alpha a colour scale drops.)
const linkStroke = (w: number) => {
const t = Math.sqrt(Math.min(1, w / maxLink));
const r = Math.round(150 - 110 * t);
const g = Math.round(186 - 96 * t);
const b = Math.round(221 - 60 * t);
return `rgba(${r}, ${g}, ${b}, ${(0.4 + 0.5 * t).toFixed(3)})`;
};
net.data(graph).layout({ backend: "force", iterations: 320 });
// Rather than a custom fit transform, **scale the layout** to fill the view at the default zoom — so
// the map opens framed and World/Screen sizing don't change the apparent scale (the transform stays
// k=1). Centre on the centroid and size from the 97th-percentile radius (robust to force-layout
// fling-outs). Modules collapse into the map of modules here; zoom in to expand them.
const p = graph.positions;
let cx = 0;
let cy = 0;
for (let i = 0; i < graph.nodeCount; i++) {
cx += p[i * 2]!;
cy += p[i * 2 + 1]!;
}
cx /= graph.nodeCount;
cy /= graph.nodeCount;
const dists = Array.from({ length: graph.nodeCount }, (_, i) => Math.hypot(p[i * 2]! - cx, p[i * 2 + 1]! - cy)).sort((a, b) => a - b);
const R = dists[Math.floor(graph.nodeCount * 0.97)] || 1;
const s = (Math.min(width, height) / 2) * 0.85 / R; // scale the 97th-pct extent to ~0.85 of half the view
for (let i = 0; i < graph.nodeCount; i++) {
p[i * 2] = width / 2 + (p[i * 2]! - cx) * s;
p[i * 2 + 1] = height / 2 + (p[i * 2 + 1]! - cy) * s;
}
net.layout({ backend: "positions", positions: p }); // commit the scaled layout at the default k=1 view
net.enableZoom([0.1, 40]); // default view; zoom out to the module map, in to single nodes
return {
engine: net,
render: (options) => {
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: d.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: d.modulePaths, expandPx, declutter, superEdges: true, aggregateOutline, crossLevelEdges, crossFade });
}
},
};
};

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 drawn beside them with the HTML LabelLayer overlay (from @mapequation/d3gl/labels), which culls overlaps and tracks the GPU geometry as you pan and zoom.

fps 0frame 0 ms
draw.ts
import { network, buildGraph, parseNetwork } from "@mapequation/d3gl/network";
import { LabelLayer, type LabelAnchor } from "@mapequation/d3gl/labels";
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;
* nodes are **sized by degree** (a d3 `scaleSqrt`) so hubs stand out. Once it settles, vertex labels
* are placed beside the nodes with the HTML `LabelLayer` overlay and kept aligned with the GPU
* geometry as you pan/zoom. Pick a file, or load a built-in sample.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const net = network(host, { width, height, backend });
// HTML label overlay above the canvas (the harness positions `host` relative).
const labelEl = document.createElement("div");
labelEl.className = "absolute inset-0 overflow-hidden pointer-events-none text-[11px] font-medium text-[#334]";
host.appendChild(labelEl);
const labels = new LabelLayer(labelEl, (a) => a.text);
let anchors: LabelAnchor[] = [];
let view = { k: 1, x: 0, y: 0 };
const drawLabels = (t = view): void => {
view = t;
labels.update(anchors, t, { width, height });
};
net.enableZoom([0.1, 8], drawLabels); // scroll to zoom, drag to pan; labels follow the transform
let token = 0;
let disposed = false;
const load = async (text: string, filename: string): Promise<void> => {
loaded = { text, name: filename };
const mine = ++token;
const { nodeCount, source, target, weight, labels: names, directed } = parseNetwork(text, filename);
anchors = [];
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",
})
.layout({ backend: "worker", iterations: 300 });
await net.whenSettled();
if (disposed || mine !== token) return; // a newer load (or teardown) superseded this one
anchors = names.map((label, i): LabelAnchor => ({
id: i,
refX: graph.positions[i * 2]!,
refY: graph.positions[i * 2 + 1]!,
text: label,
offset: [7, -4],
}));
drawLabels();
};
host.appendChild(makeControls(load));
void load(loaded.text, loaded.name);
return {
engine: net,
dispose: () => {
disposed = true;
labels.destroy();
},
};
};

Interactive node dragging — grab a node, pin it to the cursor, and let the simulation reheat for a moment while its neighbours readjust — lands together with picking in a later step (it shares the engine’s hit-testing), so it can resolve nodes correctly through level-of-detail at scale.