Charts

Adjacency Matrix

A graph drawn as its adjacency matrix — one node set on both axes, cells carrying edge weight, reorderable by name, degree or cluster, with a row-and-column crosshair, directed or symmetric halves, and a foldable triangle.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Network } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  AdjacencyLink,
  AdjacencyNode,
  AdjacencyOrder,
  ChartAdjacencyMatrixData,
} from "./chart-adjacency-matrix.contract"

export interface ChartAdjacencyMatrixProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-adjacency-matrix.json

Prompt

Build a React + TypeScript + Tailwind "Adjacency Matrix" component (zod, lucide-react, a cn() class merger).
It renders a graph — nodes plus a long-form edge list — as a square matrix whose rows and columns are the
SAME node set, and whose reordering is the analysis.

Contract
- zod schema, one source of truth:
  status: "loading" | "empty" | "error" | "ready"
  nodes: { id: string; label: string; group?: string }[]     // group = community/team, computed upstream
  links: { source: string; target: string; weight?: number }[]  // omitted weight counts as 1
  directed?: boolean                                          // default false
  unit?: string                                               // legend/readout suffix, never printed in a cell
- Props on top of the contract: title, description, rowAxisLabel, defaultOrder ("input" | "name" | "degree" |
  "cluster", default "input"), reorderable (default true), onOrderChange, symmetry ("mirror" | "upper" |
  "lower", default "mirror", ignored when directed), showValues (boolean | "auto"), crosshair (default true),
  cellSize (px, clamped 14-96, defaulting to 36 / 24 / 18 as the node count passes 18 and 30), maxHeight,
  decimals (0-6, defaulting to 0 for integer weights and 2 otherwise), locale (default "en-US"),
  skeletonRows (1-24), onRetry, emptyState, className, ...div props. forwardRef.

Behavior — the maths live in exported pure functions so a test can print the same numbers the picture is made of
- buildAdjacencyModel(nodes, links, directed) -> { nodes: stats[], weights: Map<row, Map<column, number>>,
  pairs, selfLoops, minWeight, maxWeight, maxAbsWeight, signed, integral, grouped, density, drops }.
  Four rules and nothing else decides what a cell contains:
  1. Parallel links SUM, so a raw event log can be handed over unaggregated.
  2. Undirected input writes BOTH (a,b) and (b,a) at build time. Symmetry is then a property of the model,
     not something the renderer has to remember; no order, fold or sort can make the halves disagree.
  3. source === target is a self-loop and belongs on the diagonal, not in the bin.
  4. A link whose endpoint is not in `nodes`, or whose weight is not finite, is dropped AND COUNTED, and the
     footer prints the count. A broken join that renders as a merely sparse graph is the failure to avoid.
  Per-node stats: degree (distinct neighbours, self-loop excluded), outDegree/inDegree, strength (sum of
  |weight|). density = drawn pairs / possible pairs, n*(n-1) directed or n*(n-1)/2 undirected, 0 when n < 2.
- orderAdjacency(stats, order, compareLabels) -> the row AND column order, always the same array for both
  axes (that is what keeps the diagonal the diagonal). "input" restores the given order by index; "name" is
  alphabetical; "degree" is degree desc then strength desc; "cluster" ranks groups by size, puts ungrouped
  nodes last, and sorts within a group by degree. Every branch ends its comparator chain with label then id,
  so a tie can never make two renders of one dataset disagree. `compareLabels` is INJECTED (an Intl.Collator
  built from the `locale` prop): a component that quietly used the server's collation would hydrate into a
  different row order than it rendered.
- adjacencyBlocks(ordered) -> contiguous runs of one group. Rules are drawn between blocks in cluster order
  ONLY: in any other order the runs are fragments and a rule at every second column would mean nothing.
- Scale: fill = |weight| / maxAbsWeight, mixed 18% -> 78% into the card surface. The floor is 18%, not 0%,
  because the first read on this chart is "link or no link" and the weakest link has to survive it. A cell
  with no link gets no fill at all. Negative weights keep the magnitude ramp and add a hatch, so -8 and +8
  are equally deep and colour alone never carries the sign.
- Degenerate data must not break the geometry: zero nodes (empty branch), one node (a 1x1 grid), zero links
  (every cell blank, which is a finding), all weights equal, weight 0 (present, carrying nothing), an
  isolated node (a whole blank row and column), duplicate node ids (first wins, counted), labels far longer
  than a cell (elide, never widen the grid).
- Interaction, and every one of these has a keyboard path:
  · Crosshair — pointer or focus lights the whole row and the whole column of the cell under it, and outlines
    the MIRROR cell (column,row) so a directed pair can be compared with its reverse in one look.
  · Pin — clicking a row or column header pins that node's row and column so they stay lit after the pointer
    leaves; the header carries aria-pressed; Escape or a second click clears it.
  · Reorder — a button group, one aria-pressed button per order. "Cluster" with no `group` field anywhere is
    aria-disabled with a guard in the handler, NEVER the native disabled attribute: a disabled button leaves
    the tab order and stops explaining itself.
  · Keyboard — the whole grid is ONE tab stop. Arrows move a roving tabindex across cells and into both
    header bands (headers sit at index -1 on their axis), Home/End move within a row, Ctrl/Cmd+Home and
    Ctrl/Cmd+End jump to the corners; movement clamps and never wraps. Focus moves synchronously with
    element.focus() and the state rides along on the focus event.
