Graph Visualization and UI¶
How untool.ai renders graphs — ontologies, holonic boards, self-model digital twins, disambiguation flows, and any other node-and-edge surface that ships inside the product.
This page documents what we picked, why, and how it plugs into the design system. The binding decision is recorded in ARC-ADR-040 — Graph Visualization Component Selection, ratified in PR #532. This page is the longer-form rationale and the working reference for engineers picking layouts, wiring tokens, or considering an alternative.
TL;DR
- React Flow (xyflow) is the default graph runtime for interactive product surfaces.
- Nodes are HTML/CSS (shadcn/ui cards), so they consume the W3C DTCG design tokens
from
frontend-core/contract/design-tokens.jsondirectly — no parallel canvas styling pipeline. - ELK is the default layout engine for DAGs and ontology views; Dagre for lightweight pipelines; d3-force for exploratory clouds; Cola when constraints matter.
- For ≥10k visible nodes we fall back to Sigma.js + Graphology (WebGL) or Cytoscape.js (canvas).
- Mermaid (PR #531) renders graphs in docs; React Flow renders graphs in the app. They do not overlap.
1. The choice — React Flow¶
React Flow — also known by its umbrella project name xyflow — won the trade-space evaluation captured in ARC-ADR-040 for one reason above all the others: its nodes are React components rendered as HTML, so they inherit our design system for free.
Our product is built around a coherent design system rooted in frontend-core/:
frontend-core/app/theme.css— light/dark token set, WCAG-AA audited.frontend-core/app/untool.css— the data-viz bridge (--ut-ink,--ut-accent,--ut-ok,--ut-warn,--ut-data-up,--ut-data-down).frontend-core/contract/design-tokens.json— the canonical W3C DTCG token contract (FE-1, see docs/contracts.md).frontend-core/app/tailwind.css+components.json— wires shadcn/ui (OKLch palette).
A node in a React Flow graph is just a function component:
function CapabilityNode({ data }: NodeProps<CapabilityData>) {
return (
<div
className="rounded-lg border bg-card text-card-foreground shadow-sm"
style={{ borderColor: 'var(--ut-accent)' }}
>
<Badge variant="outline">{data.kind}</Badge>
<h3 className="text-sm font-medium">{data.label}</h3>
</div>
);
}
Every other library on the shortlist renders to a <canvas> or WebGL context, where
var(--ut-accent) does not resolve and shadcn/ui means nothing. They would have forced us
to maintain a parallel styling pipeline: one set of tokens for the app, another for the
graph. The cost of that drift compounds with every theme change, every accessibility audit,
every brand refresh.
React Flow's other big wins:
| Property | Detail |
|---|---|
| License | MIT — permissive, commercial-friendly. |
| Ecosystem | xyflow GmbH ships React Flow + Svelte Flow + a docs/examples site at reactflow.dev. Active maintenance, predictable release cadence. |
| API surface | Hooks-first (useNodesState, useEdgesState, useReactFlow), state lives in React. Easy to wire to Zustand / TanStack Query / our holonic state stores. |
| Edges | Custom edge components (also React/SVG), pluggable routers. |
| Interactions | Pan, zoom, multi-select, copy/paste, undo/redo helpers shipped. |
| Extensibility | First-class NodeTypes / EdgeTypes maps; you swap in any React component. |
It does have weaknesses — chiefly the scale ceiling (Section 4) — but they are addressable and they sit far past the size of the surfaces we ship today.
2. Alternatives surveyed¶
We evaluated six libraries against the same criteria: React story, custom node rendering, layout engines available, scale ceiling, license, and the cost of binding to our design tokens.
| Library | License | Runtime | Layouts | React story | Scale ceiling | Token binding cost |
|---|---|---|---|---|---|---|
| React Flow / xyflow | MIT | DOM/SVG | ELK, Dagre, d3-force via plugins | First-class | ~2–5k visible | Free — HTML nodes inherit CSS variables |
| Cytoscape.js | MIT | Canvas | Cola, Dagre, ELK, fcose, klay — strong | react-cytoscapejs wrapper |
~10–50k | High — stylesheet is a separate DSL |
| Sigma.js + Graphology | MIT | WebGL | Graphology layouts (force-atlas2, circular, noverlap) | Thin wrappers only | ~100k+ | High — shader/program level |
| vis-network | MIT / Apache-2.0 | Canvas | Hierarchical, physics | Community wrappers, dated | ~5–10k | High |
| d3-force (raw D3) | ISC | SVG/Canvas (you pick) | Roll your own | None — you wire it | Depends on what you build | Variable; if you go SVG, free |
| G6 (AntV) | MIT | Canvas / SVG | DAGRE, force, circular, grid, concentric | @antv/g6-react wrapper |
~10–50k | High — G6 spec, not CSS |
Cytoscape.js¶
Cytoscape.js is the most algorithmically mature option. It descends from the Cytoscape bioinformatics platform — decades of investment in layout algorithms, traversal helpers, and graph analytics. The cytoscape.js-elk and fcose layouts are excellent.
Deciding factor against: nodes are styled with a Cytoscape-specific stylesheet DSL, not CSS, and rendered to canvas. Binding the design system to it means duplicating every token as a Cytoscape selector rule and re-rendering on theme change. For our scale (≤2k visible nodes per view), the algorithmic depth doesn't repay that cost. Fallback role: if a single view needs >10k interactive nodes with strong layout fidelity, Cytoscape is the first thing we reach for.
Sigma.js + Graphology¶
Sigma.js is a WebGL renderer; Graphology is the headless graph data model. Together they comfortably push 100k+ nodes at interactive frame rates. The Graphology ecosystem includes solid force layouts (graphology-layout-forceatlas2) and a range of graph algorithms.
Deciding factor against: WebGL programs are not styled by CSS. Customizing a node
means writing/extending shader programs (NodeProgram), which is a different skill from
"build a shadcn/ui card." React integration is thin and unofficial. Fallback role: if
we ever ship a single-page knowledge-graph explorer over the full Holon corpus and the
user needs to see >10k nodes at once, Sigma is the answer.
vis-network¶
vis-network is easy to get running — declarative options, hierarchical layout built in. It powered a lot of early-2010s network demos. The project is community-maintained today; the React story is dated and the customization surface for nodes is weaker than React Flow's.
Deciding factor against: lower ceiling on custom node rendering and a less active React community.
D3-force (raw D3)¶
d3-force gives you maximum control: pick your simulation, pick your DOM strategy (SVG or Canvas), wire interactions yourself. Beautiful for bespoke visualizations.
Deciding factor against: we would be reinventing pan/zoom, selection, viewport
culling, edge routing, and React reconciliation. React Flow already provides those, and
when we want raw force we can plug
d3-force into React Flow's layout slot.
G6 (AntV)¶
G6 is the graph library in the
AntV ecosystem — a serious, well-engineered toolkit with a
strong Chinese-language community. It ships with a wide layout set and a React wrapper
(@antv/g6-react).
Deciding factor against: G6 nodes are defined with G6's own spec, not React components. We'd be in the same canvas-styling situation as Cytoscape, plus the documentation centre of gravity is in zh-CN.
Why React Flow won¶
The shortest version: our nodes ARE the design system. Any library that renders nodes to a canvas or WebGL context puts a glass wall between the design system and the graph. React Flow puts the graph inside the design system.
3. Why "node-first, HTML-rendered" matters for us¶
The design system is the product's visual contract. shadcn/ui cards, badges, buttons, typography scale, focus rings, light/dark surfaces — they are the same atoms whether they appear on a settings page, a capability detail panel, or a node in the self-model graph. A capability node in the ontology viewer should look like a capability card in the registry, because it is the same thing, just laid out differently.
flowchart LR
A[Graph data<br/>nodes + edges] --> B[Layout engine<br/>ELK / Dagre / d3-force]
B --> C[React Flow runtime<br/>viewport, pan, zoom, selection]
C --> D[Custom Node components<br/>shadcn/ui cards]
D --> E[Design tokens<br/>theme.css + untool.css]
E --> F[Rendered DOM<br/>light / dark / a11y]
Concretely, the pipeline is:
- Data layer — typed nodes and edges, often projected from the live ontology
(
ontology/platform-self-model/) or the holon graph. - Layout layer — ELK or Dagre runs over the graph and writes back
(x, y)positions. This is the only place a JS layout engine touches the data. - React Flow — a
<ReactFlow>instance owns the viewport, selection, edge routing, and minimap. It is told "here are nodes with positions, here are edges, here are the component types to render them with." - Custom node components — written with the same shadcn/ui primitives the rest of the app uses. They reference CSS variables, never hex literals.
- Tokens cascade —
theme.cssprovides the surface palette;untool.cssprovides the data-viz semantic tokens; the node picks the right one for its state.
The big consequence: a theme switch reflows the graph automatically. No graph-specific
theming code, no parallel palette. The same goes for accessibility refinements — bumping a
contrast ratio in theme.css propagates everywhere.
4. Scale story¶
React Flow's sweet spot is 2–5k visible nodes before interaction starts to feel sluggish on mid-range hardware. This is a property of the DOM: every node is a real element, every edge is an SVG path, and every pan/zoom triggers a (cheap, but real) reconciliation pass.
That ceiling is far above the size of any view we ship today. The self-model graph viewer renders ~150 nodes. The holonic board sub-graphs render 20–80 nodes per view. The disambiguator streaming surface (see ARC-ADR-046) shows incremental updates over a small candidate set.
Mitigations we already use¶
- Lazy expansion of holonic subgraphs — a holon is collapsible. Children are not rendered until expanded. The whole holonic board is therefore "shallow + expandable," never "10k nodes at once."
- Level-of-detail (LOD) — at low zoom levels nodes render a stripped-down variant
(label only, no badges, no metric chips). React Flow exposes the current viewport zoom
via
useViewport(); the node component branches on it. - Viewport culling — React Flow already culls offscreen nodes from the DOM. We reinforce this by capping the maximum view size in our routes.
- Edge bundling for dense ontology projections — collapse parallel edges between the same source/target into a single styled edge with a count.
- Selective re-render — node components are memoized; the data slices they take are shallow.
When we fall back¶
We will reach for Sigma.js + Graphology if a future view legitimately needs ≥10k nodes visible at once with smooth interaction — e.g. a "all holons in the system" overview, or a knowledge-graph explorer over an unbounded corpus.
We will reach for Cytoscape.js if a view needs strong layout fidelity and analytics (centrality, shortest path, clustering) over a 5–20k-node working set and the design system cost is acceptable for that one surface.
Until then, React Flow is the right call.
5. Layout engines¶
React Flow is layout-agnostic. You give it positions; it renders. The choice of layout engine is per-view and depends on the graph's topology.
| Engine | License | Best for | Worst for | We use it for |
|---|---|---|---|---|
| ELK (Eclipse Layout Kernel) | EPL-2.0 | DAGs, hierarchical / layered, port-aware | Force-directed exploration | Default — ontology projections, capability maps, self-model layered view |
| Dagre | MIT | Quick layered DAGs | Anything cyclic or dense | Lightweight pipelines, simple flow surfaces |
| d3-force | ISC | Exploratory clouds, organic layouts | Strict reading order | Holon neighborhood exploration, "what's connected to X" |
| Cola (WebCola) | MIT | Constraint-based layouts (alignment, grouping) | Very large graphs | Boards with stack/group constraints |
ELK as the default¶
ELK is the most sophisticated open-source layout engine in this space. It was built for the Eclipse Graphical Editing Framework and powers serious modeling tools. Its layered algorithm produces clean, deterministic DAGs with explicit port handling — ideal for ontology hierarchies and capability decompositions where readability matters more than animation.
We use it via elkjs, which runs ELK in a Web Worker
so the main thread stays responsive during layout. The typical wiring is:
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
async function layout(nodes, edges) {
const elkGraph = {
id: 'root',
layoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'DOWN' },
children: nodes.map(n => ({ id: n.id, width: 200, height: 80 })),
edges: edges.map(e => ({ id: e.id, sources: [e.source], targets: [e.target] })),
};
const out = await elk.layout(elkGraph);
return out.children.map(c => ({ id: c.id, position: { x: c.x, y: c.y } }));
}
Per-view choice¶
- Ontology view (the self-model) — ELK layered, top-down. Readability wins.
- Capability map — ELK layered with grouping.
- Holonic board — Cola when there are explicit grouping constraints (parent holon ⊃ children); d3-force when the user is exploring neighborhoods.
- Disambiguator stream (ARC-ADR-046) — Dagre, left-to-right; layout is incremental as candidates arrive.
- DAG pipelines (e.g. capability dispatch trees) — Dagre. Fast, predictable, no Web Worker required.
6. Self-model graph viewer — worked example¶
The reference implementation lives at ontology/platform-self-model/viz/. It is the
visualization of the platform self-model — the digital twin of the fleet (Surfaces,
Capabilities, components, and the exposes / realized-by relations between them; see
the glossary).
It is intentionally the simplest possible production-quality React Flow app, so it doubles as the template for new graph surfaces.
Data: the generated lexicon¶
The viewer's input is ontology/platform-self-model/generated/lexicon.yaml, emitted by
python tools/selfmodel/emit.py from the source model/model.yaml +
model/instances.yaml. Never hand-edit generated/. The lexicon is a flat,
agent-friendly projection: every Surface, Capability, and component as a node, with
relations enumerated as edges.
The viewer parses the lexicon, builds a { nodes, edges } shape, runs it through ELK,
and feeds the result to <ReactFlow>.
Styling: the untool.css data-viz bridge¶
The viewer binds node colors to semantic data-viz tokens from frontend-core/app/untool.css,
never to raw hex values:
| Token | Semantic meaning | Used by |
|---|---|---|
--ut-ink |
Primary node fill / readable text | Default node background |
--ut-accent |
Highlighted / focused / selected | Hover and selection states, edges in focus |
--ut-ok |
Healthy / passing | Capability with green status |
--ut-warn |
Attention / degraded | Capability with amber status |
--ut-data-up / --ut-data-down |
Directional trend | Edge directionality cues in metric overlays |
Because these tokens are CSS variables, flipping the app from dark to light theme
re-themes the graph with zero code path. The viewer also opts in to the brand
purple/blue gradient for Surface nodes via --color-primary and --color-accent from
theme.css.
Layout¶
The viewer uses ELK layered, direction RIGHT, because the Surface → Capability →
component flow reads naturally left-to-right. The layout runs in a Web Worker so the UI
stays responsive even when the lexicon grows.
Why it matters¶
The self-model viewer is the canonical worked example because:
- It consumes a real, generated, contract-bound data source (the lexicon).
- It uses the data-viz token bridge end-to-end (no hex literals anywhere).
- It runs ELK in a worker (the recommended pattern for any ≥50-node view).
- Its custom node components are plain shadcn/ui cards — copy them into your view and start.
When you build a new graph surface, start from the self-model viewer.
7. Design system anchors¶
frontend-core/ is the single source of truth for visual and data-viz styling. This
is restated in CLAUDE.md → Design system. Any new graph surface MUST
consume these contracts, not reinvent them.
Files to bind to¶
| File | Role |
|---|---|
frontend-core/app/theme.css |
Light/dark token set, WCAG-AA audited. --color-bg, --color-surface, --color-text, --color-muted, --color-border, --color-primary, --color-accent. Dark default; [data-theme="dark"] switches modes. |
frontend-core/app/untool.css |
Data-viz bridge: --ut-ink, --ut-accent, --ut-ok, --ut-warn, --ut-data-up, --ut-data-down. Use for charts and graph node coloring. |
frontend-core/contract/design-tokens.json |
The FE-1 design-tokens contract (docs/contracts.md). W3C DTCG format. Brand primary #0066ff, brand purple #7c3aed. Vendored to consumers. |
frontend-core/app/tailwind.css + components.json |
Wires shadcn/ui with OKLch colors. |
frontend-core/design_handoff_*/ |
Richer design material — handoffs, prototypes. |
Palette spirit¶
- Base: Tailwind slate —
#0f172a(slate-900),#1e293b(slate-800),#e2e8f0(slate-200). - Primary: blue/cyan —
#60a5fa(blue-400),#38bdf8(sky-400). - Brand: purple
#7c3aed(violet-600). - Semantic: green / amber / red for success / warning / error.
These are spirit, not literals — always reach them through tokens, never via hex.
Routing visual work¶
Per CLAUDE.md, visual work routes through:
ui-designer— design direction. Owns the visual language for a new surface, produces the spec.frontend-developer— implementation. Consumes the spec and the tokens, ships the React Flow surface.
Graph-specific edge cases (layout choice, scale, custom edge routing) go to
frontend-developer with the ui-designer looped in for the visual review.
8. Accessibility¶
WCAG 2.2 AA is the target for every product surface, including graphs. Graphs are historically poor on accessibility; we treat that as a defect to design out, not an inherent limitation.
Keyboard navigation¶
- Tab order — nodes are focusable in a deterministic order (the order returned by the layout engine, which for ELK is a stable topological sweep).
- Arrow keys — move focus between adjacent nodes. Up/down moves between layers in a layered layout; left/right moves between siblings.
- Enter / Space — activates the node (opens its detail panel).
- Escape — returns focus to the graph container.
?— opens the keyboard shortcut overlay (a standard fleet helper).
React Flow exposes the focus and key handling we need via standard React props on each custom node component, so this is implementable today.
Screen reader and live regions¶
- Each node is a
role="button"(when interactive) with anaria-labelthat includes the node kind, label, and a short status summary. - Edges in pure-decoration roles are
aria-hidden; edges that convey relationships are announced via the node'saria-describedbylisting its outgoing connections. aria-live="polite"regions announce inference and disambiguation updates (see ARC-ADR-046). When a candidate becomes the leader, the live region announces the change without stealing focus.
Color and contrast¶
- Token-bound palettes inherit the theme's contrast guarantees.
theme.cssis audited at AA; the data-viz tokens inuntool.cssare checked alongside. - Color is never the only encoding. Status uses color plus an icon (e.g. a
checkmark for
ok, a warning triangle forwarn). Directional edges use color plus an arrowhead style. - The data-viz palette aligns with the Okabe & Ito 2008 color-blind-safe set — the eight colors recommended for scientific visualization to remain distinguishable under all common forms of color vision deficiency. The mapping is:
| Okabe & Ito | Hex | untool.css role |
|---|---|---|
| Black | #000000 |
n/a (substrate) |
| Orange | #E69F00 |
--ut-warn (warning) |
| Sky Blue | #56B4E9 |
--ut-accent (focus) |
| Bluish Green | #009E73 |
--ut-ok (success) |
| Yellow | #F0E442 |
reserved (highlight) |
| Blue | #0072B2 |
brand-aligned secondary |
| Vermillion | #D55E00 |
--ut-data-down |
| Reddish Purple | #CC79A7 |
brand-aligned tertiary |
Each token in untool.css resolves to a theme-aware variant of one of these anchors,
so the resulting palette is both brand-coherent and color-blind-safe.
Zoom and motion¶
- Pan/zoom respects
prefers-reduced-motion. Smooth transitions become instant when the user has reduced motion turned on. - Minimum hit-target size for nodes is 44×44 CSS pixels (WCAG 2.5.5 target size, AA).
9. Mermaid in docs vs React Flow in app¶
PR #531 enabled Mermaid rendering across
docs.untool.ai via the
mkdocs-material Mermaid extension.
Mermaid and React Flow do not overlap. Use them like this:
| Use case | Tool |
|---|---|
| A diagram inside a docs page (static, authored in Markdown) | Mermaid |
| An interactive graph inside the product (pan, zoom, click, drill in) | React Flow |
| A diagram in an ADR | Mermaid |
| A graph that's hand-authored once and never changes | Mermaid |
| A graph that's generated from live data (lexicon, holon board, ontology) | React Flow (in app) or pre-rendered Mermaid (in docs) |
| A docs page that needs to show a small node diagram next to prose | Mermaid |
| A product surface that lets a user explore the fleet topology | React Flow |
Mermaid wins on authoring ergonomics in plain text. React Flow wins on interactivity, custom rendering, and design-system fidelity. They live in different worlds — docs site vs. app — and the answer to "which one?" is almost always "look at where this is going to render."
For diagram-type-specific Mermaid guidance (flowchart, sequence, ERD, state, gantt), see
the figma-generate-diagram skill — its constraints carry over to docs Mermaid blocks.
10. References¶
Libraries surveyed¶
- React Flow / xyflow — docs site, examples, learning path. GitHub.
- Cytoscape.js — docs, demos, layouts. GitHub. Cytoscape platform homepage: cytoscape.org.
- Sigma.js — WebGL graph rendering. GitHub.
- Graphology — headless graph model, layouts, algorithms. GitHub.
- vis-network — network diagrams. GitHub.
- D3 / d3-force — force-directed simulation.
- G6 (AntV) — graph visualization in the AntV ecosystem. GitHub.
Layout engines¶
- Eclipse Layout Kernel (ELK) — algorithms, configuration
reference.
elkjsfor the JS port. - Dagre — layered graph layouts.
- d3-force — force simulation API.
- WebCola — constraint-based layouts. GitHub.
Standards¶
- W3C Design Tokens Community Group — the DTCG (Design Tokens Community Group) and its format specification, the canonical reference behind our FE-1 contract.
- WCAG 2.2 — Web Content Accessibility Guidelines 2.2, W3C Recommendation (October 2023).
- WAI-ARIA Authoring Practices — patterns for accessible interactive components.
Color-blind-safe palette¶
- Okabe, M., & Ito, K. (2008). Color Universal Design (CUD): How to make figures and presentations that are friendly to colorblind people — the canonical reference for the 8-color CUD palette used in scientific visualization.
Cross-references in this repo¶
- ARC-ADR-040 — Graph Visualization Component Selection
- ARC-ADR-072 — Self-Model Runtime Store
- ARC-ADR-046 — Disambiguator Streaming Service
- Glossary — Surfaces, Capabilities, Components
- Contracts registry — the FE-1 design-tokens contract is the binding one for this page.
ontology/platform-self-model/viz/— the worked example.ontology/platform-self-model/generated/lexicon.yaml— the data the example consumes.frontend-core/app/theme.css,frontend-core/app/untool.css,frontend-core/contract/design-tokens.json— the tokens every graph node binds to.
See also: Ontology Stack · Standards Index · Intellectual Foundations (Bibliography)