Charts

Correlogram

A correlation matrix drawn half as coefficient-shaped glyphs and half as numbers, with non-significant pairs hatched instead of blanked and the axes reorderable by hierarchical clustering.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, LayoutGrid } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartCorrelogramData,
  CorrelogramPair,
  CorrelogramVariable,
} from "./chart-correlogram.contract"

export type CorrelogramOrder = "input" | "cluster" | "alphabetical"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Correlogram" component (zod, lucide-react, a cn() class merger).
It renders an already-computed correlation matrix as a square HTML table whose cells are either
coefficient-shaped glyphs or printed numbers.

Contract
- zod schema, one source of truth:
  status: "loading" | "empty" | "error" | "ready"
  title: string
  method?: string                              // "Pearson r", "Spearman rho", printed in the footnote
  variables: { id: string; label: string; short?: string }[]   // ONE list, it sits on both axes
  pairs: { a: string; b: string; r: number; p?: number; n?: number }[]
                                               // UNORDERED pairs: the upper triangle only,
                                               // n*(n-1)/2 rows, never the full square,
                                               // never the diagonal. r is bounded on [-1, 1].
  superRefine rejects: duplicate variable ids, a pair joining a variable to itself, the same
  unordered pair twice, and a "ready" payload with no variables.
- Props on top of the contract: description, glyph ("ellipse" | "pie" | "bar", default "ellipse"),
  layout ("mixed" | "glyphs" | "numbers", default "mixed"), glyphSide ("lower" | "upper",
  default "lower", ignored unless layout is "mixed"), alpha (default 0.05, clamped 0-1),
  defaultOrder ("input" | "cluster" | "alphabetical", default "cluster"), cellSize (px, 28-96,
  default 46), decimals (1-3), locale (default "en-US"), onRetry, emptyState, className,
  ...div props. forwardRef onto the card.

Behavior
- SYMMETRY IS ENFORCED, NOT ASSUMED. Build a nested Map and write every kept pair into BOTH
  directions, so (a,b) and (b,a) are the same object. No reorder, transpose or triangle switch
  can then make the two halves disagree, and the API never has to send a symmetric square.
- NOTHING IS DROPPED QUIETLY. Guard again after zod, because props are not always parsed. Count
  and print, under the grid: coefficients that are not finite or fall outside [-1, 1], pairs naming
  an undeclared variable, self-pairs, and repeated pairs (first one wins). Those cells then render
  as "not computed", which is a THIRD state, distinct from both a value and a masked value.
- SIGNIFICANCE MASKS, IT DOES NOT DELETE. A pair whose p exceeds alpha keeps its geometry and its
  digits and loses its fill: dashed outline, muted ink, a 135-degree hatch behind the cell. Blanking
  it would make it indistinguishable from a pair nobody computed, and it would also hide the reader's
  ability to disagree with the threshold. A pair that arrives with NO p is drawn at full strength:
  "untested" must never be published as "passed".
- REORDERING IS THE ANALYSIS. Three orders behind aria-pressed buttons: input, alphabetical, and
  average-linkage (UPGMA) hierarchical clustering on distance = 1 - |r|.
    * Magnitude, not signed value: a -0.9 is as much of a relationship as a +0.9 and belongs in the
      same block. Unmeasured pairs sit at distance 1, so an untested variable drifts to the edge
      instead of joining a block it was never compared against.
    * Significance deliberately does NOT enter the distance - masking is a display decision, and
      letting it move rows would make the picture depend on alpha.
    * Merge distances with the Lance-Williams update (O(n^3), small constant), not by recomputing
      the average each time. Ties resolve to the lexicographically first index pair, so the same
      input always prints the same order on the server and on the client.
    * On each merge, pick the best of the four child flips by the distance between the touching
      endpoints - the cheap half of optimal leaf ordering, and the difference between a solid block
      and two smudges with a stripe between them.
    * Clustering needs >= 3 variables and >= 1 measured pair; below that the button is aria-disabled
      (never natively disabled - that drops it out of the tab ring) and the order falls back to input.
- Glyph geometry, all pure exported functions, all centred on (0,0) so the same code draws the cells
  and the legend:
    * ellipse: the bivariate-normal density contour, the curve (cos(t+th/2), cos(t-th/2)) with
      th = acos(r). Semi-axes are sqrt(1+r) along +45 degrees and sqrt(1-r) along -45. r=0 is a
      circle, r=+-1 is a line, eccentricity IS the coefficient and tilt IS the sign. Its bounding
      half-width is sqrt((rx^2+ry^2)/2) = radius for every r, so it can never spill out of the cell.
      Floor ry at 5% of the radius: SVG paints nothing for a zero radius, so r=1 would silently
      render an empty cell.
    * pie: a wedge of |r| of the circle from twelve o'clock, clockwise for positive and
      anticlockwise for negative, inside a permanent outline that shows what it is a fraction of.
      |r| >= 0.9995 must be drawn as two half-arcs - a single arc whose end equals its start is a
      no-op in SVG.
    * bar: a bar from the centre line, right for positive and left for negative, length linear in
      |r|, with the zero tick always drawn. The only one of the three that still resolves at 28px.
- Numbers carry magnitude too: type size scales with |r| (a fraction of cellSize, so the longest
  string stays inside its column at every size), and the ink ramps from --muted-foreground at r=0
  to the sign's hue at |r|=1.
- The diagonal is a name plate (`short`, else an em dash), muted, and held off every scale: a
  constant 1.0 in the domain would own the ceiling and flatten every real signal underneath it.
