Skip to examples
Bento / Kitchen sink
Bento / compositions

Networks

Things and the connections between them, and quantities flowing through stages. One graph component with six layouts — choose the layout for the question, because each gives position a different meaning.

NetworkGraph

six layouts · glides between them · select with keys

Service map

What calls what. Change the layout and the nodes glide to their new places.

  • Frontend
  • API
  • Data
  • Infra
View nodes for Service dependencies
Service dependencies
NodeGroupConnectionsSize
webFrontend140
adminFrontend112
mobileFrontend122
gatewayAPI760
authAPI334
projectsAPI428
billingAPI218
searchAPI214
postgresData350
redisData230
warehouseData116
queueInfra226
workersInfra324
storageInfra220
View connections for Service dependencies
Service dependencies
FromToValue
webgateway—
admingateway—
mobilegateway—
gatewayauth—
gatewayprojects—
gatewaybilling—
gatewaysearch—
authpostgres—
authredis—
projectspostgres—
projectsqueue—
billingpostgres—
searchredis—
queueworkers—
workersstorage—
workerswarehouse—
projectsstorage—

Select a service — click it, or Tab in and use the arrow keys.

Deterministic. The force layout is seeded and avoids every engine-dependent maths function, so the server and the browser draw exactly the same graph.

One tab stop. Tab into the graph, move between nodes with the arrow keys, select with Enter or Space, clear with Escape. Hover or focus lights a node's neighbours.

Sourcecomponents/charts/network-graph/doc.ts · components/charts/network-graph/network-graph.tsx · components/charts/network-graph/network-graph.module.css · components/charts/_kernel/graph.ts

components/charts/network-graph/doc.ts

/**
 * NetworkGraph — things and the connections between them.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     nodes         { id, label?, group?, size? }[]
 *     edges         { from, to, value? (signed −1–1), weight?, label? }[]
 *     layout?       "force" (default) | "radial" | "circular" | "tiered" |
 *                   "flow" | "grid"
 *     root?         the centre of a radial layout
 *     groups?       groups in a FIXED order: colour, shape, and tiers
 *     curved?, labels?, hulls? ({ key, label?, ids }[])
 *     onSelect?, selected?   selection
 *     aspect?, formatValue?, animation?  (default: nodes pop in)
 *
 * # Behaviour
 *
 * R1  The same input always gives the same picture, on the server and in
 *     every browser: layouts are deterministic.
 * R2  Position means what the layout says: force — nearness is a hint;
 *     radial — the ring is hops from the root; circular — nothing (so no
 *     node hides another); tiered — the group; flow — columns by longest
 *     path, every edge pointing forward; grid — nothing, in rows.
 * R3  Groups have a colour AND a shape. Size is drawn as area.
 * R4  A signed edge's hue is its sign and its opacity its strength; a
 *     weight is its width. An edge to a node that does not exist is ignored.
 * R5  Hover or focus lights a node and its neighbours and dims the rest.
 *     Selection is one tab stop: arrow keys move, Enter or Space selects,
 *     Escape clears.
 * R6  Changing the layout (or the data) moves nodes to their new places;
 *     a new node appears where it belongs.
 * R7  With hulls, the force layout gathers each group into its own region
 *     (up to five groups), so a hull outlines a place, not a scatter.
 * R8  Two tables list every node (with its connections) and every edge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The force layout is d3-force, run synchronously for a fixed number of
 * ticks with hash-seeded starting positions and a seeded random source. A
 * force simulation is chaotic, and ECMAScript leaves cos, sin, hypot, exp,
 * and log to each engine, so none run inside it (d3's only trigonometry is
 * the starting spiral, which seeded positions replace); sqrt and arithmetic
 * are exactly specified by IEEE-754. Labels are HTML placed by percentage.
 */
export {};

components/charts/network-graph/network-graph.tsx

"use client";

import {
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { radiusFor, shapePath } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import {
  edgePath,
  graphTarget,
  hullAround,
  liveEdges,
  neighbours,
  type GraphEdge,
  type GraphLayout,
  type GraphNode,
  type Point,
} from "../_kernel/graph";
import { px } from "../_kernel/scale";
import { categoryColor, seriesShape } from "../_kernel/scatter";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./network-graph.module.css";

export type { GraphEdge, GraphLayout, GraphNode };

export type NetworkGraphProps = {
  /** Names the chart. */
  label: string;
  nodes: readonly GraphNode[];
  edges: readonly GraphEdge[];
  /** How position is decided — and so what it means. */
  layout?: GraphLayout;
  /** The centre of a radial layout. */
  root?: string;
  /** Groups in a FIXED order: colour, shape, and the tiers of a tiered
   *  layout. */
  groups?: readonly string[];
  /** Bow edges into arcs, where straight chords would overlap. */
  curved?: boolean;
  /** Print node names; off for dense graphs where they would be a wash. */
  labels?: boolean;
  /** Labelled regions drawn behind groups of nodes. */
  hulls?: readonly { key: string; label?: string; ids: readonly string[] }[];
  /** Makes nodes selectable: one tab stop, arrow keys move between nodes. */
  onSelect?: (id: string | null) => void;
  selected?: string | null;
  /** Width over height. */
  aspect?: number;
  formatValue?: (value: number) => string;
  /** Default: nodes pop in; changing the layout glides them to their new
   *  places. */
  animation?: AnimationProp;
  className?: string;
};

/** Things and the connections between them. What position means depends
 *  on the layout — a force layout's nearness is a hint, a radial ring is a
 *  hop count — so choose the layout for the question. Hover or focus a node
 *  to light its neighbours; the tables list every node and connection. */
export function NetworkGraph({
  label,
  nodes,
  edges: rawEdges,
  layout = "force",
  root,
  groups: groupOrder,
  curved = false,
  labels = true,
  hulls = [],
  onSelect,
  selected = null,
  aspect = 1.6,
  formatValue = formatExact,
  animation,
  className,
}: NetworkGraphProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "pop",
    axis: "y",
  });
  const edges = useMemo(() => liveEdges(nodes, rawEdges), [nodes, rawEdges]);
  const groups = useMemo(
    () =>
      groupOrder ??
      [...new Set(nodes.map((n) => n.group ?? ""))].filter(Boolean),
    [groupOrder, nodes],
  );
  // The layout is computed once per input, not per hover or tween frame.
  const target = useMemo(
    () =>
      graphTarget(nodes, edges, layout, aspect, {
        root,
        groups,
        // With hulls the groups are the point, so the force layout gathers
        // each into its own region.
        cluster: hulls.length > 0,
      }),
    [nodes, edges, layout, aspect, root, groups, hulls.length],
  );
  const shown = useTweened(target, update, "target");
  const near = useMemo(() => neighbours(nodes, edges), [nodes, edges]);

  const [pointed, setPointed] = useState<string | null>(null);
  const [active, setActive] = useState(0);
  const svg = useRef<SVGSVGElement>(null);
  const lit = pointed ?? selected;
  const stateOf = (id: string) =>
    lit === null
      ? undefined
      : id === lit
        ? "lit"
        : near.get(lit)?.has(id)
          ? "near"
          : "dim";

  // The frame fits the laid-out nodes, with room for their labels.
  const at = (id: string): Point | null => {
    const x = shown[`${id}|x`];
    const y = shown[`${id}|y`];
    return x === undefined || y === undefined ? null : { x, y };
  };
  const finals = nodes.flatMap((n) => {
    const x = target[`${n.id}|x`];
    const y = target[`${n.id}|y`];
    return x === undefined ? [] : [{ x, y }];
  });
  const left = Math.min(0, ...finals.map((p) => p.x)) - 60;
  const right = Math.max(1000, ...finals.map((p) => p.x)) + 60;
  const top = Math.min(0, ...finals.map((p) => p.y)) - 50;
  const bottom = Math.max(1000 / aspect, ...finals.map((p) => p.y)) + 50;
  const width = right - left;
  const height = bottom - top;
  const pct = (p: Point) => ({
    left: `${((p.x - left) / width) * 100}%`,
    top: `${((p.y - top) / height) * 100}%`,
  });

  const sizes = nodes
    .map((n) => n.size)
    .filter((s): s is number => typeof s === "number" && s > 0);
  const range: [number, number] = sizes.length
    ? [Math.min(...sizes), Math.max(...sizes)]
    : [1, 1];
  const radius = (n: GraphNode) =>
    n.size && n.size > 0 ? radiusFor(n.size, range, [9, 26]) : 11;
  const maxWeight = Math.max(1, ...edges.map((e) => e.weight ?? 0));
  const degree = (id: string) => near.get(id)?.size ?? 0;

  const select = (id: string) => onSelect?.(selected === id ? null : id);
  const move = (event: KeyboardEvent) => {
    if (!onSelect) return;
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[
      event.key
    ];
    if (event.key === "Escape") {
      onSelect(null);
      return;
    }
    if (step === undefined) return;
    event.preventDefault();
    const next = (active + step + nodes.length) % nodes.length;
    setActive(next);
    svg.current?.querySelector<SVGGElement>(`[data-index="${next}"]`)?.focus();
  };

  if (!nodes.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {groups.length > 1 ? (
        <ChartLegend
          items={groups.map((group) => ({
            key: group,
            label: group,
            color: categoryColor(groups, group),
            shape: seriesShape(groups, group),
          }))}
        />
      ) : null}
      <div
        className={styles.frame}
        style={{ "--aspect": width / height } as CSSProperties}
      >
        <svg
          ref={svg}
          className={styles.svg}
          viewBox={`${px(left)} ${px(top)} ${px(width)} ${px(height)}`}
          role={onSelect ? "group" : "img"}
          aria-label={`${label}. Every node and connection is in the data tables.`}
          data-lit={lit ? "" : undefined}
          onKeyDown={onSelect ? move : undefined}
        >
          {hulls.map((hull, index) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape ? (
              <ellipse
                key={hull.key}
                className={styles.hull}
                style={
                  {
                    "--series": categoryColor(
                      hulls.map((h) => h.key),
                      hulls[index].key,
                    ),
                  } as CSSProperties
                }
                cx={shape.cx}
                cy={shape.cy}
                rx={shape.rx}
                ry={shape.ry}
              />
            ) : null;
          })}
          {edges.map((edge, index) => {
            const a = at(edge.from);
            const b = at(edge.to);
            if (!a || !b) return null;
            const signed = typeof edge.value === "number";
            return (
              <path
                key={`${edge.from}-${edge.to}-${index}`}
                className={styles.edge}
                d={edgePath(a, b, curved)}
                data-lit={
                  lit !== null && (edge.from === lit || edge.to === lit)
                    ? ""
                    : undefined
                }
                data-sign={
                  signed
                    ? (edge.value as number) < 0
                      ? "negative"
                      : "positive"
                    : undefined
                }
                style={
                  {
                    "--edge-w": 1 + ((edge.weight ?? 0) / maxWeight) * 3,
                    "--edge-o": signed
                      ? 0.25 +
                        Math.min(1, Math.abs(edge.value as number)) * 0.65
                      : undefined,
                  } as CSSProperties
                }
              />
            );
          })}
          {nodes.map((node, index) => {
            const p = at(node.id);
            if (!p) return null;
            const r = radius(node);
            const name = node.label ?? node.id;
            return (
              <g key={node.id} transform={`translate(${px(p.x)} ${px(p.y)})`}>
                <g
                  data-mark
                  data-index={index}
                  data-state={stateOf(node.id)}
                  className={styles.node}
                  style={
                    {
                      "--series": categoryColor(groups, node.group),
                    } as CSSProperties
                  }
                  onPointerEnter={() => setPointed(node.id)}
                  onPointerLeave={() => setPointed(null)}
                  onFocus={() => {
                    setPointed(node.id);
                    setActive(index);
                  }}
                  onBlur={() => setPointed(null)}
                  {...(onSelect
                    ? {
                        role: "button",
                        tabIndex: index === active ? 0 : -1,
                        "aria-label": `${name}${node.group ? `, ${node.group}` : ""}, ${degree(node.id)} connections`,
                        "aria-pressed": selected === node.id,
                        onClick: () => select(node.id),
                        onKeyDown: (event: KeyboardEvent) => {
                          if (event.key === "Enter" || event.key === " ") {
                            event.preventDefault();
                            select(node.id);
                          }
                        },
                      }
                    : {})}
                >
                  <circle className={styles.ring} r={r + 5} />
                  <path
                    className={styles.shape}
                    d={shapePath(seriesShape(groups, node.group), r)}
                  />
                </g>
              </g>
            );
          })}
        </svg>
        <div className={styles.labels} aria-hidden="true">
          {hulls.map((hull) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape && hull.label ? (
              <span
                key={hull.key}
                data-hull
                style={pct({ x: shape.cx, y: shape.cy - shape.ry })}
              >
                {hull.label}
              </span>
            ) : null;
          })}
          {labels
            ? nodes.map((node) => {
                const p = at(node.id);
                return p ? (
                  <span
                    key={node.id}
                    data-state={stateOf(node.id)}
                    style={pct({ x: p.x, y: p.y + radius(node) })}
                  >
                    {node.label ?? node.id}
                  </span>
                ) : null;
              })
            : null}
        </div>
      </div>
      <ChartData label={label} summary="View nodes">
        <THead>
          <Tr>
            <Th>Node</Th>
            <Th>Group</Th>
            <Th numeric>Connections</Th>
            {sizes.length ? <Th numeric>Size</Th> : null}
          </Tr>
        </THead>
        <TBody>
          {nodes.map((node) => (
            <Tr key={node.id}>
              <Th scope="row">{node.label ?? node.id}</Th>
              <Td>{node.group ?? "—"}</Td>
              <Td numeric>{degree(node.id)}</Td>
              {sizes.length ? (
                <Td numeric>
                  {typeof node.size === "number" ? formatValue(node.size) : "—"}
                </Td>
              ) : null}
            </Tr>
          ))}
        </TBody>
      </ChartData>
      <ChartData label={label} summary="View connections">
        <THead>
          <Tr>
            <Th>From</Th>
            <Th>To</Th>
            <Th numeric>Value</Th>
          </Tr>
        </THead>
        <TBody>
          {edges.map((edge, index) => {
            const name = (id: string) =>
              nodes.find((n) => n.id === id)?.label ?? id;
            const value = edge.value ?? edge.weight;
            return (
              <Tr key={index}>
                <Th scope="row">{name(edge.from)}</Th>
                <Td>{name(edge.to)}</Td>
                <Td numeric>
                  {typeof value === "number" ? formatValue(value) : "—"}
                </Td>
              </Tr>
            );
          })}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/network-graph/network-graph.module.css

