Skip to content

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.json directly — 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:

  1. Data layer — typed nodes and edges, often projected from the live ontology (ontology/platform-self-model/) or the holon graph.
  2. 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.
  3. 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."
  4. Custom node components — written with the same shadcn/ui primitives the rest of the app uses. They reference CSS variables, never hex literals.
  5. Tokens cascadetheme.css provides the surface palette; untool.css provides 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:

  1. It consumes a real, generated, contract-bound data source (the lexicon).
  2. It uses the data-viz token bridge end-to-end (no hex literals anywhere).
  3. It runs ELK in a worker (the recommended pattern for any ≥50-node view).
  4. 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:

  1. ui-designer — design direction. Owns the visual language for a new surface, produces the spec.
  2. 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 an aria-label that 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's aria-describedby listing 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.css is audited at AA; the data-viz tokens in untool.css are checked alongside.
  • Color is never the only encoding. Status uses color plus an icon (e.g. a checkmark for ok, a warning triangle for warn). 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

Layout engines

Standards

Color-blind-safe palette

Cross-references in this repo


See also: Ontology Stack · Standards Index · Intellectual Foundations (Bibliography)