- ARIA contract: real <table> with <th scope="col"> / <th scope="row">, named by aria-labelledby (the title
  above it) and described by aria-describedby (the visible stats line). Every cell carries an aria-label
  spelling out the pair and its weight in words ("Ada and Bruno: 12 reviews", "no link", "self-loop",
  "mirrored, the value is in the other half"). Reordering and pinning are the two changes that move nothing
  with focus, so they alone are announced through a polite sr-only live region; the pointer readout line is
  aria-hidden because a focused cell already announces itself.
- Four states are first-class branches. loading renders a plain-div skeleton (never a table, and no sr-only
  node inside one), empty and error render a centred message, error shows retry only when onRetry exists.

Rendering & styling
- Semantic tokens only. Fill is `color-mix(in oklab, var(--chart-1) X%, var(--card))`; mixing into the
  surface makes the ramp travel away from it in both themes, so --foreground stays legible on every step.
  The negative hatch alternates one stripe toward --foreground and one toward --card at equal strength, so
  it signals sign without adding depth. The diagonal carries its own sparser hatch in --muted-foreground:
  an empty diagonal would read as "no link here", a claim about data that does not exist.
- Colour is never the only channel: weights print in-cell whenever a cell is wide enough (>= 30px), the
  crosshair readout and the native title carry them when it is not, sign is a texture, cluster membership is
  a rule plus the group name in the header tooltip, and degree is printed as a number next to every row label.
- Cell gaps come from `shadow-[inset_0_0_0_1px_var(--card)]`, not border-spacing, because the spacing gutters
  of a sticky header let the scrolling body show through. The crosshair band is a SECOND inset shadow in the
  same arbitrary value (a class cannot beat the inline background, and two shadow-* classes would collide in
  tailwind-merge).
- `table-fixed` plus a <colgroup> of declared widths: the table's width is a function of the node count,
  never of the longest label. Column headers are rotated with `[writing-mode:vertical-rl] rotate-180` into a
  fixed 108px band and clipped; row labels truncate to one line. Full strings live in title, readout and
  aria-label. `border-separate border-spacing-0` for the sticky bands - border-collapse drops the borders of
  sticky cells, and sticky cells need an opaque bg-card.
- Performance: one <td> per cell with nothing inside it, ONE delegated listener each for hover / focus /
  keydown / click on the table, memoised rows, and prepared cell styles that do not change on hover so a
  pointer move diffs classNames only. Pointer state bails out when the coordinates did not change.
- Animation is limited to colour transitions on buttons and the skeleton pulse, both motion-reduce gated.
  Nothing about the chart depends on animation, and there is no timer, rAF or observer to clean up.

Customization levers
- Colour: swap var(--chart-1) for any single token; raise MAX_MIX for a heavier grid, lower it for a lighter
  one, raise MIN_MIX if "link vs no link" needs to read from further away. Two hues for the two signs is fine
  ONLY at the same mix percentage — and keep the hatch as the colour-independent channel.
- Emphasis curve: apply a gamma inside weightMix (t ** 0.7 spreads the low end for long-tailed weights). For
  log-scaled weights, transform upstream.
- Density: cellSize + showValues are the two knobs. 18-24px with no numbers reads as a texture map you scan
  for blocks; 40-56px with numbers reads as a table you quote from.
- Orders: add one to the union and one branch to orderAdjacency — barycentre/seriation, reverse Cuthill-McKee
  and PCA order all fit the same signature. Or drop the control (reorderable={false}) and pass a
  pre-seriated node array with defaultOrder="input".
- Trim: symmetry="upper" halves the ink of a symmetric matrix; crosshair={false} drops the row/column bands
  on very large grids; emptyState replaces the zero-state wholesale; omit onRetry to drop the retry button.
- Encode more: a second channel per cell (a corner triangle for "changed since last week") belongs in the
  prepared cell object, not in a per-cell component - the whole performance story is that a cell is one node.

Concepts

  • One node set, two axes — a heatmap crosses two different things; an adjacency matrix crosses one thing with itself. That is why the diagonal means something, why a self-loop has a home, and why rows and columns must always be reordered together.
  • Reordering is the analysis — the picture a matrix shows is decided by its permutation, not by its data. The same links look like dust in arrival order and like three solid blocks in cluster order. Ordering by degree pulls hubs into the top-left corner; ordering by name is the only order in which a reader can find a node.
  • Symmetric by construction — undirected links are written into both halves while the model is built, so no later sort, fold or re-render can make the two halves disagree. When directed is true the halves are different facts, and their disagreement — 412 one way, 6 the other — is usually the finding.
  • Blank is not zero, and the diagonal is neither — an empty cell means no link at all, a cell drawn at the ramp floor means a link carrying zero, and the hatched diagonal means "a node is not its own neighbour". Three different statements, three different fills, all three in the tooltip and the accessible label.
  • Crosshair and pin — a cell 20px wide cannot say which pair it belongs to, so pointing at one lights its whole row and column, and clicking a header keeps them lit after the pointer leaves. The mirror cell is outlined at the same time, which is how a directed pair is compared with its reverse without hunting for it.
  • Dropped links are counted out loud — a dangling endpoint or a non-finite weight is a broken join, not a sparse graph. Both are dropped, both are counted, and the count is printed under the grid where the person reading the picture will see it.

On This Page