@layer primitive {
  .frame {
    position: relative;
    width: 100%;
    aspect-ratio: var(--aspect, 1.6);
  }
  .svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  .hull {
    fill: color-mix(in oklab, var(--series) 12%, transparent);
    stroke: color-mix(in oklab, var(--series) 45%, transparent);
    stroke-dasharray: 4 4;
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
  .edge {
    fill: none;
    stroke: var(--chart-axis, var(--line-strong));
    stroke-opacity: 0.7;
    stroke-width: var(--edge-w, 1.25);
    vector-effect: non-scaling-stroke;
    transition: stroke-opacity var(--dur-2) var(--ease);
  }
  .edge[data-sign="positive"] {
    stroke: var(--chart-pos);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .edge[data-sign="negative"] {
    stroke: var(--chart-neg);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .svg[data-lit] .edge:not([data-lit]) {
    stroke-opacity: 0.12;
  }
  .node {
    transform-origin: center;
    transition: opacity var(--dur-2) var(--ease);
  }
  .shape {
    fill: var(--series);
    stroke: var(--surface-panel);
    stroke-width: 1.5;
    vector-effect: non-scaling-stroke;
  }
  .svg[data-lit] .node[data-state="dim"] {
    opacity: 0.25;
  }
  .ring {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    vector-effect: non-scaling-stroke;
    opacity: 0;
  }
  .node[data-state="lit"] .ring,
  .node[aria-pressed="true"] .ring {
    opacity: 1;
  }
  .node[role="button"] {
    cursor: pointer;
    outline: none;
  }
  .node[role="button"]:focus-visible .ring {
    stroke-dasharray: 3 2;
    opacity: 1;
  }
  .labels {
    position: absolute;
    inset: 0;
    pointer-events: none;
    font-size: var(--text-11);
  }
  .labels span {
    position: absolute;
    color: var(--ink-2);
    white-space: nowrap;
    transform: translate(-50%, 6px);
    transition: opacity var(--dur-2) var(--ease);
  }
  .labels span[data-state="dim"] {
    opacity: 0.25;
  }
  .labels span[data-hull] {
    color: var(--ink-3);
    font-weight: var(--weight-medium);
    transform: translate(-50%, -100%);
  }
}

components/charts/_kernel/graph.ts

import {
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  forceX,
  forceY,
  type SimulationNodeDatum,
} from "d3-force";

import { px } from "./scale";

/* NetworkGraph's maths: layouts and geometry, all deterministic, so the
   server and the browser draw the same picture.

   A force layout is chaotic: a one-bit difference in step one grows into a
   visibly different graph. ECMAScript leaves cos, sin, hypot, exp, and log
   to each engine, so none of them appear inside the simulation — starting
   positions come from a hash, the simulation's randomness from a seeded
   generator, and distances from sqrt, which IEEE-754 specifies exactly. */

export type GraphNode = {
  id: string;
  label?: string;
  /** A class: sets colour and shape (by `groups` order), and the tier in a
   *  tiered layout. */
  group?: string;
  /** A magnitude drawn as the node's area. */
  size?: number;
};

export type GraphEdge = {
  from: string;
  to: string;
  /** Signed, −1 to 1: hue is the sign, opacity the strength. */
  value?: number;
  /** Unsigned strength: drawn as width. */
  weight?: number;
  label?: string;
};

export type GraphLayout =
  "force" | "radial" | "circular" | "tiered" | "flow" | "grid";
export type Point = { x: number; y: number };

/** The virtual canvas layouts place nodes on. */
export const GRAPH_W = 1000;

/** FNV-1a → 0–1: a repeatable number per string. */
export function seedOf(text: string) {
  let h = 2166136261;
  for (let i = 0; i < text.length; i++) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) / 4294967296;
}

/** A seeded linear congruential generator for the simulation's jiggle. */
function lcg(seed: number) {
  let s = Math.floor(seed * 4294967296) >>> 0 || 1;
  return () => {
    s = (Math.imul(1664525, s) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** Edges whose ends both exist; a dangling edge is ignored, not guessed. */
export function liveEdges(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const ids = new Set(nodes.map((n) => n.id));
  return edges.filter(
    (e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to,
  );
}

export function neighbours(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const near = new Map<string, Set<string>>(
    nodes.map((n) => [n.id, new Set()]),
  );
  for (const e of edges) {
    near.get(e.from)?.add(e.to);
    near.get(e.to)?.add(e.from);
  }
  return near;
}

/** Hops from a root, walking edges both ways; unreached nodes go one ring
 *  beyond the furthest. */
export function hopsFrom(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  root: string,
) {
  const near = neighbours(nodes, edges);
  const depth = new Map<string, number>([[root, 0]]);
  let frontier = [root];
  while (frontier.length) {
    const next: string[] = [];
    for (const id of frontier)
      for (const other of near.get(id) ?? []) {
        if (depth.has(other)) continue;
        depth.set(other, (depth.get(id) ?? 0) + 1);
        next.push(other);
      }
    frontier = next;
  }
  const beyond = Math.max(0, ...depth.values()) + 1;
  for (const n of nodes) if (!depth.has(n.id)) depth.set(n.id, beyond);
  return depth;
}

/** Split edges into those that keep the graph acyclic and the "back"
 *  edges that close a loop, by depth-first search in input order: a loop is
 *  broken at the edge that closes it (c → a in a → b → c → a), not wherever
 *  a walk happens to land. */
export function splitLoops<E extends { from: string; to: string }>(
  nodes: readonly { id: string }[],
  edges: readonly E[],
) {
  const outgoing = new Map<string, E[]>(nodes.map((n) => [n.id, []]));
  for (const e of edges) outgoing.get(e.from)?.push(e);
  const state = new Map<string, 1 | 2>();
  const back = new Set<E>();
  const visit = (id: string) => {
    state.set(id, 1);
    for (const e of outgoing.get(id) ?? []) {
      const s = state.get(e.to);
      if (s === 1) back.add(e);
      else if (s === undefined) visit(e.to);
    }
    state.set(id, 2);
  };
  for (const n of nodes) if (!state.has(n.id)) visit(n.id);
  return {
    forward: edges.filter((e) => !back.has(e)),
    loops: edges.filter((e) => back.has(e)),
  };
}

/** Longest path from any source, for flow layouts; loops are broken first
 *  (see splitLoops). */
export function longestPath(
  nodes: readonly { id: string }[],
  edges: readonly { from: string; to: string }[],
) {
  const { forward } = splitLoops(nodes, edges);
  const incoming = new Map<string, string[]>(nodes.map((n) => [n.id, []]));
  for (const e of forward) incoming.get(e.to)?.push(e.from);
  const depth = new Map<string, number>();
  const walk = (id: string): number => {
    const known = depth.get(id);
    if (known !== undefined) return known;
    const from = incoming.get(id) ?? [];
    const d = from.length ? Math.max(...from.map((f) => walk(f) + 1)) : 0;
    depth.set(id, d);
    return d;
  };
  for (const n of nodes) walk(n.id);
  return depth;
}

type Options = { root?: string; groups: readonly string[]; cluster?: boolean };

type Layout = (
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  height: number,
  options: Options,
) => Map<string, Point>;

/** Where each group gathers when a force layout is clustered, as fractions
 *  of the canvas. A fixed table, not points on a circle: anything that feeds
 *  the simulation must avoid engine-dependent maths. */
const ANCHORS: readonly (readonly [number, number])[][] = [
  [[0.5, 0.5]],
  [
    [0.28, 0.5],
    [0.72, 0.5],
  ],
  [
    [0.5, 0.26],
    [0.26, 0.72],
    [0.74, 0.72],
  ],
  [
    [0.28, 0.28],
    [0.72, 0.28],
    [0.28, 0.72],
    [0.72, 0.72],
  ],
  [
    [0.5, 0.2],
    [0.2, 0.45],
    [0.8, 0.45],
    [0.32, 0.8],
    [0.68, 0.8],
  ],
];

const force: Layout = (nodes, edges, height, { groups, cluster }) => {
  type Sim = SimulationNodeDatum & { id: string };
  const sim: Sim[] = nodes.map((n) => ({
    id: n.id,
    x: GRAPH_W * (0.25 + seedOf(`${n.id}:x`) * 0.5),
    y: height * (0.25 + seedOf(`${n.id}:y`) * 0.5),
  }));
  const spacing = Math.sqrt((GRAPH_W * height) / Math.max(1, nodes.length));
  const simulation = forceSimulation(sim)
    .randomSource(lcg(seedOf(nodes.map((n) => n.id).join("|"))))
    .force(
      "link",
      forceLink<Sim, { source: string; target: string }>(
        edges.map((e) => ({ source: e.from, target: e.to })),
      )
        .id((d) => d.id)
        .distance(spacing * 0.55),
    )
    .force("charge", forceManyBody().strength(-spacing * 2))
    .force("collide", forceCollide(24))
    .stop();
  // Clustered, each group is drawn toward its own region (up to five
  // groups); otherwise everything is drawn gently toward the centre.
  const table = ANCHORS[Math.min(groups.length, ANCHORS.length) - 1];
  const groupOf = new Map(nodes.map((n) => [n.id, n.group ?? ""]));
  const anchor = (id: string) => {
    const index = groups.indexOf(groupOf.get(id) ?? "");
    return cluster && table && index >= 0 && index < table.length
      ? table[index]
      : [0.5, 0.5];
  };
  const pull = cluster ? 0.18 : 0.06;
  simulation
    .force("x", forceX<Sim>((d) => anchor(d.id)[0] * GRAPH_W).strength(pull))
    .force(
      "y",
      forceY<Sim>((d) => anchor(d.id)[1] * height).strength(
        pull * (GRAPH_W / height),
      ),
    );
  simulation.tick(300);
  return fit(
    new Map(sim.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }])),
    height,
  );
};

/** Scale positions uniformly (so shapes keep their proportions) to fill the
 *  canvas, less a margin for labels. */
function fit(points: Map<string, Point>, height: number, margin = 70) {
  if (points.size < 2)
    return new Map(
      [...points].map(([id]) => [id, { x: GRAPH_W / 2, y: height / 2 }]),
    );
  const xs = [...points.values()].map((p) => p.x);
  const ys = [...points.values()].map((p) => p.y);
  const [x0, x1, y0, y1] = [
    Math.min(...xs),
    Math.max(...xs),
    Math.min(...ys),
    Math.max(...ys),
  ];
  const scale = Math.min(
    (GRAPH_W - 2 * margin) / Math.max(1, x1 - x0),
    (height - 2 * margin) / Math.max(1, y1 - y0),
  );
  const ox = (GRAPH_W - (x1 - x0) * scale) / 2;
  const oy = (height - (y1 - y0) * scale) / 2;
  return new Map(
    [...points].map(([id, p]) => [
      id,
      { x: px(ox + (p.x - x0) * scale), y: px(oy + (p.y - y0) * scale) },
    ]),
  );
}

const circle = (
  count: number,
  index: number,
  cx: number,
  cy: number,
  r: number,
  turn = 0,
) => {
  const angle = (index / Math.max(1, count)) * 2 * Math.PI - Math.PI / 2 + turn;
  return { x: px(cx + r * Math.cos(angle)), y: px(cy + r * Math.sin(angle)) };
};

const radial: Layout = (nodes, edges, height, { root }) => {
  const centre = root ?? nodes[0]?.id ?? "";
  const depth = hopsFrom(nodes, edges, centre);
  const near = neighbours(nodes, edges);
  const rings = new Map<number, string[]>();
  for (const n of nodes) {
    if (n.id === centre) continue;
    const d = depth.get(n.id) ?? 1;
    rings.set(d, [...(rings.get(d) ?? []), n.id]);
  }
  const out = new Map<string, Point>([
    [centre, { x: GRAPH_W / 2, y: height / 2 }],
  ]);
  // Turns around the centre, per node, so a ring can follow the one inside.
  const turn = new Map<string, number>([[centre, 0]]);
  const count = Math.max(1, rings.size);
  const outer = Math.min(GRAPH_W, height) / 2 - 50;
  for (const [ring, ids] of [...rings].sort((a, b) => a[0] - b[0])) {
    // Order a ring by where its parent sits, so children stay beside their
    // parent and edges do not cross the middle.
    const parentTurn = (id: string) => {
      const parents = [...(near.get(id) ?? [])].filter(
        (p) => turn.has(p) && (depth.get(p) ?? 0) < ring,
      );
      return parents.length ? Math.min(...parents.map((p) => turn.get(p)!)) : 1;
    };
    const ordered = [...ids].sort(
      (a, b) => parentTurn(a) - parentTurn(b) || a.localeCompare(b),
    );
    const r = (outer * ring) / count;
    ordered.forEach((id, i) => {
      const t = (i + 0.5) / ordered.length;
      turn.set(id, t);
      out.set(id, circle(1, 0, GRAPH_W / 2, height / 2, r, t * 2 * Math.PI));
    });
  }
  return out;
};

const circular: Layout = (nodes, _edges, height) => {
  const r = Math.min(GRAPH_W, height) / 2 - 40;
  return new Map(
    nodes.map((n, i) => [
      n.id,
      circle(nodes.length, i, GRAPH_W / 2, height / 2, r),
    ]),
  );
};

const tiered: Layout = (nodes, _edges, height, { groups }) => {
  const order = [
    ...new Set([...groups, ...nodes.map((n) => n.group ?? "")]),
  ].filter((g) => nodes.some((n) => (n.group ?? "") === g));
  const out = new Map<string, Point>();
  order.forEach((group, column) => {
    const ids = nodes.filter((n) => (n.group ?? "") === group).map((n) => n.id);
    const x =
      order.length === 1
        ? GRAPH_W / 2
        : 60 + (column * (GRAPH_W - 120)) / (order.length - 1);
    ids.forEach((id, i) =>
      out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
    );
  });
  return out;
};

const flow: Layout = (nodes, edges, height) => {
  const depth = longestPath(nodes, edges);
  const columns = new Map<number, string[]>();
  for (const n of nodes) {
    const d = depth.get(n.id) ?? 0;
    columns.set(d, [...(columns.get(d) ?? []), n.id]);
  }
  const count = Math.max(1, columns.size);
  const out = new Map<string, Point>();
  [...columns]
    .sort((a, b) => a[0] - b[0])
    .forEach(([, ids], column) => {
      const x =
        count === 1
          ? GRAPH_W / 2
          : 60 + (column * (GRAPH_W - 120)) / (count - 1);
      ids.forEach((id, i) =>
        out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
      );
    });
  return out;
};

const grid: Layout = (nodes, _edges, height) => {
  const cols = Math.max(
    1,
    Math.ceil(Math.sqrt(nodes.length * (GRAPH_W / height))),
  );
  const rows = Math.max(1, Math.ceil(nodes.length / cols));
  return new Map(
    nodes.map((n, i) => [
      n.id,
      {
        x: px((((i % cols) + 1) * GRAPH_W) / (cols + 1)),
        y: px(((Math.floor(i / cols) + 1) * height) / (rows + 1)),
      },
    ]),
  );
};

const LAYOUTS: Record<GraphLayout, Layout> = {
  force,
  radial,
  circular,
  tiered,
  flow,
  grid,
};

/** Every node's position, as a record for tweening: `${id}|x`, `${id}|y`. */
export function graphTarget(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  layout: GraphLayout,
  aspect: number,
  options: Options,
) {
  const height = GRAPH_W / aspect;
  const placed = nodes.length
    ? LAYOUTS[layout](nodes, edges, height, options)
    : new Map();
  const target: Record<string, number> = {};
  for (const [id, p] of placed) {
    target[`${id}|x`] = p.x;
    target[`${id}|y`] = p.y;
  }
  return target;
}

/** A straight edge, or a shallow arc that bows with length — straight for
 *  radial layouts, arcs where chords would overlap into a disc. */
export function edgePath(a: Point, b: Point, curved: boolean) {
  if (!curved) return `M${px(a.x)},${px(a.y)}L${px(b.x)},${px(b.y)}`;
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distance = Math.sqrt(dx * dx + dy * dy) || 1;
  const bow = Math.min(distance * 0.18, 60);
  const mx = (a.x + b.x) / 2 - (dy / distance) * bow;
  const my = (a.y + b.y) / 2 + (dx / distance) * bow;
  return `M${px(a.x)},${px(a.y)}Q${px(mx)},${px(my)} ${px(b.x)},${px(b.y)}`;
}

/** An ellipse around a set of positions, for a labelled group (a hull). */
export function hullAround(points: readonly Point[]) {
  if (!points.length) return null;
  const cx = points.reduce((sum, p) => sum + p.x, 0) / points.length;
  const cy = points.reduce((sum, p) => sum + p.y, 0) / points.length;
  const r =
    Math.max(
      34,
      ...points.map((p) => {
        const dx = p.x - cx;
        const dy = p.y - cy;
        return Math.sqrt(dx * dx + dy * dy);
      }),
    ) + 30;
  return { cx: px(cx), cy: px(cy), rx: px(r * 1.12), ry: px(r) };
}

Layouts as answers

radial · flow · multipartite · clique

Radial

Rings are hops from the workspace: position is an answer.

  • Workspace
  • Team
  • Member
View nodes for Ownership, by hops from the workspace
Ownership, by hops from the workspace
NodeGroupConnections
NorthstarWorkspace3
PlatformTeam4
GrowthTeam3
DesignTeam3
AdaMember1
GraceMember1
AlanMember1
RadiaMember1
KenMember1
BarbaraMember1
LinusMember1
View connections for Ownership, by hops from the workspace
Ownership, by hops from the workspace
FromToValue
NorthstarPlatform—
NorthstarGrowth—
NorthstarDesign—
PlatformAda—
PlatformGrace—
PlatformAlan—
GrowthRadia—
GrowthKen—
DesignBarbara—
DesignLinus—
Flow

Columns by longest path: every edge points forward.

View nodes for Deploy pipeline
Deploy pipeline
NodeGroupConnections
commitStep2
lintStep2
unit testsStep2
buildStep4
integrationStep2
previewStep1
stagingStep2
canaryStep2
productionStep1
View connections for Deploy pipeline
Deploy pipeline
FromToValue
commitlint—
commitunit tests—
lintbuild—
unit testsbuild—
buildintegration—
buildpreview—
integrationstaging—
stagingcanary—
canaryproduction—
Multipartite

One tier per group, in the order given.

  • Region
  • Service
  • Datastore
View nodes for Regions, services, and datastores
Regions, services, and datastores
NodeGroupConnections
us-eastRegion2
eu-westRegion2
ap-southRegion2
gatewayService4
authService2
projectsService3
billingService2
postgresDatastore3
redisDatastore1
storageDatastore1
View connections for Regions, services, and datastores
Regions, services, and datastores
FromToValue
us-eastgateway—
us-eastauth—
eu-westgateway—
eu-westprojects—
ap-southgateway—
ap-southbilling—
gatewayredis—
authpostgres—
projectspostgres—
projectsstorage—
billingpostgres—
Clique

Circular, so every edge of a dense group is visible.

View nodes for Who reviews whom
Who reviews whom
NodeGroupConnections
AdaReviewer5
GraceReviewer5
AlanReviewer5
RadiaReviewer5
KenReviewer5
BarbaraReviewer5
View connections for Who reviews whom
Who reviews whom
FromToValue
AdaGrace—
AdaAlan—
AdaRadia—
AdaKen—
AdaBarbara—
GraceAlan—
GraceRadia—
GraceKen—
GraceBarbara—
AlanRadia—
AlanKen—
AlanBarbara—
RadiaKen—
RadiaBarbara—
KenBarbara—

Position means different things. In a radial layout the ring is a hop count; in a flow, columns are stages; in a tiered layout, the group. A force layout's nearness is only a hint.

Sourcecomponents/charts/network-graph/doc.ts · components/charts/network-graph/network-graph.tsx · components/charts/network-graph/network-graph.module.css · components/charts/_kernel/graph.ts

components/charts/network-graph/doc.ts

/**
 * NetworkGraph — things and the connections between them.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     nodes         { id, label?, group?, size? }[]
 *     edges         { from, to, value? (signed −1–1), weight?, label? }[]
 *     layout?       "force" (default) | "radial" | "circular" | "tiered" |
 *                   "flow" | "grid"
 *     root?         the centre of a radial layout
 *     groups?       groups in a FIXED order: colour, shape, and tiers
 *     curved?, labels?, hulls? ({ key, label?, ids }[])
 *     onSelect?, selected?   selection
 *     aspect?, formatValue?, animation?  (default: nodes pop in)
 *
 * # Behaviour
 *
 * R1  The same input always gives the same picture, on the server and in
 *     every browser: layouts are deterministic.
 * R2  Position means what the layout says: force — nearness is a hint;
 *     radial — the ring is hops from the root; circular — nothing (so no
 *     node hides another); tiered — the group; flow — columns by longest
 *     path, every edge pointing forward; grid — nothing, in rows.
 * R3  Groups have a colour AND a shape. Size is drawn as area.
 * R4  A signed edge's hue is its sign and its opacity its strength; a
 *     weight is its width. An edge to a node that does not exist is ignored.
 * R5  Hover or focus lights a node and its neighbours and dims the rest.
 *     Selection is one tab stop: arrow keys move, Enter or Space selects,
 *     Escape clears.
 * R6  Changing the layout (or the data) moves nodes to their new places;
 *     a new node appears where it belongs.
 * R7  With hulls, the force layout gathers each group into its own region
 *     (up to five groups), so a hull outlines a place, not a scatter.
 * R8  Two tables list every node (with its connections) and every edge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The force layout is d3-force, run synchronously for a fixed number of
 * ticks with hash-seeded starting positions and a seeded random source. A
 * force simulation is chaotic, and ECMAScript leaves cos, sin, hypot, exp,
 * and log to each engine, so none run inside it (d3's only trigonometry is
 * the starting spiral, which seeded positions replace); sqrt and arithmetic
 * are exactly specified by IEEE-754. Labels are HTML placed by percentage.
 */
export {};

components/charts/network-graph/network-graph.tsx

"use client";

import {
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { radiusFor, shapePath } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import {
  edgePath,
  graphTarget,
  hullAround,
  liveEdges,
  neighbours,
  type GraphEdge,
  type GraphLayout,
  type GraphNode,
  type Point,
} from "../_kernel/graph";
import { px } from "../_kernel/scale";
import { categoryColor, seriesShape } from "../_kernel/scatter";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./network-graph.module.css";

export type { GraphEdge, GraphLayout, GraphNode };

export type NetworkGraphProps = {
  /** Names the chart. */
  label: string;
  nodes: readonly GraphNode[];
  edges: readonly GraphEdge[];
  /** How position is decided — and so what it means. */
  layout?: GraphLayout;
  /** The centre of a radial layout. */
  root?: string;
  /** Groups in a FIXED order: colour, shape, and the tiers of a tiered
   *  layout. */
  groups?: readonly string[];
  /** Bow edges into arcs, where straight chords would overlap. */
  curved?: boolean;
  /** Print node names; off for dense graphs where they would be a wash. */
  labels?: boolean;
  /** Labelled regions drawn behind groups of nodes. */
  hulls?: readonly { key: string; label?: string; ids: readonly string[] }[];
  /** Makes nodes selectable: one tab stop, arrow keys move between nodes. */
  onSelect?: (id: string | null) => void;
  selected?: string | null;
  /** Width over height. */
  aspect?: number;
  formatValue?: (value: number) => string;
  /** Default: nodes pop in; changing the layout glides them to their new
   *  places. */
  animation?: AnimationProp;
  className?: string;
};

/** Things and the connections between them. What position means depends
 *  on the layout — a force layout's nearness is a hint, a radial ring is a
 *  hop count — so choose the layout for the question. Hover or focus a node
 *  to light its neighbours; the tables list every node and connection. */
export function NetworkGraph({
  label,
  nodes,
  edges: rawEdges,
  layout = "force",
  root,
  groups: groupOrder,
  curved = false,
  labels = true,
  hulls = [],
  onSelect,
  selected = null,
  aspect = 1.6,
  formatValue = formatExact,
  animation,
  className,
}: NetworkGraphProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "pop",
    axis: "y",
  });
  const edges = useMemo(() => liveEdges(nodes, rawEdges), [nodes, rawEdges]);
  const groups = useMemo(
    () =>
      groupOrder ??
      [...new Set(nodes.map((n) => n.group ?? ""))].filter(Boolean),
    [groupOrder, nodes],
  );
  // The layout is computed once per input, not per hover or tween frame.
  const target = useMemo(
    () =>
      graphTarget(nodes, edges, layout, aspect, {
        root,
        groups,
        // With hulls the groups are the point, so the force layout gathers
        // each into its own region.
        cluster: hulls.length > 0,
      }),
    [nodes, edges, layout, aspect, root, groups, hulls.length],
  );
  const shown = useTweened(target, update, "target");
  const near = useMemo(() => neighbours(nodes, edges), [nodes, edges]);

  const [pointed, setPointed] = useState<string | null>(null);
  const [active, setActive] = useState(0);
  const svg = useRef<SVGSVGElement>(null);
  const lit = pointed ?? selected;
  const stateOf = (id: string) =>
    lit === null
      ? undefined
      : id === lit
        ? "lit"
        : near.get(lit)?.has(id)
          ? "near"
          : "dim";

  // The frame fits the laid-out nodes, with room for their labels.
  const at = (id: string): Point | null => {
    const x = shown[`${id}|x`];
    const y = shown[`${id}|y`];
    return x === undefined || y === undefined ? null : { x, y };
  };
  const finals = nodes.flatMap((n) => {
    const x = target[`${n.id}|x`];
    const y = target[`${n.id}|y`];
    return x === undefined ? [] : [{ x, y }];
  });
  const left = Math.min(0, ...finals.map((p) => p.x)) - 60;
  const right = Math.max(1000, ...finals.map((p) => p.x)) + 60;
  const top = Math.min(0, ...finals.map((p) => p.y)) - 50;
  const bottom = Math.max(1000 / aspect, ...finals.map((p) => p.y)) + 50;
  const width = right - left;
  const height = bottom - top;
  const pct = (p: Point) => ({
    left: `${((p.x - left) / width) * 100}%`,
    top: `${((p.y - top) / height) * 100}%`,
  });

  const sizes = nodes
    .map((n) => n.size)
    .filter((s): s is number => typeof s === "number" && s > 0);
  const range: [number, number] = sizes.length
    ? [Math.min(...sizes), Math.max(...sizes)]
    : [1, 1];
  const radius = (n: GraphNode) =>
    n.size && n.size > 0 ? radiusFor(n.size, range, [9, 26]) : 11;
  const maxWeight = Math.max(1, ...edges.map((e) => e.weight ?? 0));
  const degree = (id: string) => near.get(id)?.size ?? 0;

  const select = (id: string) => onSelect?.(selected === id ? null : id);
  const move = (event: KeyboardEvent) => {
    if (!onSelect) return;
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[
      event.key
    ];
    if (event.key === "Escape") {
      onSelect(null);
      return;
    }
    if (step === undefined) return;
    event.preventDefault();
    const next = (active + step + nodes.length) % nodes.length;
    setActive(next);
    svg.current?.querySelector<SVGGElement>(`[data-index="${next}"]`)?.focus();
  };

  if (!nodes.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {groups.length > 1 ? (
        <ChartLegend
          items={groups.map((group) => ({
            key: group,
            label: group,
            color: categoryColor(groups, group),
            shape: seriesShape(groups, group),
          }))}
        />
      ) : null}
      <div
        className={styles.frame}
        style={{ "--aspect": width / height } as CSSProperties}
      >
        <svg
          ref={svg}
          className={styles.svg}
          viewBox={`${px(left)} ${px(top)} ${px(width)} ${px(height)}`}
          role={onSelect ? "group" : "img"}
          aria-label={`${label}. Every node and connection is in the data tables.`}
          data-lit={lit ? "" : undefined}
          onKeyDown={onSelect ? move : undefined}
        >
          {hulls.map((hull, index) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape ? (
              <ellipse
                key={hull.key}
                className={styles.hull}
                style={
                  {
                    "--series": categoryColor(
                      hulls.map((h) => h.key),
                      hulls[index].key,
                    ),
                  } as CSSProperties
                }
                cx={shape.cx}
                cy={shape.cy}
                rx={shape.rx}
                ry={shape.ry}
              />
            ) : null;
          })}
          {edges.map((edge, index) => {
            const a = at(edge.from);
            const b = at(edge.to);
            if (!a || !b) return null;
            const signed = typeof edge.value === "number";
            return (
              <path
                key={`${edge.from}-${edge.to}-${index}`}
                className={styles.edge}
                d={edgePath(a, b, curved)}
                data-lit={
                  lit !== null && (edge.from === lit || edge.to === lit)
                    ? ""
                    : undefined
                }
                data-sign={
                  signed
                    ? (edge.value as number) < 0
                      ? "negative"
                      : "positive"
                    : undefined
                }
                style={
                  {
                    "--edge-w": 1 + ((edge.weight ?? 0) / maxWeight) * 3,
                    "--edge-o": signed
                      ? 0.25 +
                        Math.min(1, Math.abs(edge.value as number)) * 0.65
                      : undefined,
                  } as CSSProperties
                }
              />
            );
          })}
          {nodes.map((node, index) => {
            const p = at(node.id);
            if (!p) return null;
            const r = radius(node);
            const name = node.label ?? node.id;
            return (
              <g key={node.id} transform={`translate(${px(p.x)} ${px(p.y)})`}>
                <g
                  data-mark
                  data-index={index}
                  data-state={stateOf(node.id)}
                  className={styles.node}
                  style={
                    {
                      "--series": categoryColor(groups, node.group),
                    } as CSSProperties
                  }
                  onPointerEnter={() => setPointed(node.id)}
                  onPointerLeave={() => setPointed(null)}
                  onFocus={() => {
                    setPointed(node.id);
                    setActive(index);
                  }}
                  onBlur={() => setPointed(null)}
                  {...(onSelect
                    ? {
                        role: "button",
                        tabIndex: index === active ? 0 : -1,
                        "aria-label": `${name}${node.group ? `, ${node.group}` : ""}, ${degree(node.id)} connections`,
                        "aria-pressed": selected === node.id,
                        onClick: () => select(node.id),
                        onKeyDown: (event: KeyboardEvent) => {
                          if (event.key === "Enter" || event.key === " ") {
                            event.preventDefault();
                            select(node.id);
                          }
                        },
                      }
                    : {})}
                >
                  <circle className={styles.ring} r={r + 5} />
                  <path
                    className={styles.shape}
                    d={shapePath(seriesShape(groups, node.group), r)}
                  />
                </g>
              </g>
            );
          })}
        </svg>
        <div className={styles.labels} aria-hidden="true">
          {hulls.map((hull) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape && hull.label ? (
              <span
                key={hull.key}
                data-hull
                style={pct({ x: shape.cx, y: shape.cy - shape.ry })}
              >
                {hull.label}
              </span>
            ) : null;
          })}
          {labels
            ? nodes.map((node) => {
                const p = at(node.id);
                return p ? (
                  <span
                    key={node.id}
                    data-state={stateOf(node.id)}
                    style={pct({ x: p.x, y: p.y + radius(node) })}
                  >
                    {node.label ?? node.id}
                  </span>
                ) : null;
              })
            : null}
        </div>
      </div>
      <ChartData label={label} summary="View nodes">
        <THead>
          <Tr>
            <Th>Node</Th>
            <Th>Group</Th>
            <Th numeric>Connections</Th>
            {sizes.length ? <Th numeric>Size</Th> : null}
          </Tr>
        </THead>
        <TBody>
          {nodes.map((node) => (
            <Tr key={node.id}>
              <Th scope="row">{node.label ?? node.id}</Th>
              <Td>{node.group ?? "—"}</Td>
              <Td numeric>{degree(node.id)}</Td>
              {sizes.length ? (
                <Td numeric>
                  {typeof node.size === "number" ? formatValue(node.size) : "—"}
                </Td>
              ) : null}
            </Tr>
          ))}
        </TBody>
      </ChartData>
      <ChartData label={label} summary="View connections">
        <THead>
          <Tr>
            <Th>From</Th>
            <Th>To</Th>
            <Th numeric>Value</Th>
          </Tr>
        </THead>
        <TBody>
          {edges.map((edge, index) => {
            const name = (id: string) =>
              nodes.find((n) => n.id === id)?.label ?? id;
            const value = edge.value ?? edge.weight;
            return (
              <Tr key={index}>
                <Th scope="row">{name(edge.from)}</Th>
                <Td>{name(edge.to)}</Td>
                <Td numeric>
                  {typeof value === "number" ? formatValue(value) : "—"}
                </Td>
              </Tr>
            );
          })}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/network-graph/network-graph.module.css

@layer primitive {
  .frame {
    position: relative;
    width: 100%;
    aspect-ratio: var(--aspect, 1.6);
  }
  .svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  .hull {
    fill: color-mix(in oklab, var(--series) 12%, transparent);
    stroke: color-mix(in oklab, var(--series) 45%, transparent);
    stroke-dasharray: 4 4;
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
  .edge {
    fill: none;
    stroke: var(--chart-axis, var(--line-strong));
    stroke-opacity: 0.7;
    stroke-width: var(--edge-w, 1.25);
    vector-effect: non-scaling-stroke;
    transition: stroke-opacity var(--dur-2) var(--ease);
  }
  .edge[data-sign="positive"] {
    stroke: var(--chart-pos);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .edge[data-sign="negative"] {
    stroke: var(--chart-neg);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .svg[data-lit] .edge:not([data-lit]) {
    stroke-opacity: 0.12;
  }
  .node {
    transform-origin: center;
    transition: opacity var(--dur-2) var(--ease);
  }
  .shape {
    fill: var(--series);
    stroke: var(--surface-panel);
    stroke-width: 1.5;
    vector-effect: non-scaling-stroke;
  }
  .svg[data-lit] .node[data-state="dim"] {
    opacity: 0.25;
  }
  .ring {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    vector-effect: non-scaling-stroke;
    opacity: 0;
  }
  .node[data-state="lit"] .ring,
  .node[aria-pressed="true"] .ring {
    opacity: 1;
  }
  .node[role="button"] {
    cursor: pointer;
    outline: none;
  }
  .node[role="button"]:focus-visible .ring {
    stroke-dasharray: 3 2;
    opacity: 1;
  }
  .labels {
    position: absolute;
    inset: 0;
    pointer-events: none;
    font-size: var(--text-11);
  }
  .labels span {
    position: absolute;
    color: var(--ink-2);
    white-space: nowrap;
    transform: translate(-50%, 6px);
    transition: opacity var(--dur-2) var(--ease);
  }
  .labels span[data-state="dim"] {
    opacity: 0.25;
  }
  .labels span[data-hull] {
    color: var(--ink-3);
    font-weight: var(--weight-medium);
    transform: translate(-50%, -100%);
  }
}

components/charts/_kernel/graph.ts

import {
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  forceX,
  forceY,
  type SimulationNodeDatum,
} from "d3-force";

import { px } from "./scale";

/* NetworkGraph's maths: layouts and geometry, all deterministic, so the
   server and the browser draw the same picture.

   A force layout is chaotic: a one-bit difference in step one grows into a
   visibly different graph. ECMAScript leaves cos, sin, hypot, exp, and log
   to each engine, so none of them appear inside the simulation — starting
   positions come from a hash, the simulation's randomness from a seeded
   generator, and distances from sqrt, which IEEE-754 specifies exactly. */

export type GraphNode = {
  id: string;
  label?: string;
  /** A class: sets colour and shape (by `groups` order), and the tier in a
   *  tiered layout. */
  group?: string;
  /** A magnitude drawn as the node's area. */
  size?: number;
};

export type GraphEdge = {
  from: string;
  to: string;
  /** Signed, −1 to 1: hue is the sign, opacity the strength. */
  value?: number;
  /** Unsigned strength: drawn as width. */
  weight?: number;
  label?: string;
};

export type GraphLayout =
  "force" | "radial" | "circular" | "tiered" | "flow" | "grid";
export type Point = { x: number; y: number };

/** The virtual canvas layouts place nodes on. */
export const GRAPH_W = 1000;

/** FNV-1a → 0–1: a repeatable number per string. */
export function seedOf(text: string) {
  let h = 2166136261;
  for (let i = 0; i < text.length; i++) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) / 4294967296;
}

/** A seeded linear congruential generator for the simulation's jiggle. */
function lcg(seed: number) {
  let s = Math.floor(seed * 4294967296) >>> 0 || 1;
  return () => {
    s = (Math.imul(1664525, s) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** Edges whose ends both exist; a dangling edge is ignored, not guessed. */
export function liveEdges(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const ids = new Set(nodes.map((n) => n.id));
  return edges.filter(
    (e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to,
  );
}

export function neighbours(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const near = new Map<string, Set<string>>(
    nodes.map((n) => [n.id, new Set()]),
  );
  for (const e of edges) {
    near.get(e.from)?.add(e.to);
    near.get(e.to)?.add(e.from);
  }
  return near;
}

/** Hops from a root, walking edges both ways; unreached nodes go one ring
 *  beyond the furthest. */
export function hopsFrom(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  root: string,
) {
  const near = neighbours(nodes, edges);
  const depth = new Map<string, number>([[root, 0]]);
  let frontier = [root];
  while (frontier.length) {
    const next: string[] = [];
    for (const id of frontier)
      for (const other of near.get(id) ?? []) {
        if (depth.has(other)) continue;
        depth.set(other, (depth.get(id) ?? 0) + 1);
        next.push(other);
      }
    frontier = next;
  }
  const beyond = Math.max(0, ...depth.values()) + 1;
  for (const n of nodes) if (!depth.has(n.id)) depth.set(n.id, beyond);
  return depth;
}

/** Split edges into those that keep the graph acyclic and the "back"
 *  edges that close a loop, by depth-first search in input order: a loop is
 *  broken at the edge that closes it (c → a in a → b → c → a), not wherever
 *  a walk happens to land. */
export function splitLoops<E extends { from: string; to: string }>(
  nodes: readonly { id: string }[],
  edges: readonly E[],
) {
  const outgoing = new Map<string, E[]>(nodes.map((n) => [n.id, []]));
  for (const e of edges) outgoing.get(e.from)?.push(e);
  const state = new Map<string, 1 | 2>();
  const back = new Set<E>();
  const visit = (id: string) => {
    state.set(id, 1);
    for (const e of outgoing.get(id) ?? []) {
      const s = state.get(e.to);
      if (s === 1) back.add(e);
      else if (s === undefined) visit(e.to);
    }
    state.set(id, 2);
  };
  for (const n of nodes) if (!state.has(n.id)) visit(n.id);
  return {
    forward: edges.filter((e) => !back.has(e)),
    loops: edges.filter((e) => back.has(e)),
  };
}

/** Longest path from any source, for flow layouts; loops are broken first
 *  (see splitLoops). */
export function longestPath(
  nodes: readonly { id: string }[],
  edges: readonly { from: string; to: string }[],
) {
  const { forward } = splitLoops(nodes, edges);
  const incoming = new Map<string, string[]>(nodes.map((n) => [n.id, []]));
  for (const e of forward) incoming.get(e.to)?.push(e.from);
  const depth = new Map<string, number>();
  const walk = (id: string): number => {
    const known = depth.get(id);
    if (known !== undefined) return known;
    const from = incoming.get(id) ?? [];
    const d = from.length ? Math.max(...from.map((f) => walk(f) + 1)) : 0;
    depth.set(id, d);
    return d;
  };
  for (const n of nodes) walk(n.id);
  return depth;
}

type Options = { root?: string; groups: readonly string[]; cluster?: boolean };

type Layout = (
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  height: number,
  options: Options,
) => Map<string, Point>;

/** Where each group gathers when a force layout is clustered, as fractions
 *  of the canvas. A fixed table, not points on a circle: anything that feeds
 *  the simulation must avoid engine-dependent maths. */
const ANCHORS: readonly (readonly [number, number])[][] = [
  [[0.5, 0.5]],
  [
    [0.28, 0.5],
    [0.72, 0.5],
  ],
  [
    [0.5, 0.26],
    [0.26, 0.72],
    [0.74, 0.72],
  ],
  [
    [0.28, 0.28],
    [0.72, 0.28],
    [0.28, 0.72],
    [0.72, 0.72],
  ],
  [
    [0.5, 0.2],
    [0.2, 0.45],
    [0.8, 0.45],
    [0.32, 0.8],
    [0.68, 0.8],
  ],
];

const force: Layout = (nodes, edges, height, { groups, cluster }) => {
  type Sim = SimulationNodeDatum & { id: string };
  const sim: Sim[] = nodes.map((n) => ({
    id: n.id,
    x: GRAPH_W * (0.25 + seedOf(`${n.id}:x`) * 0.5),
    y: height * (0.25 + seedOf(`${n.id}:y`) * 0.5),
  }));
  const spacing = Math.sqrt((GRAPH_W * height) / Math.max(1, nodes.length));
  const simulation = forceSimulation(sim)
    .randomSource(lcg(seedOf(nodes.map((n) => n.id).join("|"))))
    .force(
      "link",
      forceLink<Sim, { source: string; target: string }>(
        edges.map((e) => ({ source: e.from, target: e.to })),
      )
        .id((d) => d.id)
        .distance(spacing * 0.55),
    )
    .force("charge", forceManyBody().strength(-spacing * 2))
    .force("collide", forceCollide(24))
    .stop();
  // Clustered, each group is drawn toward its own region (up to five
  // groups); otherwise everything is drawn gently toward the centre.
  const table = ANCHORS[Math.min(groups.length, ANCHORS.length) - 1];
  const groupOf = new Map(nodes.map((n) => [n.id, n.group ?? ""]));
  const anchor = (id: string) => {
    const index = groups.indexOf(groupOf.get(id) ?? "");
    return cluster && table && index >= 0 && index < table.length
      ? table[index]
      : [0.5, 0.5];
  };
  const pull = cluster ? 0.18 : 0.06;
  simulation
    .force("x", forceX<Sim>((d) => anchor(d.id)[0] * GRAPH_W).strength(pull))
    .force(
      "y",
      forceY<Sim>((d) => anchor(d.id)[1] * height).strength(
        pull * (GRAPH_W / height),
      ),
    );
  simulation.tick(300);
  return fit(
    new Map(sim.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }])),
    height,
  );
};

/** Scale positions uniformly (so shapes keep their proportions) to fill the
 *  canvas, less a margin for labels. */
function fit(points: Map<string, Point>, height: number, margin = 70) {
  if (points.size < 2)
    return new Map(
      [...points].map(([id]) => [id, { x: GRAPH_W / 2, y: height / 2 }]),
    );
  const xs = [...points.values()].map((p) => p.x);
  const ys = [...points.values()].map((p) => p.y);
  const [x0, x1, y0, y1] = [
    Math.min(...xs),
    Math.max(...xs),
    Math.min(...ys),
    Math.max(...ys),
  ];
  const scale = Math.min(
    (GRAPH_W - 2 * margin) / Math.max(1, x1 - x0),
    (height - 2 * margin) / Math.max(1, y1 - y0),
  );
  const ox = (GRAPH_W - (x1 - x0) * scale) / 2;
  const oy = (height - (y1 - y0) * scale) / 2;
  return new Map(
    [...points].map(([id, p]) => [
      id,
      { x: px(ox + (p.x - x0) * scale), y: px(oy + (p.y - y0) * scale) },
    ]),
  );
}

const circle = (
  count: number,
  index: number,
  cx: number,
  cy: number,
  r: number,
  turn = 0,
) => {
  const angle = (index / Math.max(1, count)) * 2 * Math.PI - Math.PI / 2 + turn;
  return { x: px(cx + r * Math.cos(angle)), y: px(cy + r * Math.sin(angle)) };
};

const radial: Layout = (nodes, edges, height, { root }) => {
  const centre = root ?? nodes[0]?.id ?? "";
  const depth = hopsFrom(nodes, edges, centre);
  const near = neighbours(nodes, edges);
  const rings = new Map<number, string[]>();
  for (const n of nodes) {
    if (n.id === centre) continue;
    const d = depth.get(n.id) ?? 1;
    rings.set(d, [...(rings.get(d) ?? []), n.id]);
  }
  const out = new Map<string, Point>([
    [centre, { x: GRAPH_W / 2, y: height / 2 }],
  ]);
  // Turns around the centre, per node, so a ring can follow the one inside.
  const turn = new Map<string, number>([[centre, 0]]);
  const count = Math.max(1, rings.size);
  const outer = Math.min(GRAPH_W, height) / 2 - 50;
  for (const [ring, ids] of [...rings].sort((a, b) => a[0] - b[0])) {
    // Order a ring by where its parent sits, so children stay beside their
    // parent and edges do not cross the middle.
    const parentTurn = (id: string) => {
      const parents = [...(near.get(id) ?? [])].filter(
        (p) => turn.has(p) && (depth.get(p) ?? 0) < ring,
      );
      return parents.length ? Math.min(...parents.map((p) => turn.get(p)!)) : 1;
    };
    const ordered = [...ids].sort(
      (a, b) => parentTurn(a) - parentTurn(b) || a.localeCompare(b),
    );
    const r = (outer * ring) / count;
    ordered.forEach((id, i) => {
      const t = (i + 0.5) / ordered.length;
      turn.set(id, t);
      out.set(id, circle(1, 0, GRAPH_W / 2, height / 2, r, t * 2 * Math.PI));
    });
  }
  return out;
};

const circular: Layout = (nodes, _edges, height) => {
  const r = Math.min(GRAPH_W, height) / 2 - 40;
  return new Map(
    nodes.map((n, i) => [
      n.id,
      circle(nodes.length, i, GRAPH_W / 2, height / 2, r),
    ]),
  );
};

const tiered: Layout = (nodes, _edges, height, { groups }) => {
  const order = [
    ...new Set([...groups, ...nodes.map((n) => n.group ?? "")]),
  ].filter((g) => nodes.some((n) => (n.group ?? "") === g));
  const out = new Map<string, Point>();
  order.forEach((group, column) => {
    const ids = nodes.filter((n) => (n.group ?? "") === group).map((n) => n.id);
    const x =
      order.length === 1
        ? GRAPH_W / 2
        : 60 + (column * (GRAPH_W - 120)) / (order.length - 1);
    ids.forEach((id, i) =>
      out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
    );
  });
  return out;
};

const flow: Layout = (nodes, edges, height) => {
  const depth = longestPath(nodes, edges);
  const columns = new Map<number, string[]>();
  for (const n of nodes) {
    const d = depth.get(n.id) ?? 0;
    columns.set(d, [...(columns.get(d) ?? []), n.id]);
  }
  const count = Math.max(1, columns.size);
  const out = new Map<string, Point>();
  [...columns]
    .sort((a, b) => a[0] - b[0])
    .forEach(([, ids], column) => {
      const x =
        count === 1
          ? GRAPH_W / 2
          : 60 + (column * (GRAPH_W - 120)) / (count - 1);
      ids.forEach((id, i) =>
        out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
      );
    });
  return out;
};

const grid: Layout = (nodes, _edges, height) => {
  const cols = Math.max(
    1,
    Math.ceil(Math.sqrt(nodes.length * (GRAPH_W / height))),
  );
  const rows = Math.max(1, Math.ceil(nodes.length / cols));
  return new Map(
    nodes.map((n, i) => [
      n.id,
      {
        x: px((((i % cols) + 1) * GRAPH_W) / (cols + 1)),
        y: px(((Math.floor(i / cols) + 1) * height) / (rows + 1)),
      },
    ]),
  );
};

const LAYOUTS: Record<GraphLayout, Layout> = {
  force,
  radial,
  circular,
  tiered,
  flow,
  grid,
};

/** Every node's position, as a record for tweening: `${id}|x`, `${id}|y`. */
export function graphTarget(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  layout: GraphLayout,
  aspect: number,
  options: Options,
) {
  const height = GRAPH_W / aspect;
  const placed = nodes.length
    ? LAYOUTS[layout](nodes, edges, height, options)
    : new Map();
  const target: Record<string, number> = {};
  for (const [id, p] of placed) {
    target[`${id}|x`] = p.x;
    target[`${id}|y`] = p.y;
  }
  return target;
}

/** A straight edge, or a shallow arc that bows with length — straight for
 *  radial layouts, arcs where chords would overlap into a disc. */
export function edgePath(a: Point, b: Point, curved: boolean) {
  if (!curved) return `M${px(a.x)},${px(a.y)}L${px(b.x)},${px(b.y)}`;
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distance = Math.sqrt(dx * dx + dy * dy) || 1;
  const bow = Math.min(distance * 0.18, 60);
  const mx = (a.x + b.x) / 2 - (dy / distance) * bow;
  const my = (a.y + b.y) / 2 + (dx / distance) * bow;
  return `M${px(a.x)},${px(a.y)}Q${px(mx)},${px(my)} ${px(b.x)},${px(b.y)}`;
}

/** An ellipse around a set of positions, for a labelled group (a hull). */
export function hullAround(points: readonly Point[]) {
  if (!points.length) return null;
  const cx = points.reduce((sum, p) => sum + p.x, 0) / points.length;
  const cy = points.reduce((sum, p) => sum + p.y, 0) / points.length;
  const r =
    Math.max(
      34,
      ...points.map((p) => {
        const dx = p.x - cx;
        const dy = p.y - cy;
        return Math.sqrt(dx * dx + dy * dy);
      }),
    ) + 30;
  return { cx: px(cx), cy: px(cy), rx: px(r * 1.12), ry: px(r) };
}

Annotated graphs

signed edges · hulls · a hairball

Correlations

Hue is the sign, opacity the strength; a missing edge is below the threshold, not zero.

View nodes for Correlations between product metrics, above 0.3
Correlations between product metrics, above 0.3
NodeGroupConnections
SeatsMetric3
DeploysMetric3
API callsMetric2
TicketsMetric3
Churn riskMetric4
NPSMetric3
LatencyMetric2
View connections for Correlations between product metrics, above 0.3
Correlations between product metrics, above 0.3
FromToValue
SeatsDeploys0.62
SeatsAPI calls0.71
DeploysAPI calls0.83
TicketsChurn risk0.57
SeatsChurn risk-0.44
DeploysChurn risk-0.51
NPSChurn risk-0.68
NPSTickets-0.46
LatencyTickets0.49
LatencyNPS-0.38
Modules

Hulls name the communities; the layout placed them.

  • Collaboration
  • Shipping
  • Administration
View nodes for Feature communities
Feature communities
NodeGroupConnections
CommentsCollaboration2
MentionsCollaboration2
ReviewsCollaboration2
PresenceCollaboration1
SharingCollaboration3
DeploysShipping3
PreviewsShipping2
RollbacksShipping1
CanariesShipping2
SSOAdministration2
RolesAdministration2
Audit logAdministration3
BillingAdministration4
QuotasAdministration1
View connections for Feature communities
Feature communities
FromToValue
CommentsMentions—
CommentsPresence—
MentionsSharing—
ReviewsSharing—
DeploysRollbacks—
DeploysCanaries—
PreviewsCanaries—
SSOAudit log—
SSOBilling—
RolesBilling—
Audit logBilling—
BillingQuotas—
ReviewsPreviews—
SharingRoles—
DeploysAudit log—
Hairball

Labels off: at this density the honest reading is a texture.

  • A
  • B
  • C
View nodes for A dense graph of 60 nodes
A dense graph of 60 nodes
NodeGroupConnections
n0A6
n1B5
n2C5
n3A7
n4B5
n5C6
n6A5
n7B5
n8C4
n9A4
n10B3
n11C9
n12A8
n13B7
n14C6
n15A5
n16B7
n17C6
n18A7
n19B5
n20C7
n21A5
n22B6
n23C7
n24A5
n25B8
n26C7
n27A6
n28B8
n29C4
n30A4
n31B5
n32C4
n33A6
n34B8
n35C5
n36A9
n37B4
n38C6
n39A7
n40B6
n41C6
n42A5
n43B6
n44C4
n45A6
n46B7
n47C6
n48A6
n49B8
n50C6
n51A5
n52B5
n53C7
n54A7
n55B5
n56C4
n57A4
n58B6
n59C3
View connections for A dense graph of 60 nodes
A dense graph of 60 nodes
FromToValue
n0n25—
n0n26—
n0n16—
n1n22—
n1n28—
n1n53—
n2n52—
n2n20—
n2n26—
n3n46—
n3n11—
n3n20—
n4n36—
n4n20—
n4n5—
n5n54—
n5n12—
n5n33—
n6n49—
n6n3—
n6n29—
n7n13—
n7n32—
n7n27—
n8n5—
n8n40—
n8n48—
n9n53—
n9n38—
n9n35—
n10n11—
n10n19—
n10n22—
n11n18—
n11n38—
n11n25—
n12n23—
n12n55—
n12n34—
n13n45—
n13n18—
n13n51—
n14n55—
n14n47—
n14n38—
n15n7—
n15n28—
n15n3—
n16n6—
n16n43—
n16n23—
n17n47—
n17n16—
n17n12—
n18n39—
n18n50—
n18n2—
n19n1—
n19n7—
n19n12—
n20n36—
n20n45—
n20n16—
n21n58—
n21n12—
n21n54—
n22n58—
n22n15—
n22n14—
n23n0—
n23n5—
n23n45—
n24n49—
n24n43—
n25n18—
n25n13—
n25n28—
n26n47—
n26n14—
n26n22—
n27n9—
n27n33—
n27n23—
n28n3—
n28n33—
n28n54—
n29n21—
n29n24—
n29n43—
n30n20—
n30n11—
n30n41—
n31n50—
n31n34—
n31n56—
n32n27—
n32n2—
n32n41—
n33n0—
n33n40—
n33n1—
n34n42—
n34n38—
n34n15—
n35n30—
n35n50—
n35n13—
n36n35—
n36n26—
n36n27—
n37n54—
n37n0—
n38n44—
n38n24—
n38n44—
n39n36—
n39n48—
n39n34—
n40n39—
n40n49—
n40n28—
n41n51—
n41n24—
n41n3—
n42n11—
n42n14—
n42n39—
n43n4—
n43n24—
n44n57—
n44n17—
n44n36—
n45n25—
n45n6—
n45n19—
n46n34—
n46n13—
n46n25—
n47n4—
n47n11—
n47n52—
n48n58—
n48n37—
n48n53—
n49n26—
n49n31—
n49n18—
n50n23—
n50n8—
n50n17—
n51n40—
n51n28—
n51n36—
n52n2—
n52n49—
n52n41—
n53n25—
n53n49—
n53n36—
n54n46—
n54n48—
n54n16—
n55n21—
n55n31—
n55n46—
n56n52—
n56n12—
n56n34—
n57n43—
n57n39—
n57n42—
n58n17—
n58n43—
n58n46—
n59n11—
n59n37—
n59n53—
Sourcecomponents/charts/network-graph/doc.ts · components/charts/network-graph/network-graph.tsx · components/charts/network-graph/network-graph.module.css · components/charts/_kernel/graph.ts

components/charts/network-graph/doc.ts

/**
 * NetworkGraph — things and the connections between them.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     nodes         { id, label?, group?, size? }[]
 *     edges         { from, to, value? (signed −1–1), weight?, label? }[]
 *     layout?       "force" (default) | "radial" | "circular" | "tiered" |
 *                   "flow" | "grid"
 *     root?         the centre of a radial layout
 *     groups?       groups in a FIXED order: colour, shape, and tiers
 *     curved?, labels?, hulls? ({ key, label?, ids }[])
 *     onSelect?, selected?   selection
 *     aspect?, formatValue?, animation?  (default: nodes pop in)
 *
 * # Behaviour
 *
 * R1  The same input always gives the same picture, on the server and in
 *     every browser: layouts are deterministic.
 * R2  Position means what the layout says: force — nearness is a hint;
 *     radial — the ring is hops from the root; circular — nothing (so no
 *     node hides another); tiered — the group; flow — columns by longest
 *     path, every edge pointing forward; grid — nothing, in rows.
 * R3  Groups have a colour AND a shape. Size is drawn as area.
 * R4  A signed edge's hue is its sign and its opacity its strength; a
 *     weight is its width. An edge to a node that does not exist is ignored.
 * R5  Hover or focus lights a node and its neighbours and dims the rest.
 *     Selection is one tab stop: arrow keys move, Enter or Space selects,
 *     Escape clears.
 * R6  Changing the layout (or the data) moves nodes to their new places;
 *     a new node appears where it belongs.
 * R7  With hulls, the force layout gathers each group into its own region
 *     (up to five groups), so a hull outlines a place, not a scatter.
 * R8  Two tables list every node (with its connections) and every edge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The force layout is d3-force, run synchronously for a fixed number of
 * ticks with hash-seeded starting positions and a seeded random source. A
 * force simulation is chaotic, and ECMAScript leaves cos, sin, hypot, exp,
 * and log to each engine, so none run inside it (d3's only trigonometry is
 * the starting spiral, which seeded positions replace); sqrt and arithmetic
 * are exactly specified by IEEE-754. Labels are HTML placed by percentage.
 */
export {};

components/charts/network-graph/network-graph.tsx

"use client";

import {
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { radiusFor, shapePath } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import {
  edgePath,
  graphTarget,
  hullAround,
  liveEdges,
  neighbours,
  type GraphEdge,
  type GraphLayout,
  type GraphNode,
  type Point,
} from "../_kernel/graph";
import { px } from "../_kernel/scale";
import { categoryColor, seriesShape } from "../_kernel/scatter";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./network-graph.module.css";

export type { GraphEdge, GraphLayout, GraphNode };

export type NetworkGraphProps = {
  /** Names the chart. */
  label: string;
  nodes: readonly GraphNode[];
  edges: readonly GraphEdge[];
  /** How position is decided — and so what it means. */
  layout?: GraphLayout;
  /** The centre of a radial layout. */
  root?: string;
  /** Groups in a FIXED order: colour, shape, and the tiers of a tiered
   *  layout. */
  groups?: readonly string[];
  /** Bow edges into arcs, where straight chords would overlap. */
  curved?: boolean;
  /** Print node names; off for dense graphs where they would be a wash. */
  labels?: boolean;
  /** Labelled regions drawn behind groups of nodes. */
  hulls?: readonly { key: string; label?: string; ids: readonly string[] }[];
  /** Makes nodes selectable: one tab stop, arrow keys move between nodes. */
  onSelect?: (id: string | null) => void;
  selected?: string | null;
  /** Width over height. */
  aspect?: number;
  formatValue?: (value: number) => string;
  /** Default: nodes pop in; changing the layout glides them to their new
   *  places. */
  animation?: AnimationProp;
  className?: string;
};

/** Things and the connections between them. What position means depends
 *  on the layout — a force layout's nearness is a hint, a radial ring is a
 *  hop count — so choose the layout for the question. Hover or focus a node
 *  to light its neighbours; the tables list every node and connection. */
export function NetworkGraph({
  label,
  nodes,
  edges: rawEdges,
  layout = "force",
  root,
  groups: groupOrder,
  curved = false,
  labels = true,
  hulls = [],
  onSelect,
  selected = null,
  aspect = 1.6,
  formatValue = formatExact,
  animation,
  className,
}: NetworkGraphProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "pop",
    axis: "y",
  });
  const edges = useMemo(() => liveEdges(nodes, rawEdges), [nodes, rawEdges]);
  const groups = useMemo(
    () =>
      groupOrder ??
      [...new Set(nodes.map((n) => n.group ?? ""))].filter(Boolean),
    [groupOrder, nodes],
  );
  // The layout is computed once per input, not per hover or tween frame.
  const target = useMemo(
    () =>
      graphTarget(nodes, edges, layout, aspect, {
        root,
        groups,
        // With hulls the groups are the point, so the force layout gathers
        // each into its own region.
        cluster: hulls.length > 0,
      }),
    [nodes, edges, layout, aspect, root, groups, hulls.length],
  );
  const shown = useTweened(target, update, "target");
  const near = useMemo(() => neighbours(nodes, edges), [nodes, edges]);

  const [pointed, setPointed] = useState<string | null>(null);
  const [active, setActive] = useState(0);
  const svg = useRef<SVGSVGElement>(null);
  const lit = pointed ?? selected;
  const stateOf = (id: string) =>
    lit === null
      ? undefined
      : id === lit
        ? "lit"
        : near.get(lit)?.has(id)
          ? "near"
          : "dim";

  // The frame fits the laid-out nodes, with room for their labels.
  const at = (id: string): Point | null => {
    const x = shown[`${id}|x`];
    const y = shown[`${id}|y`];
    return x === undefined || y === undefined ? null : { x, y };
  };
  const finals = nodes.flatMap((n) => {
    const x = target[`${n.id}|x`];
    const y = target[`${n.id}|y`];
    return x === undefined ? [] : [{ x, y }];
  });
  const left = Math.min(0, ...finals.map((p) => p.x)) - 60;
  const right = Math.max(1000, ...finals.map((p) => p.x)) + 60;
  const top = Math.min(0, ...finals.map((p) => p.y)) - 50;
  const bottom = Math.max(1000 / aspect, ...finals.map((p) => p.y)) + 50;
  const width = right - left;
  const height = bottom - top;
  const pct = (p: Point) => ({
    left: `${((p.x - left) / width) * 100}%`,
    top: `${((p.y - top) / height) * 100}%`,
  });

  const sizes = nodes
    .map((n) => n.size)
    .filter((s): s is number => typeof s === "number" && s > 0);
  const range: [number, number] = sizes.length
    ? [Math.min(...sizes), Math.max(...sizes)]
    : [1, 1];
  const radius = (n: GraphNode) =>
    n.size && n.size > 0 ? radiusFor(n.size, range, [9, 26]) : 11;
  const maxWeight = Math.max(1, ...edges.map((e) => e.weight ?? 0));
  const degree = (id: string) => near.get(id)?.size ?? 0;

  const select = (id: string) => onSelect?.(selected === id ? null : id);
  const move = (event: KeyboardEvent) => {
    if (!onSelect) return;
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[
      event.key
    ];
    if (event.key === "Escape") {
      onSelect(null);
      return;
    }
    if (step === undefined) return;
    event.preventDefault();
    const next = (active + step + nodes.length) % nodes.length;
    setActive(next);
    svg.current?.querySelector<SVGGElement>(`[data-index="${next}"]`)?.focus();
  };

  if (!nodes.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {groups.length > 1 ? (
        <ChartLegend
          items={groups.map((group) => ({
            key: group,
            label: group,
            color: categoryColor(groups, group),
            shape: seriesShape(groups, group),
          }))}
        />
      ) : null}
      <div
        className={styles.frame}
        style={{ "--aspect": width / height } as CSSProperties}
      >
        <svg
          ref={svg}
          className={styles.svg}
          viewBox={`${px(left)} ${px(top)} ${px(width)} ${px(height)}`}
          role={onSelect ? "group" : "img"}
          aria-label={`${label}. Every node and connection is in the data tables.`}
          data-lit={lit ? "" : undefined}
          onKeyDown={onSelect ? move : undefined}
        >
          {hulls.map((hull, index) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape ? (
              <ellipse
                key={hull.key}
                className={styles.hull}
                style={
                  {
                    "--series": categoryColor(
                      hulls.map((h) => h.key),
                      hulls[index].key,
                    ),
                  } as CSSProperties
                }
                cx={shape.cx}
                cy={shape.cy}
                rx={shape.rx}
                ry={shape.ry}
              />
            ) : null;
          })}
          {edges.map((edge, index) => {
            const a = at(edge.from);
            const b = at(edge.to);
            if (!a || !b) return null;
            const signed = typeof edge.value === "number";
            return (
              <path
                key={`${edge.from}-${edge.to}-${index}`}
                className={styles.edge}
                d={edgePath(a, b, curved)}
                data-lit={
                  lit !== null && (edge.from === lit || edge.to === lit)
                    ? ""
                    : undefined
                }
                data-sign={
                  signed
                    ? (edge.value as number) < 0
                      ? "negative"
                      : "positive"
                    : undefined
                }
                style={
                  {
                    "--edge-w": 1 + ((edge.weight ?? 0) / maxWeight) * 3,
                    "--edge-o": signed
                      ? 0.25 +
                        Math.min(1, Math.abs(edge.value as number)) * 0.65
                      : undefined,
                  } as CSSProperties
                }
              />
            );
          })}
          {nodes.map((node, index) => {
            const p = at(node.id);
            if (!p) return null;
            const r = radius(node);
            const name = node.label ?? node.id;
            return (
              <g key={node.id} transform={`translate(${px(p.x)} ${px(p.y)})`}>
                <g
                  data-mark
                  data-index={index}
                  data-state={stateOf(node.id)}
                  className={styles.node}
                  style={
                    {
                      "--series": categoryColor(groups, node.group),
                    } as CSSProperties
                  }
                  onPointerEnter={() => setPointed(node.id)}
                  onPointerLeave={() => setPointed(null)}
                  onFocus={() => {
                    setPointed(node.id);
                    setActive(index);
                  }}
                  onBlur={() => setPointed(null)}
                  {...(onSelect
                    ? {
                        role: "button",
                        tabIndex: index === active ? 0 : -1,
                        "aria-label": `${name}${node.group ? `, ${node.group}` : ""}, ${degree(node.id)} connections`,
                        "aria-pressed": selected === node.id,
                        onClick: () => select(node.id),
                        onKeyDown: (event: KeyboardEvent) => {
                          if (event.key === "Enter" || event.key === " ") {
                            event.preventDefault();
                            select(node.id);
                          }
                        },
                      }
                    : {})}
                >
                  <circle className={styles.ring} r={r + 5} />
                  <path
                    className={styles.shape}
                    d={shapePath(seriesShape(groups, node.group), r)}
                  />
                </g>
              </g>
            );
          })}
        </svg>
        <div className={styles.labels} aria-hidden="true">
          {hulls.map((hull) => {
            const shape = hullAround(hull.ids.flatMap((id) => at(id) ?? []));
            return shape && hull.label ? (
              <span
                key={hull.key}
                data-hull
                style={pct({ x: shape.cx, y: shape.cy - shape.ry })}
              >
                {hull.label}
              </span>
            ) : null;
          })}
          {labels
            ? nodes.map((node) => {
                const p = at(node.id);
                return p ? (
                  <span
                    key={node.id}
                    data-state={stateOf(node.id)}
                    style={pct({ x: p.x, y: p.y + radius(node) })}
                  >
                    {node.label ?? node.id}
                  </span>
                ) : null;
              })
            : null}
        </div>
      </div>
      <ChartData label={label} summary="View nodes">
        <THead>
          <Tr>
            <Th>Node</Th>
            <Th>Group</Th>
            <Th numeric>Connections</Th>
            {sizes.length ? <Th numeric>Size</Th> : null}
          </Tr>
        </THead>
        <TBody>
          {nodes.map((node) => (
            <Tr key={node.id}>
              <Th scope="row">{node.label ?? node.id}</Th>
              <Td>{node.group ?? "—"}</Td>
              <Td numeric>{degree(node.id)}</Td>
              {sizes.length ? (
                <Td numeric>
                  {typeof node.size === "number" ? formatValue(node.size) : "—"}
                </Td>
              ) : null}
            </Tr>
          ))}
        </TBody>
      </ChartData>
      <ChartData label={label} summary="View connections">
        <THead>
          <Tr>
            <Th>From</Th>
            <Th>To</Th>
            <Th numeric>Value</Th>
          </Tr>
        </THead>
        <TBody>
          {edges.map((edge, index) => {
            const name = (id: string) =>
              nodes.find((n) => n.id === id)?.label ?? id;
            const value = edge.value ?? edge.weight;
            return (
              <Tr key={index}>
                <Th scope="row">{name(edge.from)}</Th>
                <Td>{name(edge.to)}</Td>
                <Td numeric>
                  {typeof value === "number" ? formatValue(value) : "—"}
                </Td>
              </Tr>
            );
          })}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/network-graph/network-graph.module.css

@layer primitive {
  .frame {
    position: relative;
    width: 100%;
    aspect-ratio: var(--aspect, 1.6);
  }
  .svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  .hull {
    fill: color-mix(in oklab, var(--series) 12%, transparent);
    stroke: color-mix(in oklab, var(--series) 45%, transparent);
    stroke-dasharray: 4 4;
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
  .edge {
    fill: none;
    stroke: var(--chart-axis, var(--line-strong));
    stroke-opacity: 0.7;
    stroke-width: var(--edge-w, 1.25);
    vector-effect: non-scaling-stroke;
    transition: stroke-opacity var(--dur-2) var(--ease);
  }
  .edge[data-sign="positive"] {
    stroke: var(--chart-pos);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .edge[data-sign="negative"] {
    stroke: var(--chart-neg);
    stroke-opacity: var(--edge-o, 0.7);
  }
  .svg[data-lit] .edge:not([data-lit]) {
    stroke-opacity: 0.12;
  }
  .node {
    transform-origin: center;
    transition: opacity var(--dur-2) var(--ease);
  }
  .shape {
    fill: var(--series);
    stroke: var(--surface-panel);
    stroke-width: 1.5;
    vector-effect: non-scaling-stroke;
  }
  .svg[data-lit] .node[data-state="dim"] {
    opacity: 0.25;
  }
  .ring {
    fill: none;
    stroke: var(--accent);
    stroke-width: 2.5;
    vector-effect: non-scaling-stroke;
    opacity: 0;
  }
  .node[data-state="lit"] .ring,
  .node[aria-pressed="true"] .ring {
    opacity: 1;
  }
  .node[role="button"] {
    cursor: pointer;
    outline: none;
  }
  .node[role="button"]:focus-visible .ring {
    stroke-dasharray: 3 2;
    opacity: 1;
  }
  .labels {
    position: absolute;
    inset: 0;
    pointer-events: none;
    font-size: var(--text-11);
  }
  .labels span {
    position: absolute;
    color: var(--ink-2);
    white-space: nowrap;
    transform: translate(-50%, 6px);
    transition: opacity var(--dur-2) var(--ease);
  }
  .labels span[data-state="dim"] {
    opacity: 0.25;
  }
  .labels span[data-hull] {
    color: var(--ink-3);
    font-weight: var(--weight-medium);
    transform: translate(-50%, -100%);
  }
}

components/charts/_kernel/graph.ts

import {
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  forceX,
  forceY,
  type SimulationNodeDatum,
} from "d3-force";

import { px } from "./scale";

/* NetworkGraph's maths: layouts and geometry, all deterministic, so the
   server and the browser draw the same picture.

   A force layout is chaotic: a one-bit difference in step one grows into a
   visibly different graph. ECMAScript leaves cos, sin, hypot, exp, and log
   to each engine, so none of them appear inside the simulation — starting
   positions come from a hash, the simulation's randomness from a seeded
   generator, and distances from sqrt, which IEEE-754 specifies exactly. */

export type GraphNode = {
  id: string;
  label?: string;
  /** A class: sets colour and shape (by `groups` order), and the tier in a
   *  tiered layout. */
  group?: string;
  /** A magnitude drawn as the node's area. */
  size?: number;
};

export type GraphEdge = {
  from: string;
  to: string;
  /** Signed, −1 to 1: hue is the sign, opacity the strength. */
  value?: number;
  /** Unsigned strength: drawn as width. */
  weight?: number;
  label?: string;
};

export type GraphLayout =
  "force" | "radial" | "circular" | "tiered" | "flow" | "grid";
export type Point = { x: number; y: number };

/** The virtual canvas layouts place nodes on. */
export const GRAPH_W = 1000;

/** FNV-1a → 0–1: a repeatable number per string. */
export function seedOf(text: string) {
  let h = 2166136261;
  for (let i = 0; i < text.length; i++) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) / 4294967296;
}

/** A seeded linear congruential generator for the simulation's jiggle. */
function lcg(seed: number) {
  let s = Math.floor(seed * 4294967296) >>> 0 || 1;
  return () => {
    s = (Math.imul(1664525, s) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** Edges whose ends both exist; a dangling edge is ignored, not guessed. */
export function liveEdges(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const ids = new Set(nodes.map((n) => n.id));
  return edges.filter(
    (e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to,
  );
}

export function neighbours(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
) {
  const near = new Map<string, Set<string>>(
    nodes.map((n) => [n.id, new Set()]),
  );
  for (const e of edges) {
    near.get(e.from)?.add(e.to);
    near.get(e.to)?.add(e.from);
  }
  return near;
}

/** Hops from a root, walking edges both ways; unreached nodes go one ring
 *  beyond the furthest. */
export function hopsFrom(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  root: string,
) {
  const near = neighbours(nodes, edges);
  const depth = new Map<string, number>([[root, 0]]);
  let frontier = [root];
  while (frontier.length) {
    const next: string[] = [];
    for (const id of frontier)
      for (const other of near.get(id) ?? []) {
        if (depth.has(other)) continue;
        depth.set(other, (depth.get(id) ?? 0) + 1);
        next.push(other);
      }
    frontier = next;
  }
  const beyond = Math.max(0, ...depth.values()) + 1;
  for (const n of nodes) if (!depth.has(n.id)) depth.set(n.id, beyond);
  return depth;
}

/** Split edges into those that keep the graph acyclic and the "back"
 *  edges that close a loop, by depth-first search in input order: a loop is
 *  broken at the edge that closes it (c → a in a → b → c → a), not wherever
 *  a walk happens to land. */
export function splitLoops<E extends { from: string; to: string }>(
  nodes: readonly { id: string }[],
  edges: readonly E[],
) {
  const outgoing = new Map<string, E[]>(nodes.map((n) => [n.id, []]));
  for (const e of edges) outgoing.get(e.from)?.push(e);
  const state = new Map<string, 1 | 2>();
  const back = new Set<E>();
  const visit = (id: string) => {
    state.set(id, 1);
    for (const e of outgoing.get(id) ?? []) {
      const s = state.get(e.to);
      if (s === 1) back.add(e);
      else if (s === undefined) visit(e.to);
    }
    state.set(id, 2);
  };
  for (const n of nodes) if (!state.has(n.id)) visit(n.id);
  return {
    forward: edges.filter((e) => !back.has(e)),
    loops: edges.filter((e) => back.has(e)),
  };
}

/** Longest path from any source, for flow layouts; loops are broken first
 *  (see splitLoops). */
export function longestPath(
  nodes: readonly { id: string }[],
  edges: readonly { from: string; to: string }[],
) {
  const { forward } = splitLoops(nodes, edges);
  const incoming = new Map<string, string[]>(nodes.map((n) => [n.id, []]));
  for (const e of forward) incoming.get(e.to)?.push(e.from);
  const depth = new Map<string, number>();
  const walk = (id: string): number => {
    const known = depth.get(id);
    if (known !== undefined) return known;
    const from = incoming.get(id) ?? [];
    const d = from.length ? Math.max(...from.map((f) => walk(f) + 1)) : 0;
    depth.set(id, d);
    return d;
  };
  for (const n of nodes) walk(n.id);
  return depth;
}

type Options = { root?: string; groups: readonly string[]; cluster?: boolean };

type Layout = (
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  height: number,
  options: Options,
) => Map<string, Point>;

/** Where each group gathers when a force layout is clustered, as fractions
 *  of the canvas. A fixed table, not points on a circle: anything that feeds
 *  the simulation must avoid engine-dependent maths. */
const ANCHORS: readonly (readonly [number, number])[][] = [
  [[0.5, 0.5]],
  [
    [0.28, 0.5],
    [0.72, 0.5],
  ],
  [
    [0.5, 0.26],
    [0.26, 0.72],
    [0.74, 0.72],
  ],
  [
    [0.28, 0.28],
    [0.72, 0.28],
    [0.28, 0.72],
    [0.72, 0.72],
  ],
  [
    [0.5, 0.2],
    [0.2, 0.45],
    [0.8, 0.45],
    [0.32, 0.8],
    [0.68, 0.8],
  ],
];

const force: Layout = (nodes, edges, height, { groups, cluster }) => {
  type Sim = SimulationNodeDatum & { id: string };
  const sim: Sim[] = nodes.map((n) => ({
    id: n.id,
    x: GRAPH_W * (0.25 + seedOf(`${n.id}:x`) * 0.5),
    y: height * (0.25 + seedOf(`${n.id}:y`) * 0.5),
  }));
  const spacing = Math.sqrt((GRAPH_W * height) / Math.max(1, nodes.length));
  const simulation = forceSimulation(sim)
    .randomSource(lcg(seedOf(nodes.map((n) => n.id).join("|"))))
    .force(
      "link",
      forceLink<Sim, { source: string; target: string }>(
        edges.map((e) => ({ source: e.from, target: e.to })),
      )
        .id((d) => d.id)
        .distance(spacing * 0.55),
    )
    .force("charge", forceManyBody().strength(-spacing * 2))
    .force("collide", forceCollide(24))
    .stop();
  // Clustered, each group is drawn toward its own region (up to five
  // groups); otherwise everything is drawn gently toward the centre.
  const table = ANCHORS[Math.min(groups.length, ANCHORS.length) - 1];
  const groupOf = new Map(nodes.map((n) => [n.id, n.group ?? ""]));
  const anchor = (id: string) => {
    const index = groups.indexOf(groupOf.get(id) ?? "");
    return cluster && table && index >= 0 && index < table.length
      ? table[index]
      : [0.5, 0.5];
  };
  const pull = cluster ? 0.18 : 0.06;
  simulation
    .force("x", forceX<Sim>((d) => anchor(d.id)[0] * GRAPH_W).strength(pull))
    .force(
      "y",
      forceY<Sim>((d) => anchor(d.id)[1] * height).strength(
        pull * (GRAPH_W / height),
      ),
    );
  simulation.tick(300);
  return fit(
    new Map(sim.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }])),
    height,
  );
};

/** Scale positions uniformly (so shapes keep their proportions) to fill the
 *  canvas, less a margin for labels. */
function fit(points: Map<string, Point>, height: number, margin = 70) {
  if (points.size < 2)
    return new Map(
      [...points].map(([id]) => [id, { x: GRAPH_W / 2, y: height / 2 }]),
    );
  const xs = [...points.values()].map((p) => p.x);
  const ys = [...points.values()].map((p) => p.y);
  const [x0, x1, y0, y1] = [
    Math.min(...xs),
    Math.max(...xs),
    Math.min(...ys),
    Math.max(...ys),
  ];
  const scale = Math.min(
    (GRAPH_W - 2 * margin) / Math.max(1, x1 - x0),
    (height - 2 * margin) / Math.max(1, y1 - y0),
  );
  const ox = (GRAPH_W - (x1 - x0) * scale) / 2;
  const oy = (height - (y1 - y0) * scale) / 2;
  return new Map(
    [...points].map(([id, p]) => [
      id,
      { x: px(ox + (p.x - x0) * scale), y: px(oy + (p.y - y0) * scale) },
    ]),
  );
}

const circle = (
  count: number,
  index: number,
  cx: number,
  cy: number,
  r: number,
  turn = 0,
) => {
  const angle = (index / Math.max(1, count)) * 2 * Math.PI - Math.PI / 2 + turn;
  return { x: px(cx + r * Math.cos(angle)), y: px(cy + r * Math.sin(angle)) };
};

const radial: Layout = (nodes, edges, height, { root }) => {
  const centre = root ?? nodes[0]?.id ?? "";
  const depth = hopsFrom(nodes, edges, centre);
  const near = neighbours(nodes, edges);
  const rings = new Map<number, string[]>();
  for (const n of nodes) {
    if (n.id === centre) continue;
    const d = depth.get(n.id) ?? 1;
    rings.set(d, [...(rings.get(d) ?? []), n.id]);
  }
  const out = new Map<string, Point>([
    [centre, { x: GRAPH_W / 2, y: height / 2 }],
  ]);
  // Turns around the centre, per node, so a ring can follow the one inside.
  const turn = new Map<string, number>([[centre, 0]]);
  const count = Math.max(1, rings.size);
  const outer = Math.min(GRAPH_W, height) / 2 - 50;
  for (const [ring, ids] of [...rings].sort((a, b) => a[0] - b[0])) {
    // Order a ring by where its parent sits, so children stay beside their
    // parent and edges do not cross the middle.
    const parentTurn = (id: string) => {
      const parents = [...(near.get(id) ?? [])].filter(
        (p) => turn.has(p) && (depth.get(p) ?? 0) < ring,
      );
      return parents.length ? Math.min(...parents.map((p) => turn.get(p)!)) : 1;
    };
    const ordered = [...ids].sort(
      (a, b) => parentTurn(a) - parentTurn(b) || a.localeCompare(b),
    );
    const r = (outer * ring) / count;
    ordered.forEach((id, i) => {
      const t = (i + 0.5) / ordered.length;
      turn.set(id, t);
      out.set(id, circle(1, 0, GRAPH_W / 2, height / 2, r, t * 2 * Math.PI));
    });
  }
  return out;
};

const circular: Layout = (nodes, _edges, height) => {
  const r = Math.min(GRAPH_W, height) / 2 - 40;
  return new Map(
    nodes.map((n, i) => [
      n.id,
      circle(nodes.length, i, GRAPH_W / 2, height / 2, r),
    ]),
  );
};

const tiered: Layout = (nodes, _edges, height, { groups }) => {
  const order = [
    ...new Set([...groups, ...nodes.map((n) => n.group ?? "")]),
  ].filter((g) => nodes.some((n) => (n.group ?? "") === g));
  const out = new Map<string, Point>();
  order.forEach((group, column) => {
    const ids = nodes.filter((n) => (n.group ?? "") === group).map((n) => n.id);
    const x =
      order.length === 1
        ? GRAPH_W / 2
        : 60 + (column * (GRAPH_W - 120)) / (order.length - 1);
    ids.forEach((id, i) =>
      out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
    );
  });
  return out;
};

const flow: Layout = (nodes, edges, height) => {
  const depth = longestPath(nodes, edges);
  const columns = new Map<number, string[]>();
  for (const n of nodes) {
    const d = depth.get(n.id) ?? 0;
    columns.set(d, [...(columns.get(d) ?? []), n.id]);
  }
  const count = Math.max(1, columns.size);
  const out = new Map<string, Point>();
  [...columns]
    .sort((a, b) => a[0] - b[0])
    .forEach(([, ids], column) => {
      const x =
        count === 1
          ? GRAPH_W / 2
          : 60 + (column * (GRAPH_W - 120)) / (count - 1);
      ids.forEach((id, i) =>
        out.set(id, { x: px(x), y: px(((i + 1) * height) / (ids.length + 1)) }),
      );
    });
  return out;
};

const grid: Layout = (nodes, _edges, height) => {
  const cols = Math.max(
    1,
    Math.ceil(Math.sqrt(nodes.length * (GRAPH_W / height))),
  );
  const rows = Math.max(1, Math.ceil(nodes.length / cols));
  return new Map(
    nodes.map((n, i) => [
      n.id,
      {
        x: px((((i % cols) + 1) * GRAPH_W) / (cols + 1)),
        y: px(((Math.floor(i / cols) + 1) * height) / (rows + 1)),
      },
    ]),
  );
};

const LAYOUTS: Record<GraphLayout, Layout> = {
  force,
  radial,
  circular,
  tiered,
  flow,
  grid,
};

/** Every node's position, as a record for tweening: `${id}|x`, `${id}|y`. */
export function graphTarget(
  nodes: readonly GraphNode[],
  edges: readonly GraphEdge[],
  layout: GraphLayout,
  aspect: number,
  options: Options,
) {
  const height = GRAPH_W / aspect;
  const placed = nodes.length
    ? LAYOUTS[layout](nodes, edges, height, options)
    : new Map();
  const target: Record<string, number> = {};
  for (const [id, p] of placed) {
    target[`${id}|x`] = p.x;
    target[`${id}|y`] = p.y;
  }
  return target;
}

/** A straight edge, or a shallow arc that bows with length — straight for
 *  radial layouts, arcs where chords would overlap into a disc. */
export function edgePath(a: Point, b: Point, curved: boolean) {
  if (!curved) return `M${px(a.x)},${px(a.y)}L${px(b.x)},${px(b.y)}`;
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distance = Math.sqrt(dx * dx + dy * dy) || 1;
  const bow = Math.min(distance * 0.18, 60);
  const mx = (a.x + b.x) / 2 - (dy / distance) * bow;
  const my = (a.y + b.y) / 2 + (dx / distance) * bow;
  return `M${px(a.x)},${px(a.y)}Q${px(mx)},${px(my)} ${px(b.x)},${px(b.y)}`;
}

/** An ellipse around a set of positions, for a labelled group (a hull). */
export function hullAround(points: readonly Point[]) {
  if (!points.length) return null;
  const cx = points.reduce((sum, p) => sum + p.x, 0) / points.length;
  const cy = points.reduce((sum, p) => sum + p.y, 0) / points.length;
  const r =
    Math.max(
      34,
      ...points.map((p) => {
        const dx = p.x - cx;
        const dy = p.y - cy;
        return Math.sqrt(dx * dx + dy * dy);
      }),
    ) + 30;
  return { cx: px(cx), cy: px(cy), rx: px(r * 1.12), ry: px(r) };
}

SankeyChart

stages from links · one scale · a loop reported

Where signups go

Source, then plan, then outcome. Ribbon width is the number of accounts.

View data for Signups by source, plan, and outcome, this quarter
Signups by source, plan, and outcome, this quarter
FromToValue
SearchFree1,840
SearchTeam trial620
ReferralFree410
ReferralTeam trial540
AdsFree960
AdsTeam trial230
FreePaid390
FreeInactive2,820
Team trialPaid870
Team trialChurned520

Stages are worked out, not declared. A node sits one column after the furthest node that feeds it. A link that would close a loop is not drawn backwards; the table reports it.

Sourcecomponents/charts/sankey-chart/doc.ts · components/charts/sankey-chart/sankey-chart.tsx · components/charts/_kernel/sankey.ts

components/charts/sankey-chart/doc.ts

/**
 * SankeyChart — quantities flowing through stages.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     nodes         { id, label }[]
 *     links         { from, to, value: number | null }[]
 *     aspect?, formatValue?, animation?  (default: flows wipe in)
 *
 * # Behaviour
 *
 * R1  Stages come from the links: a node sits one column after the furthest
 *     node that feeds it.
 * R2  Ribbon width is the quantity, on one scale for the whole chart; a
 *     node is as tall as the larger of what enters and what leaves it.
 * R3  A downstream node takes the colour of its largest source, so a flow
 *     keeps its colour across the chart.
 * R4  A link that would close a loop is not drawn (never backwards); the
 *     table reports it. A null, zero, or negative link is not drawn.
 * R5  Every node is labelled with its throughput; the table lists every
 *     link.
 */
export {};

components/charts/sankey-chart/sankey-chart.tsx

"use client";

import { useState, type CSSProperties } from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { formatExact } from "../_kernel/format";
import {
  sankeyLayout,
  sankeyLinks,
  sankeyTarget,
  type SankeyLink,
  type SankeyNode,
} from "../_kernel/sankey";
import { isValue, px } from "../_kernel/scale";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./sankey-chart.module.css";

export type { SankeyLink, SankeyNode };

export type SankeyChartProps = {
  /** Names the chart. */
  label: string;
  nodes: readonly SankeyNode[];
  /** Quantities from one node to another. Stages are worked out from the
   *  links: a node sits one column after the furthest node that feeds it. */
  links: readonly SankeyLink[];
  /** Width over height. */
  aspect?: number;
  formatValue?: (value: number) => string;
  /** Default: flows wipe in from the left, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Quantities flowing through stages: where signups come from, which plan
 *  they pick, whether they stay. Ribbon width is the quantity, on one scale
 *  for the whole chart. Hover a node to light its flows. */
export function SankeyChart({
  label,
  nodes,
  links,
  aspect = 2,
  formatValue = formatExact,
  animation,
  className,
}: SankeyChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "wipe",
    axis: "x",
  });
  const shown = useTweened(sankeyTarget(links), update);
  const [lit, setLit] = useState<string | null>(null);
  const W = 1000;
  const H = W / aspect;
  const layout = sankeyLayout(nodes, links, shown, W, H);
  const { loops } = sankeyLinks(nodes, links);
  const name = (id: string) => nodes.find((n) => n.id === id)?.label ?? id;
  const pct = (x: number, y: number) => ({
    left: `${(x / W) * 100}%`,
    top: `${(y / H) * 100}%`,
  });

  if (!nodes.length || !layout.links.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>
          {nodes.length ? "Measurements unavailable." : "No data to display."}
        </p>
      </div>
    );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      <div
        className={styles.frame}
        style={{ "--aspect": aspect } as CSSProperties}
      >
        <svg
          className={styles.svg}
          viewBox={`0 0 ${W} ${px(H)}`}
          preserveAspectRatio="none"
          role="img"
          aria-label={`${label}. Every flow is in the data table.`}
          data-lit={lit ? "" : undefined}
        >
          {layout.links.map((link) => (
            <path
              key={link.key}
              data-mark
              data-lit={
                lit !== null && (link.from === lit || link.to === lit)
                  ? ""
                  : undefined
              }
              className={styles.link}
              d={link.path}
              strokeWidth={Math.max(1, link.width)}
              style={{ "--series": link.color } as CSSProperties}
            />
          ))}
          {layout.nodes.map((node) =>
            node.value > 0 ? (
              <rect
                key={node.id}
                data-mark
                className={styles.node}
                x={px(node.x)}
                y={px(node.y)}
                width={layout.nodeWidth}
                height={px(Math.max(1, node.height))}
                style={{ "--series": node.color } as CSSProperties}
                onPointerEnter={() => setLit(node.id)}
                onPointerLeave={() => setLit(null)}
              />
            ) : null,
          )}
        </svg>
        <div className={styles.labels} aria-hidden="true">
          {layout.nodes.map((node) => {
            if (node.value <= 0) return null;
            const last =
              node.column === layout.columns - 1 && layout.columns > 1;
            return (
              <span
                key={node.id}
                data-side={last ? "left" : "right"}
                style={pct(
                  last ? node.x : node.x + layout.nodeWidth,
                  node.y + node.height / 2,
                )}
              >
                {node.label}
                <b>{formatValue(node.value)}</b>
              </span>
            );
          })}
        </div>
      </div>
      <ChartData label={label}>
        <THead>
          <Tr>
            <Th>From</Th>
            <Th>To</Th>
            <Th numeric>Value</Th>
          </Tr>
        </THead>
        <TBody>
          {links.map((link, index) => {
            const loop = loops.includes(link);
            return (
              <Tr key={index}>
                <Th scope="row">{name(link.from)}</Th>
                <Td>{name(link.to)}</Td>
                <Td numeric>
                  {isValue(link.value)
                    ? `${formatValue(link.value)}${loop ? " (not drawn: it forms a loop)" : ""}`
                    : "Unavailable"}
                </Td>
              </Tr>
            );
          })}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/_kernel/sankey.ts

import { colorVar, type ChartColor } from "./encode";
import { longestPath, splitLoops } from "./graph";
import { isValue, px } from "./scale";

/* SankeyChart: quantities flowing through stages. */

export type SankeyNode = { id: string; label: string };
export type SankeyLink = { from: string; to: string; value: number | null };

export type SankeyLayoutNode = SankeyNode & {
  column: number;
  x: number;
  y: number;
  height: number;
  value: number;
  inflow: number;
  outflow: number;
  color: string;
};

export type SankeyLayoutLink = {
  key: string;
  from: string;
  to: string;
  value: number;
  width: number;
  path: string;
  color: string;
};

const linkKey = (l: { from: string; to: string }) => `${l.from}>${l.to}`;

/** The links that can be drawn: both ends exist, the value is finite and
 *  positive, and it runs forward. A link that would close a loop is kept
 *  out of the picture (and reported), never drawn backwards. */
export function sankeyLinks(
  nodes: readonly SankeyNode[],
  links: readonly SankeyLink[],
) {
  const ids = new Set(nodes.map((n) => n.id));
  const candidate = links.filter(
    (l) =>
      ids.has(l.from) &&
      ids.has(l.to) &&
      l.from !== l.to &&
      isValue(l.value) &&
      l.value > 0,
  );
  const { forward, loops } = splitLoops(nodes, candidate);
  const depth = longestPath(nodes, forward);
  return { forward, loops, depth };
}

export function sankeyTarget(links: readonly SankeyLink[]) {
  const target: Record<string, number> = {};
  for (const l of links)
    if (isValue(l.value) && l.value > 0) target[linkKey(l)] = l.value;
  return target;
}

/** Positions for the tweened link values, in a `width` × `height` space. */
export function sankeyLayout(
  nodes: readonly SankeyNode[],
  links: readonly SankeyLink[],
  shown: Readonly<Record<string, number>>,
  width: number,
  height: number,
  { nodeWidth = 14, gap = 14 } = {},
) {
  const { forward, depth } = sankeyLinks(nodes, links);
  const flows = forward.map((l) => ({ ...l, value: shown[linkKey(l)] ?? 0 }));
  const columns = Math.max(0, ...nodes.map((n) => depth.get(n.id) ?? 0)) + 1;

  const placed = new Map<string, SankeyLayoutNode>();
  nodes.forEach((node, index) => {
    const inflow = flows
      .filter((l) => l.to === node.id)
      .reduce((s, l) => s + l.value, 0);
    const outflow = flows
      .filter((l) => l.from === node.id)
      .reduce((s, l) => s + l.value, 0);
    const column = depth.get(node.id) ?? 0;
    placed.set(node.id, {
      ...node,
      column,
      x:
        columns === 1
          ? width / 2
          : (column * (width - nodeWidth)) / (columns - 1),
      y: 0,
      height: 0,
      value: Math.max(inflow, outflow),
      inflow,
      outflow,
      color: colorVar(((index % 4) + 1) as ChartColor),
    });
  });

  // One vertical scale for every column: the fullest column fills the height.
  const byColumn = Array.from({ length: columns }, (_, c) =>
    [...placed.values()].filter((n) => n.column === c && n.value > 0),
  );
  const scale = Math.min(
    ...byColumn
      .filter((c) => c.length)
      .map(
        (c) =>
          (height - gap * (c.length - 1)) / c.reduce((s, n) => s + n.value, 0),
      ),
  );
  for (const column of byColumn) {
    const used =
      column.reduce((s, n) => s + n.value * scale, 0) +
      gap * (column.length - 1);
    let y = (height - used) / 2;
    for (const n of column) {
      n.y = y;
      n.height = n.value * scale;
      y += n.height + gap;
    }
  }

  // First-column nodes keep their hue; everything downstream takes the hue of
  // its largest source, so a flow keeps its colour across the chart.
  for (let c = 1; c < columns; c++)
    for (const n of byColumn[c]) {
      const main = flows
        .filter((l) => l.to === n.id)
        .sort((a, b) => b.value - a.value)[0];
      if (main) n.color = placed.get(main.from)!.color;
    }

  // Links leave a node ordered by where they arrive, and arrive ordered by
  // where they left, so ribbons cross as little as they can.
  const outOffset = new Map<string, number>();
  const inOffset = new Map<string, number>();
  const sorted = [...flows].sort(
    (a, b) =>
      placed.get(a.from)!.y - placed.get(b.from)!.y ||
      placed.get(a.to)!.y - placed.get(b.to)!.y,
  );
  const out: SankeyLayoutLink[] = [];
  const bySource = [...sorted].sort(
    (a, b) => placed.get(a.to)!.y - placed.get(b.to)!.y,
  );
  const sourceY = new Map<string, number>();
  for (const l of bySource) {
    const o = outOffset.get(l.from) ?? 0;
    sourceY.set(linkKey(l), placed.get(l.from)!.y + o);
    outOffset.set(l.from, o + l.value * scale);
  }
  for (const l of sorted) {
    const a = placed.get(l.from)!;
    const b = placed.get(l.to)!;
    const w = l.value * scale;
    const y0 = sourceY.get(linkKey(l))! + w / 2;
    const i = inOffset.get(l.to) ?? 0;
    const y1 = b.y + i + w / 2;
    inOffset.set(l.to, i + w);
    const x0 = a.x + nodeWidth;
    const x1 = b.x;
    const mid = (x0 + x1) / 2;
    out.push({
      key: linkKey(l),
      from: l.from,
      to: l.to,
      value: l.value,
      width: w,
      path: `M${px(x0)},${px(y0)}C${px(mid)},${px(y0)} ${px(mid)},${px(y1)} ${px(x1)},${px(y1)}`,
      color: a.color,
    });
  }
  return { nodes: [...placed.values()], links: out, nodeWidth, columns };
}

Edge cases

empty · dangling edges · a loop

No nodes

No data to display.

Dangling edges ignored
View nodes for Two nodes and a dangling edge
Two nodes and a dangling edge
NodeGroupConnections
A—1
B—1
View connections for Two nodes and a dangling edge
Two nodes and a dangling edge
FromToValue
AB—
A loop in a Sankey
View data for Flows with a loop
Flows with a loop
FromToValue
AB10
BC6
CA3 (not drawn: it forms a loop)