- Keyboard: the whole grid is ONE tab stop. Arrow keys move a roving tabindex, Home/End jump within
  a row, Ctrl/Cmd+Home and Ctrl/Cmd+End to the corners; movement clamps and never wraps. The tab
  stop is stored as a PAIR OF IDS, not as indices, so a reorder carries it along with the pair it
  was on instead of leaving it pointing at whatever slid into that square.
- Hover and focus both feed one readout line under the grid; a change of order is announced through
  a polite sr-only live region, because the grid is re-labelled around the cursor rather than moving
  under it. There is no gesture anywhere - reordering is buttons, inspection is arrows or a pointer.
- Four states are first-class branches. loading renders a triangular plain-div skeleton (no table
  anywhere near it, and the sr-only status text outside it); empty and error render a centred
  message; error shows a retry button only when onRetry exists; a "ready" payload with no variables
  falls through to empty rather than drawing an empty frame.

Rendering & styling
- Real `<table>` with `<th scope="col">` / `<th scope="row">`, table-fixed plus a declared
  `<colgroup>`: the table's width is a function of the variable count, never of the longest label,
  which is what stops a 40-character label from pushing the page sideways. Column headers print
  `short` (or an elided label with the full string on `title`); row headers spell the label out and
  wrap. `border-separate border-spacing-0`, `border-t border-l` on every cell and `border-r border-b`
  on the table, so the grid is one line thick everywhere.
- All the prose - the accessible name, the "not computed" wording, the significance note - lives
  outside the table or on aria-label/title ATTRIBUTES, never as sr-only text nodes inside a cell: a
  selection copied into a spreadsheet has to paste as bare numbers, and a text node in a cell pastes
  too.
- Semantic tokens only, no hex anywhere. Positive is var(--chart-1), negative var(--chart-5); both
  are mixed into var(--card) at 14%-92% by |r|, so the mark always travels AWAY from the surface in
  both themes. Hue is redundant on purpose - geometry already says which arm a mark is on, so the
  grid survives greyscale, colour blindness and a projector.
- The number half tints its TEXT, not its background: a --chart-5 tile deep enough to read as
  "strong" leaves --foreground at about 2.8:1 on the dark theme. Pull the arm token 30% toward
  --foreground first (that lands every arm at >= 6.6:1 on the card in both themes), then blend
  toward --muted-foreground by 1-|r|.
- One delegated listener each for hover / focus / keydown on the table, not one per cell: 20
  variables is 400 tiles.
- prefers-reduced-motion: the only animation is the skeleton pulse, and it is motion-reduce gated.
  The chart is complete and readable with animation off - there is no entrance transition to miss.

Customization levers
- Glyph: "ellipse" is the analyst default (eccentricity reads as strength at a glance), "pie" is the
  friendliest to a non-technical audience, "bar" is the one to pick below ~34px or above ~16
  variables. Adding a fourth is one pure function returning SVG geometry - keep a geometric sign
  channel or the mark stops working in greyscale.
- Halves: layout="mixed" with glyphSide "lower"/"upper" is the corrplot idiom; "numbers" turns the
  card into a quotable table, "glyphs" into a pattern you scan. Numbers-only plus cellSize 34 is a
  good dense report; glyphs-only plus cellSize 24 is a good screening sweep.
- Colour: swap --chart-1 / --chart-5 for your own two hues. Keep the two arms at the SAME mix
  percentage so equal magnitudes read equally deep, and keep the geometry doing the sign.
- Emphasis curve: apply a gamma to |r| inside glyphFill (t ** 0.7 spreads the weak end when almost
  everything is above 0.6). Never apply it to the geometry - the ellipse's eccentricity is a
  definition, not a style.
- Threshold: alpha is a lever, not a constant. Pass 0.001 for a stricter grid, 1 to switch masking
  off entirely. Multiple-comparison corrections belong upstream: correct the p-values, then hand
  them over.
- Order: defaultOrder="input" when the incoming order is meaningful (a pipeline, a questionnaire's
  printed sequence); "cluster" when you want the structure found for you. Swap the distance for
  1 - r (signed) if you want the two arms to cluster apart rather than together.
- Trim: drop the order toolbar for a static report, drop the readout line when every cell already
  prints its number, replace the zero-state wholesale with `emptyState`.

Concepts

  • Symmetric by construction — the component is handed one variable list and the unordered pairs above the diagonal, then mirrors each pair into both directions of its lookup itself. Nothing downstream can make the two triangles disagree, and a sparse API response needs no padding into a square.
  • Masking, not blanking — a pair that fails the significance threshold keeps its shape and its digits and loses its fill. Deleting it would collide with the cell that means "never computed", and it would quietly take away the reader's right to disagree with alpha. A pair that arrives with no p-value at all is drawn at full strength, because untested is not the same as passed.
  • Sign is geometry — the ellipse leans one way for positive and the other for negative, the wedge sweeps clockwise or anticlockwise, the bar runs right or left. Hue repeats the same fact rather than carrying it, so the grid still works printed in grey, seen by a red-green viewer, or thrown at a washed-out projector.
  • Reordering is the analysis — in acquisition order a correlation matrix usually looks like static. Average-linkage clustering on 1 − abs(r) puts related variables next to each other, and the same coefficients resolve into blocks on the diagonal. Distance uses magnitude, so a strong negative relationship pulls two variables together rather than pushing them apart.
  • Leaf-order flipping — a dendrogram is free to reverse either child at every merge, and which flip it picks decides whether a block reads as a solid square or as two smudges with a stripe through it. Choosing the flip whose touching endpoints are closest is the cheap half of optimal leaf ordering and costs nothing at these sizes.
  • Not computed is a third state — a value, a masked value and a pair that was never measured are three different facts, so they get three different cells: a filled mark, a hatched outline, and a hatch at the opposite angle with no mark at all. Every row the component had to reject is counted in a note, never swallowed.

On This Page