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.
Large-scale layout in a Web Worker
Section titled “Large-scale layout in a Web Worker”This example builds an LFR benchmark network — power-law node degrees and power-law community
sizes with a tunable mixing parameter, the standard benchmark for community detection — and lets
d3gl’s in-library force layout (Barnes-Hut) place the nodes; no coordinates are supplied. The
layout is seeded by multilevel coarsening: the graph is collapsed into a hierarchy of
ever-smaller graphs (each node merged with a neighbour, hubs and their leaves absorbed together so
power-law graphs still shrink geometrically), the tiny coarsest one is laid out first, and positions
are projected back down and refined — far faster convergence and fewer tangles than a cold start.
With layout({ backend: "worker" }) the entire solve runs in a Web Worker and streams positions
back, so the layout converges progressively on screen while the main thread stays responsive —
pan and zoom while it settles. (On a cross-origin-isolated page positions are shared zero-copy via
SharedArrayBuffer; otherwise they are posted as snapshots.) The Nodes slider scales from 10 to
1,000,000; Seeding compares multilevel against a cold start; Size switches uniform vs
degree-weighted radius; Sizing switches world vs screen units; and LOD / Declutter /
Edges control the level-of-detail cut (below). Drag to pan, scroll to zoom.
import { network, buildGraph, 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 }); }, };};export interface GeneratedNetwork { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge weight. All 1 unless `weighted` is set, then a power-law draw (most light, a few heavy). */ weight: Float32Array; /** Node → community id (planted ground truth; handy for colouring or validation). */ community: Int32Array;}
export interface LFROptions { /** Mixing: fraction of each node's edges that go *outside* its community (0..1). Default 0.1. */ mu?: number; /** Target mean degree. Default 12. */ avgDegree?: number; /** Max degree. Default ≈ 3·√n. */ maxDegree?: number; /** Degree power-law exponent τ₁. Default 2.5. */ degreeExponent?: number; /** Community-size power-law exponent τ₂. Default 1.5. */ communityExponent?: number; /** Smallest community. Default 20. */ minCommunity?: number; /** Largest community. Default ≈ n/8. */ maxCommunity?: number; /** Assign power-law edge weights (so links — and accumulated LOD super-edges — vary). Default false. */ weighted?: boolean; /** Edge-weight power-law exponent (when `weighted`). Default 2.2 (most weights near 1, a few large). */ weightExponent?: number; /** Max edge weight (when `weighted`). Default 12. */ maxWeight?: number; /** PRNG seed (deterministic output across re-renders). Default 1. */ seed?: number;}
/** Tiny deterministic PRNG (mulberry32) — keeps the generated network stable across re-renders. */function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Sample an integer in [min,max] from a power law p(x) ∝ x^(−exponent) via inverse-CDF. */function powerLaw(rand: () => number, min: number, max: number, exponent: number): number { if (max <= min) return min; const e = 1 - exponent; const lo = Math.pow(min, e); const hi = Math.pow(max, e); const x = Math.round(Math.pow(lo + rand() * (hi - lo), 1 / e)); return x < min ? min : x > max ? max : x;}
/** Fisher–Yates shuffle of `arr[from..to)` in place. */function shuffle(arr: Uint32Array, from: number, to: number, rand: () => number): void { for (let i = to - 1; i > from; i--) { const j = from + Math.floor(rand() * (i - from + 1)); const tmp = arr[i]!; arr[i] = arr[j]!; arr[j] = tmp; }}
/** * A clean-room **LFR-style benchmark network** (Lancichinetti–Fortunato–Radicchi): power-law node * degrees and power-law community sizes, with a mixing parameter `mu` controlling the fraction of * inter-community edges. Edges are wired with a configuration model — intra-community stubs paired * within each community, inter-community stubs paired globally across communities — so the planted * communities are real, nested structure the force layout resolves and LOD coarsening can recover. * * This is an approximation tuned for visualization (it allows the occasional multi-edge and skips * the reference algorithm's exact degree-sequence rewiring), not a bit-faithful reproduction of the * published LFR generator. Pass `weighted` for power-law edge weights, so links vary in width/colour * and an LOD super-edge reads as the (summed) weight of the edges it subsumes. */export function generateLFR(n: number, opts: LFROptions = {}): GeneratedNetwork { const mu = opts.mu ?? 0.1; const avgDegree = opts.avgDegree ?? 12; const degExp = opts.degreeExponent ?? 2.5; const comExp = opts.communityExponent ?? 1.5; const maxDeg = Math.min(n - 1, opts.maxDegree ?? Math.round(3 * Math.sqrt(n))); // Mean of a bounded power law ≈ minDeg·(γ−1)/(γ−2); invert to hit the target average degree. const minDeg = Math.max(2, Math.round((avgDegree * (degExp - 2)) / (degExp - 1))); const minCom = Math.max(2, Math.min(opts.minCommunity ?? 20, n)); const maxCom = Math.max(minCom, Math.min(opts.maxCommunity ?? Math.round(n / 8), n)); const rand = mulberry32(opts.seed ?? 1);
// 1) Planted communities as contiguous node ranges with power-law sizes covering all n nodes. const community = new Int32Array(n); const comStart: number[] = []; let assigned = 0; for (let c = 0; assigned < n; c++) { let size = powerLaw(rand, minCom, maxCom, comExp); if (assigned + size > n) size = n - assigned; comStart.push(assigned); community.fill(c, assigned, assigned + size); assigned += size; } comStart.push(n); // sentinel end
// 2) Per-node degree (power law), split into intra/inter targets. Intra is capped at the // community size so a small community can satisfy it without excessive multi-edges. const intra = new Uint32Array(n); const inter = new Uint32Array(n); let sumIntra = 0; let sumInter = 0; for (let c = 0; c + 1 < comStart.length; c++) { const start = comStart[c]!; const end = comStart[c + 1]!; const cap = end - start - 1; // most intra-neighbours available for (let u = start; u < end; u++) { const deg = powerLaw(rand, minDeg, maxDeg, degExp); let ai = Math.round((1 - mu) * deg); if (ai > cap) ai = cap < 0 ? 0 : cap; intra[u] = ai; inter[u] = deg - ai; sumIntra += ai; sumInter += inter[u]!; } }
// Edge buffers, sized at the stub-pair upper bound (each undirected edge consumes two stubs). const cap = Math.ceil(sumIntra / 2) + Math.ceil(sumInter / 2) + 1; const source = new Uint32Array(cap); const target = new Uint32Array(cap); let ne = 0;
// 3) Intra-community edges: pair shuffled intra-stubs within each community (configuration model). const intraStubs = new Uint32Array(sumIntra); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < intra[u]!; k++) intraStubs[p++] = u; } let seg = 0; // running start of the current community's stub segment (stubs are in node-id order) for (let c = 0; c + 1 < comStart.length; c++) { let segEnd = seg; for (let u = comStart[c]!; u < comStart[c + 1]!; u++) segEnd += intra[u]!; shuffle(intraStubs, seg, segEnd, rand); for (let i = seg; i + 1 < segEnd; i += 2) { const a = intraStubs[i]!; const b = intraStubs[i + 1]!; if (a !== b) { source[ne] = a; target[ne] = b; ne++; } } seg = segEnd; }
// 4) Inter-community edges: pair shuffled inter-stubs globally, skipping same-community/self pairs. const interStubs = new Uint32Array(sumInter); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < inter[u]!; k++) interStubs[p++] = u; } shuffle(interStubs, 0, sumInter, rand); for (let i = 0; i + 1 < sumInter; i += 2) { const a = interStubs[i]!; const b = interStubs[i + 1]!; if (a !== b && community[a] !== community[b]) { source[ne] = a; target[ne] = b; ne++; } }
// 5) Edge weights: a power-law draw per edge (most ≈ 1, a few heavy) so links vary — and an LOD // super-edge, summing the weights it subsumes, reads as genuinely thicker/heavier. Unweighted ⇒ 1. const weightExp = opts.weightExponent ?? 2.2; const maxWeight = opts.maxWeight ?? 12; const weight = new Float32Array(ne); for (let e = 0; e < ne; e++) weight[e] = opts.weighted ? powerLaw(rand, 1, maxWeight, weightExp) : 1;
return { nodeCount: n, source: source.subarray(0, ne), target: target.subarray(0, ne), weight, community };}Sizing nodes by degree (or any metric)
Section titled “Sizing nodes by degree (or any metric)”nodeRadius accepts more than a constant — it takes a d3 scale so node size can encode a metric.
The radius is a per-instance GPU attribute, so varying it per node is free at draw time (it is
resolved once per style() call, never per frame), all the way to millions of nodes.
import { scaleSqrt } from "d3-scale";
// A function receives the node's degree — a bare d3 scale fits directly.// scaleSqrt makes the *area* proportional to degree, the honest mapping for circles.net.style({ nodeRadius: scaleSqrt().domain([1, maxDegree]).range([2, 20]) });
// Or size by a chosen metric through any scale with { by, scale }:net.style({ nodeRadius: { by: "strength", scale: scaleSqrt().range([2, 20]) } });nodeRadius can be:
- a
number— one constant radius for every node (the default is 4); - a function
(degree, index, graph) => radius— the node’s degree is the first argument, so a bare d3 scale works;index/graphare there for anything custom; { by, scale }— feed a metric through any scale, wherebyis"degree"(neighbour count),"strength"(weighted degree — summed incident edge weights),"flow"(an app-provided per-node value, see below), or your own(index, graph) => valueaccessor;- a
Float32Arrayof per-node radii you computed yourself.
degree and strength are derived from the edge list automatically. Flow is a model quantity
(e.g. an Infomap visit rate) that d3gl does not invent — supply it as buildGraph({ …, nodeFlow })
to make { by: "flow", scale } available.
Flow borders and half-arrows
Section titled “Flow borders and half-arrows”The “map of networks” glyph style — independent of LOD, these are plain rendering features. A node or
module reads as a disc whose fill + size is its total flow and whose border ring encodes its
enter/exit flow (the flow crossing its boundary). Directed links draw as half-arrows
(linkStyle: "half-arrow"): each is one filled shape that pinches to the source node’s centre and
lands its barbed tip on the target node’s boundary, bowed around a shared centre curve so a
reciprocal A→B / B→A pair nests instead of overlapping. This example reproduces mapequation’s
network-rendering example.svg exactly — switch
the backend (WebGL / Canvas / SVG): the render is equivalent across all three.
Every visual channel maps a flow quantity through a d3 scale, so the map reads quantitatively:
nodeRadius+nodeFill— total node flow → disc size and fill colour (nodeRadius: { by: "flow", scale },nodeFilla per-node colour scale).flowBorder—flowis a per-node value (yourFloat32Arrayof enter/exit flow, or a built-in metric like"strength");scalemaps it to ring width andcolormay be a per-node accessor so the ring colour encodes it too. d3gl doesn’t compute enter/exit flow — you supply it (Infomap gives it).linkWidth+linkStroke— link flow (the per-edge weight) → half-arrow width and colour. Each takes a constant, a(weight) => …scale (a bare d3 colour scale fitslinkStroke—scaleSqrt().range([light, dark])interpolates RGBA, alpha included), or{ by, scale }likenodeRadius(byis"weight"/"flow"). A super-edge applies the same scale to its accumulated subsumed weight, so a heavier super-edge reads as thicker and darker. The arrowhead is part of the shape, so it always takes the link’s colour — there is no separate arrow fill. Keep range minimums ≥ 1 so nothing vanishes at low flow.linkBend— for half-arrows, an absolute world-unit ⟂ offset (the reference’sbend); the bow side is derived from the link direction so reciprocal links nest automatically.
import { scaleLinear } from "d3-scale";
net.data(graph).style({ directed: true, linkStyle: "half-arrow", nodeRadius: { by: "flow", scale: scaleLinear().domain([lo, hi]).range([20, 30]) }, // size ∝ flow nodeFill: (i) => fillColor(graph.flow[i]), // a per-node colour scale on flow flowBorder: { flow: enterExitFlow, scale: borderWidth, color: (v) => borderColor(v) }, // ring ∝ enter/exit linkBend: 30, linkWidth: scaleLinear().domain([lo, hi]).range([7, 13]), // width ∝ link flow linkStroke: scaleLinear().domain([lo, hi]).range(["#71B2D7", "#418EC7"]), // colour ∝ link flow});The half-arrow renders fully instanced on the WebGL lane (the vertex shader places the foot, the
shared centre curve and the barbed head per instance) and traces the identical reference path for
SVG/Canvas export, so publication output matches the screen. The plain linkStyle: "line" (the
default, see the large-scale example) keeps stroked lines + separate arrowheads for graphs with many
edges.
The Sizing toggle switches sizeMode. In screen mode the link decorations (width, arrow tip,
bend, node radii) stay a constant pixel size as you zoom while the nodes still move — the navigation
register LOD wants. Screen-mode half-arrows are recomputed per frame on the WebGL lane; for SVG/Canvas
the shape is baked into world coords at the current zoom (a retained backend can’t recompute a shape
spanning two moving anchors per frame), refreshed automatically on backend switch and at the end of a
pan/zoom — call net.syncScreenGeometry() to refit on demand (e.g. before a programmatic export).
import { network, buildGraph } from "@mapequation/d3gl/network";import { scaleLinear } from "d3-scale";import type { ImperativeSetup } from "../types.js";import { buildReplica, REPLICA_BOUNDS, NODE_FILL_RANGE, NODE_BORDER_RANGE, LINK_RANGE } from "./data.js";
const BENDS = [0, 15, 30, 45, 60]; // the Bend slider's stops (absolute world-unit ⟂ offset)
/** * The **flow-border + half-arrow** glyph style (the `network-rendering` look), shown on the reference * two-node network **without LOD** — so it's clear these are plain rendering features. The planted * **flow** model drives every channel through a d3 scale: node total flow → fill colour + radius, * enter/exit flow → ring width + colour, link flow → half-arrow width + colour. With * `linkStyle: "half-arrow"` each directed link is one filled shape that pinches to the source centre * and lands its barbed tip on the target node's boundary; a reciprocal pair nests around a shared * centre curve. Switch the **backend** (WebGL / Canvas / SVG) — the render is equivalent — export, go * fullscreen, scroll to zoom, and drag the **Bend** slider. */export const setup: ImperativeSetup = (host, { width, height, backend }) => { const net = network(host, { width, height, backend }); const { minX, maxX, minY, maxY } = REPLICA_BOUNDS; const k = Math.min(width / (maxX - minX), height / (maxY - minY)) * 0.95; net.setTransform({ k, x: width / 2 - ((minX + maxX) / 2) * k, y: height / 2 - ((minY + maxY) / 2) * k }); net.enableZoom([k * 0.3, k * 12]);
const g = buildReplica(); const graph = buildGraph({ nodeCount: g.nodeCount, source: g.source, target: g.target, weight: g.weight, directed: true, nodeFlow: g.flow }); net.data(graph).layout({ backend: "positions", positions: g.positions });
// d3 scales over the planted flow, with the reference's domains & ranges (range minimums ≥ 1 so // nothing vanishes at low flow). Colour ranges interpolate in RGB, as in the reference. const fillColor = scaleLinear<string>().domain([0.4, 0.6]).range(NODE_FILL_RANGE); const radius = scaleLinear().domain([0.4, 0.6]).range([20, 30]); const borderColor = scaleLinear<string>().domain([0.2, 0.3]).range(NODE_BORDER_RANGE); const borderWidth = scaleLinear().domain([0.2, 0.3]).range([3, 6]); const linkColor = scaleLinear<string>().domain([0.3, 0.5]).range(LINK_RANGE); const linkWidth = scaleLinear().domain([0.3, 0.5]).range([7, 13]);
return { engine: net, render: (options) => { const bend = BENDS[(options.bend as number) ?? 2] ?? 30; // World (default): radii/widths/bend are world units and scale with zoom (the reference is a fixed // publication layout). Screen: they're constant pixels as you zoom, while the nodes still move // apart/together — the navigation register LOD wants. (Screen-mode half-arrows are WebGL-only.) const sizeMode = options.sizing === "Screen" ? "screen" : "world"; net.style({ directed: true, linkStyle: "half-arrow", sizeMode, nodeRadius: { by: "flow", scale: radius }, // radius ∝ total flow nodeFill: (i) => fillColor(graph.flow![i]!), // fill ∝ total flow flowBorder: { flow: g.outFlow, scale: borderWidth, color: (v) => borderColor(v) }, // ring ∝ enter/exit flow linkBend: bend, linkWidth, // half-arrow width ∝ link flow linkStroke: linkColor, // half-arrow colour ∝ link flow }); }, };};/** * The exact `mapequation/network-rendering` `example.svg` network — the "map of networks" glyph style, * **without** LOD. Two directed nodes joined by reciprocal links, with a planted **flow** model: * * - node **flow** (total) → fill colour + radius * - node **enter/exit flow** (`outFlow`) → flow-border ring width + colour * - link **flow** (the per-edge weight) → half-arrow width + colour * * The numbers below are the reference's: nodes at (100,100)/(300,180), the same flow values, so the * d3 scales in `draw.ts` reproduce the reference's radii (20–30), border widths (3–6) and palette. */
/** Node fill colour range (low→high flow): the reference's two oranges. */export const NODE_FILL_RANGE: [string, string] = ["#EF7518", "#D75908"];/** Flow-border ring colour range (low→high enter/exit flow): the reference's light oranges. */export const NODE_BORDER_RANGE: [string, string] = ["#FFAE38", "#f9a327"];/** Half-arrow link colour range (low→high link flow): the reference's two blues. */export const LINK_RANGE: [string, string] = ["#71B2D7", "#418EC7"];
export interface ReplicaGraph { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge link flow → half-arrow width + colour. */ weight: Float32Array; positions: Float32Array; /** Per-node total flow → fill colour + radius. */ flow: Float32Array; /** Per-node enter/exit (boundary) flow → ring width + colour. */ outFlow: Float32Array;}
/** The two-node reference network: node 0 carries more flow than node 1; the 0→1 link is heavier. */export function buildReplica(): ReplicaGraph { return { nodeCount: 2, source: Uint32Array.from([0, 1]), target: Uint32Array.from([1, 0]), weight: Float32Array.from([0.5, 0.3]), // link flow: 0→1 heavier than 1→0 positions: Float32Array.from([100, 100, 300, 180]), flow: Float32Array.from([0.6, 0.4]), // node total flow → radius 30 / 20 outFlow: Float32Array.from([0.3, 0.2]), // enter/exit flow → border 6 / 3 };}
/** World bounds of the reference layout (its 400×300 frame), for fitting the initial view. */export const REPLICA_BOUNDS = { minX: 40, maxX: 360, minY: 50, maxY: 230 };Level of detail (LOD)
Section titled “Level of detail (LOD)”Drawing every node and edge stops scaling long before 10M. net.lod({ … }) turns on an adaptive
hierarchy cut: d3gl keeps the multilevel coarsening tree around after layout and, each frame, walks
it top-down for the current view — a dense region collapses to a single aggregate glyph, and
expands into its members only once its on-screen footprint grows past a threshold as you zoom in. Per
frame the engine touches the visible frontier, not the whole graph.
net .style({ sizeMode: "screen" }) // constant-pixel glyphs — the natural register for navigating .lod({ expandPx: 48, // expand an aggregate once its on-screen footprint reaches ~48px aggregateFill: "#7f97c8", maxAggregateRadius: 26, // cap the aggregate glyph size (pixels, in screen sizeMode) declutter: true, // thin overlapping glyphs by importance (default: the node-size metric) superEdges: true, // summarise connectivity between aggregates crossLevelEdges: true, // also link a visible leaf to a still-collapsed module (opt-in, #139) crossFade: 0.3, // ease aggregate↔children across the expand threshold (opt-in, #133) });net.lod(false); // back to drawing every elementWhat it composes:
- Aggregates carry their subtree’s centroid and a radius (area-additive
√Σr², or — when sizing by an additive metric likeflow— the same node scale applied to the summed child value, so a module reads as its total flow). Aggregate identity is stable, so they don’t pop as you pan. - Declutter drops glyphs that would overlap a kept one, keeping the highest-importance member —
zoom-dependent, so more resolve as you zoom in. Importance is summed up the tree (a module’s is its
members’ total) and set by
style({ importance })— a metric ("degree"/"strength"/"flow"), an accessor, aFloat32Array, or"order". It defaults to the node-size metric, so the biggest glyph wins an overlap. - Super-edges draw connectivity between visible nodes (a leaf↔leaf graph edge, or an
aggregate↔aggregate coarse edge); a visible node keeps its edges even when a neighbour is
off-screen. By default they connect same-level pairs only, so when you expand one region its leaves
lose their links to the still-collapsed regions;
crossLevelEdges: truerestores them by projecting the off-frontier endpoint to its nearest visible ancestor (opt-in — zero added cost when off). crossFadesmooths level transitions: over a band around the expand threshold (a fraction ofexpandPx, e.g.0.3), the aggregate and its children draw together, the parent easing out as the children ease in — so a split/merge reads smoothly instead of popping. Opt-in; off (and free) by default.sizeMode—"world"(glyphs scale with zoom) or"screen"(constant pixels, so nodes stay visible when zoomed out). LOD pairs naturally with"screen".
The geometry tracks the layout as it converges (LOD helps during the solve, not just after), while
panning and zooming only re-run the cheap cut. On the WebGL lane the cut re-runs live every frame.
On the Canvas/SVG backends the same frontier draws as retained geometry — so toSVG() exports a
level-of-detail map — but a retained backend can’t re-tessellate per frame, so there the frontier is
static during a gesture and re-cuts when it ends (call syncScreenGeometry() to re-cut at a chosen
zoom before a programmatic export).
With the worker backend, enable LOD before layout({ backend: "worker" }) and the worker builds
the hierarchy itself — it reuses the coarsening it already computes for the multilevel seed, then
streams the tree once plus its aggregate geometry each frame. The main thread then never coarsens or
runs the per-frame O(N) geometry pass; it only fills the style geometry once and runs the
on-screen-bounded cut. That keeps the main thread free as the network scales toward millions of nodes.
net .lod({ expandPx: 48, maxAggregateRadius: 26 }) // configure LOD first… .layout({ backend: "worker", iterations }); // …so the worker builds + streams the treeMaps of networks: a provided module hierarchy
Section titled “Maps of networks: a provided module hierarchy”When the app already has a module hierarchy — e.g. an Infomap clustering — pass it as the LOD source instead of letting d3gl coarsen structurally. d3gl does not cluster; the tree is computed app-side and handed in, just like externally-provided positions. Modules then expand → sub-modules → leaves on zoom through the same adaptive cut.
Pass Infomap’s JSON nodes array straight through: each record’s id is the node index (aligned with
buildGraph) and path is its 1-based module chain ([2, 1, 3] = top module 2 → sub-module 1 → the
node ranked 3).
import infomapResult from "./network.json"; // Infomap JSON: { nodes: [{ id, path, flow, … }], … }
net .data(graph) .style({ sizeMode: "screen" }) .lod({ modules: infomapResult.nodes, expandPx: 48 }) .layout({ backend: "positions", positions });
net.lodSource; // "modules"The provided hierarchy takes priority over structural coarsening; everything else about the cut
(declutter, sizeMode, the on-screen-bounded per-frame work) is unchanged. Under LOD a module’s
flow border sums its members’ enter/exit flow, and its bent-half-arrow super-edges size by the
accumulated weight of the edges they subsume — the same flow-border and bent-link glyphs as above,
driven by the cut.
Modular level of detail
Section titled “Modular level of detail”When the LOD hierarchy comes from a provided module tree (rather than structural coarsening), the
adaptive cut becomes modular-aware: nodes aggregate into their parent module as you zoom out, and
expand back to sub-modules and leaves as you zoom in. This example builds an undirected Sierpinski
gasket whose recursive subdivision is the module hierarchy (an Infomap-style path per node), fed
to net.lod({ modules }).
Each node is coloured by its top-level module through a categorical palette, and nodeFill accepts
a per-node accessor — so a module’s aggregate glyph and all of its leaves share one colour, and you can
read the planted hierarchy at every zoom level. Links are simple bent lines (linkBend), nodes get
a 1px white outline (nodeBorder). Scroll to zoom and watch the three top modules expand → sub-modules
→ leaf triangles; the Depth slider grows the gasket from 27 to 2,187 nodes, and LOD off draws
every node.
import { network, buildGraph, moduleColors } 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 }); }, };};/** * An **undirected Sierpinski gasket** with a planted module hierarchy — a clean test bed for * **modular-aware LOD**: nodes aggregate into their parent module as you zoom out, and a module's * glyph (and all its leaves) share one categorical colour. * * A recursive 3-ary gasket: each smallest triangle is a 3-node community joined to its siblings by * sparse corner bridges, with distinct nodes (no shared corners), so every node has one unambiguous * module `path`. The subdivision tree *is* the module hierarchy, emitted in Infomap's JSON `nodes` * shape and fed to `net.lod({ modules })`. depth D → 3^D leaf triangles → 3^(D+1) nodes. */
export interface SierpinskiGraph { nodeCount: number; source: Uint32Array; target: Uint32Array; weight: Float32Array; /** Gasket coordinates [x, y, …], apex up. */ positions: Float32Array; /** Infomap JSON `nodes`: { id, path } per node — the provided module hierarchy. `path[0]` is the top module. */ modules: { id: number; path: number[] }[];}
type Pt = readonly [number, number];const mid = (a: Pt, b: Pt): Pt => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const SCALE = 1000;const HEIGHT = (SCALE * Math.sqrt(3)) / 2;const SHRINK = 0.22; // pull leaf nodes off the shared corners so bridges have length// All edges carry weight 1 (an unweighted network). Inter-module bridges don't aggregate in this// gasket — each module pair is joined by exactly one bridge — so super-edges stay weight 1 and links// are uniform; zooming in just reveals more (still unit-weight) bridges.const INTRA = 1;const BRIDGE = 1;
/** Generate the undirected Sierpinski gasket at the given `depth` (≥ 1). Deterministic. */export function generateSierpinski(depth: number): SierpinskiGraph { const positions: number[] = []; const paths: number[][] = []; const idByPath = new Map<string, number>(); const key = (p: number[]): string => p.join(":");
const emitLeaf = (prefix: number[], q0: Pt, q1: Pt, q2: Pt): void => { const gx = (q0[0] + q1[0] + q2[0]) / 3; const gy = (q0[1] + q1[1] + q2[1]) / 3; [q0, q1, q2].forEach((c, r) => { const id = paths.length; paths.push([...prefix, r + 1]); idByPath.set(key(paths[id]!), id); // Apex-up: flip y within the gasket height. positions.push(c[0] + (gx - c[0]) * SHRINK, HEIGHT - (c[1] + (gy - c[1]) * SHRINK)); }); }; const subdivide = (prefix: number[], p0: Pt, p1: Pt, p2: Pt): void => { if (prefix.length === depth) return emitLeaf(prefix, p0, p1, p2); subdivide([...prefix, 1], p0, mid(p0, p1), mid(p0, p2)); subdivide([...prefix, 2], mid(p0, p1), p1, mid(p1, p2)); subdivide([...prefix, 3], mid(p0, p2), mid(p1, p2), p2); }; subdivide([], [0, 0], [SCALE, 0], [SCALE / 2, HEIGHT]);
const nodeCount = paths.length; const cornerId = (prefix: number[], c: number): number => { const p = [...prefix]; while (p.length < depth) p.push(c); p.push(c); return idByPath.get(key(p))!; };
const src: number[] = []; const tgt: number[] = []; const w: number[] = []; const edge = (a: number, b: number, weight: number): void => void (src.push(a), tgt.push(b), w.push(weight));
for (let leaf = 0; leaf < nodeCount; leaf += 3) { edge(leaf, leaf + 1, INTRA); edge(leaf + 1, leaf + 2, INTRA); edge(leaf, leaf + 2, INTRA); } const addBridges = (prefix: number[]): void => { if (prefix.length === depth) return; const c1 = [...prefix, 1]; const c2 = [...prefix, 2]; const c3 = [...prefix, 3]; edge(cornerId(c1, 2), cornerId(c2, 1), BRIDGE); edge(cornerId(c2, 3), cornerId(c3, 2), BRIDGE); edge(cornerId(c1, 3), cornerId(c3, 1), BRIDGE); addBridges(c1), addBridges(c2), addBridges(c3); }; addBridges([]);
return { nodeCount, source: Uint32Array.from(src), target: Uint32Array.from(tgt), weight: Float32Array.from(w), positions: Float32Array.from(positions), modules: paths.map((path, id) => ({ id, path })), };}
/** The gasket's fixed world bounds (depth-independent) — for fitting the initial view. */export const SIERPINSKI_BOUNDS = { minX: 0, maxX: SCALE, minY: 0, maxY: HEIGHT };A directed map of modules
Section titled “A directed map of modules”This brings it together: an LFR planted-partition network rendered as a directed map of modules.
Each undirected benchmark edge is split into a reciprocal pair, and the random-walk flow is computed
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.
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 }); } }, };};import fixture from "./modular-map.json";
/** * The baked **LFR modular map** fixture (see `generate.ts`): a directed planted-partition network with * authoritative random-walk **flow** (matched to Infomap). Each undirected LFR edge was split into a * reciprocal a→b / b→a pair, so a half-arrow pair carries genuinely different flow each way. * * - `linkFlow` — per directed edge → half-arrow width + colour (stored as the graph's edge weight, so * LOD super-edges accumulate flow automatically). * - `nodeFlow` — per-node visit rate → node radius (and a flow read-out). * - `enterExit` — per-node flow crossing its module boundary → the flow-border ring. * - `community` — the planted partition → a one-level module hierarchy (`path = [community + 1]`). */export interface ModularMapData { nodeCount: number; communities: number; source: Uint32Array; target: Uint32Array; linkFlow: Float32Array; nodeFlow: Float32Array; enterExit: Float32Array; community: Int32Array; /** Infomap-shape module records for `lod({ modules })`: one level, 1-based community as the path. */ modulePaths: { id: number; path: number[] }[];}
export function loadModularMap(): ModularMapData { const community = Int32Array.from(fixture.community); // Infomap path shape: the module level(s) then the node's rank within its module. One level here, so // path = [community, rank] — the community is the enclosing module (its colour + LOD aggregate) and // the per-community rank distinguishes the leaves. (A bare [community] would read as "rank under the // root" — no module at all.) const rank = new Map<number, number>(); const modulePaths = Array.from(community, (c, id) => { const r = (rank.get(c) ?? 0) + 1; rank.set(c, r); return { id, path: [c + 1, r] }; }); return { nodeCount: fixture.nodeCount, communities: fixture.communities, source: Uint32Array.from(fixture.source), target: Uint32Array.from(fixture.target), linkFlow: Float32Array.from(fixture.linkFlow), nodeFlow: Float32Array.from(fixture.nodeFlow), enterExit: Float32Array.from(fixture.enterExit), community, modulePaths, };}/** * Offline generator for the modular-map fixture (run with `node generate.ts` — Node strips the types). * * Builds a deterministic LFR planted-partition network, makes it **directed** by splitting every edge * into a reciprocal a→b / b→a pair (so a half-arrow pair has genuinely different flow each way), * computes the random-walk **flow** with {@link randomWalkFlow} (cross-checked against Infomap), and * derives per-link flow + per-node enter/exit (boundary) flow. The planted communities become a * one-level module hierarchy. The result is written to `modular-map.json`, which the example imports — * so the browser ships static data, no Infomap/WASM at runtime. */import { writeFileSync } from "node:fs";import { fileURLToPath } from "node:url";import { dirname, join } from "node:path";import { generateLFR } from "../network/data.ts";import { randomWalkFlow } from "../../../../packages/d3gl/src/network/flow.ts";
const N = 500;const lfr = generateLFR(N, { mu: 0.1, avgDegree: 10, minCommunity: 18, seed: 42 });
// Deterministic PRNG (mulberry32) for reproducible asymmetric edge weights.function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}const rng = mulberry32(7);
// Directed: each undirected edge → a reciprocal pair with **asymmetric** weights, so the two// half-arrows of a pair carry genuinely different flow (a→b ≠ b→a) and the map reads as directed.const src: number[] = [];const tgt: number[] = [];const w: number[] = [];const draw = () => 0.3 + 4 * rng() * rng(); // skewed [0.3, ~4.3): most light, some heavyfor (let e = 0; e < lfr.source.length; e++) { const a = lfr.source[e]!; const b = lfr.target[e]!; src.push(a, b); tgt.push(b, a); w.push(draw(), draw());}const source = Uint32Array.from(src);const target = Uint32Array.from(tgt);const weight = Float32Array.from(w);
// Authoritative random-walk flow (Infomap convention), then per-link + per-node boundary flow.const { nodeFlow, linkFlow } = randomWalkFlow({ nodeCount: N, source, target, weight }, { tau: 0.15 });const enterExit = new Float64Array(N);for (let e = 0; e < source.length; e++) { const a = source[e]!; const b = target[e]!; if (lfr.community[a] !== lfr.community[b]) { enterExit[a] += linkFlow[e]!; // exit flow from a enterExit[b] += linkFlow[e]!; // enter flow to b }}
const communities = new Set(Array.from(lfr.community)).size;const round = (x: number) => Number(x.toPrecision(7));const fixture = { nodeCount: N, communities, source: Array.from(source), target: Array.from(target), linkFlow: Array.from(linkFlow, round), // per-directed-edge flow → half-arrow width + colour nodeFlow: Array.from(nodeFlow, round), // per-node visit rate → radius + fill enterExit: Array.from(enterExit, round), // per-node boundary flow → ring community: Array.from(lfr.community), // planted partition → one-level module path};
const out = join(dirname(fileURLToPath(import.meta.url)), "modular-map.json");writeFileSync(out, JSON.stringify(fixture));console.log(`wrote ${out}: ${N} nodes, ${source.length} directed links, ${communities} communities`);export interface GeneratedNetwork { nodeCount: number; source: Uint32Array; target: Uint32Array; /** Per-edge weight. All 1 unless `weighted` is set, then a power-law draw (most light, a few heavy). */ weight: Float32Array; /** Node → community id (planted ground truth; handy for colouring or validation). */ community: Int32Array;}
export interface LFROptions { /** Mixing: fraction of each node's edges that go *outside* its community (0..1). Default 0.1. */ mu?: number; /** Target mean degree. Default 12. */ avgDegree?: number; /** Max degree. Default ≈ 3·√n. */ maxDegree?: number; /** Degree power-law exponent τ₁. Default 2.5. */ degreeExponent?: number; /** Community-size power-law exponent τ₂. Default 1.5. */ communityExponent?: number; /** Smallest community. Default 20. */ minCommunity?: number; /** Largest community. Default ≈ n/8. */ maxCommunity?: number; /** Assign power-law edge weights (so links — and accumulated LOD super-edges — vary). Default false. */ weighted?: boolean; /** Edge-weight power-law exponent (when `weighted`). Default 2.2 (most weights near 1, a few large). */ weightExponent?: number; /** Max edge weight (when `weighted`). Default 12. */ maxWeight?: number; /** PRNG seed (deterministic output across re-renders). Default 1. */ seed?: number;}
/** Tiny deterministic PRNG (mulberry32) — keeps the generated network stable across re-renders. */function mulberry32(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Sample an integer in [min,max] from a power law p(x) ∝ x^(−exponent) via inverse-CDF. */function powerLaw(rand: () => number, min: number, max: number, exponent: number): number { if (max <= min) return min; const e = 1 - exponent; const lo = Math.pow(min, e); const hi = Math.pow(max, e); const x = Math.round(Math.pow(lo + rand() * (hi - lo), 1 / e)); return x < min ? min : x > max ? max : x;}
/** Fisher–Yates shuffle of `arr[from..to)` in place. */function shuffle(arr: Uint32Array, from: number, to: number, rand: () => number): void { for (let i = to - 1; i > from; i--) { const j = from + Math.floor(rand() * (i - from + 1)); const tmp = arr[i]!; arr[i] = arr[j]!; arr[j] = tmp; }}
/** * A clean-room **LFR-style benchmark network** (Lancichinetti–Fortunato–Radicchi): power-law node * degrees and power-law community sizes, with a mixing parameter `mu` controlling the fraction of * inter-community edges. Edges are wired with a configuration model — intra-community stubs paired * within each community, inter-community stubs paired globally across communities — so the planted * communities are real, nested structure the force layout resolves and LOD coarsening can recover. * * This is an approximation tuned for visualization (it allows the occasional multi-edge and skips * the reference algorithm's exact degree-sequence rewiring), not a bit-faithful reproduction of the * published LFR generator. Pass `weighted` for power-law edge weights, so links vary in width/colour * and an LOD super-edge reads as the (summed) weight of the edges it subsumes. */export function generateLFR(n: number, opts: LFROptions = {}): GeneratedNetwork { const mu = opts.mu ?? 0.1; const avgDegree = opts.avgDegree ?? 12; const degExp = opts.degreeExponent ?? 2.5; const comExp = opts.communityExponent ?? 1.5; const maxDeg = Math.min(n - 1, opts.maxDegree ?? Math.round(3 * Math.sqrt(n))); // Mean of a bounded power law ≈ minDeg·(γ−1)/(γ−2); invert to hit the target average degree. const minDeg = Math.max(2, Math.round((avgDegree * (degExp - 2)) / (degExp - 1))); const minCom = Math.max(2, Math.min(opts.minCommunity ?? 20, n)); const maxCom = Math.max(minCom, Math.min(opts.maxCommunity ?? Math.round(n / 8), n)); const rand = mulberry32(opts.seed ?? 1);
// 1) Planted communities as contiguous node ranges with power-law sizes covering all n nodes. const community = new Int32Array(n); const comStart: number[] = []; let assigned = 0; for (let c = 0; assigned < n; c++) { let size = powerLaw(rand, minCom, maxCom, comExp); if (assigned + size > n) size = n - assigned; comStart.push(assigned); community.fill(c, assigned, assigned + size); assigned += size; } comStart.push(n); // sentinel end
// 2) Per-node degree (power law), split into intra/inter targets. Intra is capped at the // community size so a small community can satisfy it without excessive multi-edges. const intra = new Uint32Array(n); const inter = new Uint32Array(n); let sumIntra = 0; let sumInter = 0; for (let c = 0; c + 1 < comStart.length; c++) { const start = comStart[c]!; const end = comStart[c + 1]!; const cap = end - start - 1; // most intra-neighbours available for (let u = start; u < end; u++) { const deg = powerLaw(rand, minDeg, maxDeg, degExp); let ai = Math.round((1 - mu) * deg); if (ai > cap) ai = cap < 0 ? 0 : cap; intra[u] = ai; inter[u] = deg - ai; sumIntra += ai; sumInter += inter[u]!; } }
// Edge buffers, sized at the stub-pair upper bound (each undirected edge consumes two stubs). const cap = Math.ceil(sumIntra / 2) + Math.ceil(sumInter / 2) + 1; const source = new Uint32Array(cap); const target = new Uint32Array(cap); let ne = 0;
// 3) Intra-community edges: pair shuffled intra-stubs within each community (configuration model). const intraStubs = new Uint32Array(sumIntra); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < intra[u]!; k++) intraStubs[p++] = u; } let seg = 0; // running start of the current community's stub segment (stubs are in node-id order) for (let c = 0; c + 1 < comStart.length; c++) { let segEnd = seg; for (let u = comStart[c]!; u < comStart[c + 1]!; u++) segEnd += intra[u]!; shuffle(intraStubs, seg, segEnd, rand); for (let i = seg; i + 1 < segEnd; i += 2) { const a = intraStubs[i]!; const b = intraStubs[i + 1]!; if (a !== b) { source[ne] = a; target[ne] = b; ne++; } } seg = segEnd; }
// 4) Inter-community edges: pair shuffled inter-stubs globally, skipping same-community/self pairs. const interStubs = new Uint32Array(sumInter); { let p = 0; for (let u = 0; u < n; u++) for (let k = 0; k < inter[u]!; k++) interStubs[p++] = u; } shuffle(interStubs, 0, sumInter, rand); for (let i = 0; i + 1 < sumInter; i += 2) { const a = interStubs[i]!; const b = interStubs[i + 1]!; if (a !== b && community[a] !== community[b]) { source[ne] = a; target[ne] = b; ne++; } }
// 5) Edge weights: a power-law draw per edge (most ≈ 1, a few heavy) so links vary — and an LOD // super-edge, summing the weights it subsumes, reads as genuinely thicker/heavier. Unweighted ⇒ 1. const weightExp = opts.weightExponent ?? 2.2; const maxWeight = opts.maxWeight ?? 12; const weight = new Float32Array(ne); for (let e = 0; e < ne; e++) weight[e] = opts.weighted ? powerLaw(rand, 1, maxWeight, weightExp) : 1;
return { nodeCount: n, source: source.subarray(0, ne), target: target.subarray(0, ne), weight, community };}Loading a network from a file
Section titled “Loading a network from a file”The same engine renders a network parsed from a file. parseNetwork(text, filename) dispatches on
the name: a .net file is read as Pajek (vertex labels and coordinates, *Arcs/*Edges),
and anything else as a plain edge list (source target [weight], one edge per line, #
comments). Pick your own file or load a built-in sample of each format. Nodes are sized by degree
(scaleSqrt) so hubs stand out, and vertex labels are 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.
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(); }, };};/** * Built-in sample networks for the load-from-file example — one in each format the example * accepts, so it has something to show before you pick a file. `parseNetwork` (from * `@mapequation/d3gl/network`) dispatches on the filename: `.net` → Pajek, anything else → the * plain edge-list parser. */
/** Pajek `.net`: two friend groups bridged by a few links, with quoted vertex labels. */export const SAMPLE_PAJEK = `*Vertices 121 "Alice"2 "Bob"3 "Carol"4 "Dave"5 "Erin"6 "Frank"7 "Grace"8 "Heidi"9 "Ivan"10 "Judy"11 "Mallory"12 "Niaj"*Edges1 21 32 33 42 45 65 76 77 86 89 109 1110 1111 1210 124 58 912 1`;
/** Plain edge list: `source target [weight]`, `#` comments, whitespace-separated. */export const SAMPLE_EDGELIST = `# tiny weighted edge list — source target weightcore hub 5hub a 2hub b 2hub c 2a b 1b c 1c a 1core leaf1 3core leaf2 3leaf1 leaf2 1`;/** * Overlay control bar for the load-network example: a file picker + two built-in-sample buttons. * Plain DOM plumbing, kept out of the example's d3gl `draw.ts` so the code tab stays focused. */import { SAMPLE_PAJEK, SAMPLE_EDGELIST } from "./data.js";
const BTN = "rounded border border-[#cbd5e1] bg-white/90 px-2 py-1 text-xs text-[#334] shadow-sm hover:bg-white cursor-pointer";
/** Build the control bar; `load(text, filename)` is called with whatever the user picks/clicks. */export function makeControls(load: (text: string, filename: string) => void): HTMLElement { const bar = document.createElement("div"); bar.className = "absolute left-2 top-2 z-10 flex flex-wrap items-center gap-2";
const picker = document.createElement("label"); picker.className = BTN; picker.textContent = "Load .net / edge list…"; const input = document.createElement("input"); input.type = "file"; input.accept = ".net,.txt,.edges,.edgelist,.tsv,.csv,text/plain"; input.className = "hidden"; input.addEventListener("change", () => { const file = input.files?.[0]; if (!file) return; void file.text().then((text) => load(text, file.name)); input.value = ""; // let the same file be re-picked }); picker.appendChild(input);
const sample = (text: string, label: string, filename: string): HTMLButtonElement => { const b = document.createElement("button"); b.type = "button"; b.className = BTN; b.textContent = label; b.addEventListener("click", () => load(text, filename)); return b; };
bar.append( picker, sample(SAMPLE_PAJEK, "Sample .net", "sample.net"), sample(SAMPLE_EDGELIST, "Sample edges", "sample.txt"), ); return bar;}Dragging nodes
Section titled “Dragging nodes”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.