Some networks aren’t one graph — they’re a bag of little graphs. Every repo in an org contributes a small CI dependency DAG; every dataset a small lineage subgraph; a biological network is riddled with recurring wiring patterns. Laid side by side these are mostlydisconnected, or joined only by a thread or two. Force-directed layout has nothing to pull on between components, so it scatters them into a meaningless spray of blobs. Hierarchical layouts (dagre, flextree) can’t place them at all — they assume one connected root.
netEnsembleLayout is built for exactly this case. It borrows an idea from the mathematical notion of a net to answer a different question: not “how are these connected” but “what shapes recur, and which ones cohere?”
Preview
Each cell is one small graph, placed so flow converges toward its sink(s) at the bottom. Cells are grouped into bands of the same structural shape; the exemplar at each band's left is that motif drawn once. Toggle to Force scatter to see the same data as a hairball of blobs.
The idea: a net, in plain terms
In topology, a netis a generalization of a sequence: a collection of points indexed by a directed set. A directed set is just a partial order with one extra promise — any two elements have a common element “above” them. Informally: no matter which two things you pick, they eventually flow to a shared point. That’s what makes a net converge.
A DAG hands you a partial order for free. Read each arrow as “comes before”: nodeu is “below” node v if you can reach v fromu. So the natural question to ask of a little DAG is: is it a net? Does everything in it flow to a common place — or does it split into rival endpoints that never reconcile?
The one-line test: count the sinks
Here’s the useful part. A finite directed set always has a single greatest element — one thing that everything is below. In a weakly-connected DAG, a “greatest element” is asink: a node with no outgoing arrows, that every path eventually reaches. So the whole test collapses to something you can do by eye:
- Exactly one sink ⟹ the component is a net. Everything converges to a single outcome; every pair of nodes shares a common descendant. Drawn here inblue.
- Two or more sinks ⟹ it is not a net. Some pair of nodes has no common descendant at all — the graph branches to endpoints that never meet. Drawn inamber.
That’s an O(nodes + edges) check — no heavy math — yet it captures a real semantic distinction. A “fan-in funnel” (many sources, one sink) is a net: work converges. A “fan-out fork” (one source, many sinks) is not: work diverges and never rejoins. The layout colors every component by this test, so convergence vs. divergence is legible at a glance across hundreds of graphs at once.
Motifs: the same shape, twice
The second idea is motifs — structural shapes that recur. Two components are the “same shape” if you could relabel one to get the other (they’reorder-isomorphic). Checking that exactly is expensive in general, so the recipe uses a near-linear fingerprint: Weisfeiler–Leman color refinement. Each node starts labeled by how many arrows come in and out; every round, a node’s label is replaced by a hash of its neighbors’ labels. After a few rounds, components that are the same shape land on the same fingerprint, and different shapes separate.
The layout groups components by fingerprint into labelled bands — one band per motif, the most common first, with the shape drawn once as an exemplar at the band’s left. A hairball of blobs becomes a census: 16 chains, 11 diamonds, 8 fan-in funnels, 6 branching forks, 4 isolates… Gestalt similarity does the rest — identical shapes look identical, and the one weird component pops.
What you can’t do with force or hierarchy
Toggle the demo to Force scatter to see the alternative. Everything the ensemble view makes obvious is invisible there:
- The census. Force layout can never tell you “you have twelve chains and three branching forks.” Position encodes nothing when components don’t touch. The ensemble view makes the motif distribution the primary read.
- Convergence at a glance. Coloring by the sink-count test surfaces which workflows cohere to a single outcome and which fork to rival endpoints — a semantic property, not a layout artifact.
- Placement at all. dagre and flextree need one connected root; a bag of 54 tiny graphs simply isn’t their input. The ensemble view is designed around disconnection instead of fighting it.
- Scale. When cells get too small to interact with individually, each component collapses to a single glyph (still colored by convergence, still one keyboard-navigable mark) — so the census stays readable from dozens to hundreds of components.
The headless census API
The diagnostics are a pure function you can call without rendering anything —analyzeNetEnsemble(nodes, edges). It returns every component with its sink/source counts, its directed flag (the net test), and its motif fingerprint, plus the motif census and the converge/branch totals. Use it to drive a summary readout (as the stat tiles above do), gate a data-quality check (“why does this pipeline have three sinks?”), or feed the numbers into a report — the visualization is optional.
Customization
| What | Where | How |
|---|
| Node fill encoding | layoutConfig.colorMode | "directedness" (converge/branch), "motif", or "category" (a node field). |
| Group into motif bands | layoutConfig.groupByMotif | true (default) bands by shape; false places every component in one size-ordered grid. |
| Band order | layoutConfig.sort | "frequency" (default), "size", or "directedness". |
| Motif sensitivity | layoutConfig.fingerprintRounds | Weisfeiler–Leman rounds (default 3). More rounds distinguish subtler structural differences. |
| Collapse threshold | layoutConfig.minCellForFull | Cell size (px) below which a component collapses to a single census glyph. Default 46. |
| Convergence colors | convergeColor / branchColor / edgeColor | Override the palette; defaults track the active theme’s semantic colors. |
| Chrome | showBandLabels / showExemplars / showLegend | Toggle the band labels, per-band exemplar drawings, and the directedness legend. |
| Edge fields | sourceAccessor / targetAccessor / labelAccessor | Name the edge endpoint fields and the node label field. |
Accessibility
Each drawn mark is a hit-testable, keyboard-navigable scene node carrying a stable id and datum — individual nodes at full detail, one glyph per component in the collapsed census view. That means the layout inherits keyboard navigation, focus rings, the data-table fallback, annotation anchoring, and shared cross-chart selection from the framework, exactly like a built-in chart. Passdescription and summary to describe the ensemble for screen readers.
- Satellites in Space — the sibling
packedClusterMatrix recipe, which bins records (not whole graphs) into a matrix of packed clusters. - Kafka Streams —
lineageDagLayout, for one large connected lineage DAG rather than an ensemble of small ones. - Style Rules — declarative threshold styling that composes with custom network layouts.
Source Code
Abbreviated for readability — data arrays and steps are truncated. See the full runnable source in docs/src/examples/recipes/.
JSX
| 1 | import { NetworkCustomChart } from "semiotic/network" |
| 2 | import { netEnsembleLayout, analyzeNetEnsemble } from "semiotic/recipes" |
| 3 | |
| 4 | // nodes: [{ id }], edges: [{ source, target }] — a bag of small, |
| 5 | // mostly-disconnected DAGs (workflow fragments, lineage subgraphs, motifs…). |
| 6 | |
| 7 | // Headless census — the diagnostics without drawing: |
| 8 | const { components, motifs, directedCount, branchingCount } = |
| 9 | analyzeNetEnsemble(nodes, edges) |
| 10 | // motifs → [{ descriptor: "chain of 3", count: 16, directed: true }, …] |
| 11 | |
| 12 | // The layout, on NetworkCustomChart: |
| 13 | <NetworkCustomChart |
| 14 | nodes={nodes} |
| 15 | edges={edges} |
| 16 | nodeIDAccessor="id" |
| 17 | sourceAccessor="source" |
| 18 | targetAccessor="target" |
| 19 | layout={netEnsembleLayout} |
| 20 | layoutConfig={{ |
| 21 | colorMode: "directedness", // "directedness" | "motif" | "category" |
| 22 | groupByMotif: true, // group order-isomorphic components into bands |
| 23 | sort: "frequency", // band order: "frequency" | "size" | "directedness" |
| 24 | }} |
| 25 | width={880} |
| 26 | height={580} |
| 27 | /> |