Charts

Horizon

A four-state horizon chart that folds each series into stacked bands and encodes magnitude as depth of colour, so a dozen dense time series fit in a screenful — one shared band scale, mirrored or split negatives, and a column cursor that reads every lane at the same instant.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  bandOfMagnitude,
  buildHorizonLayout,
  inspectHorizonData,
  type ChartHorizonData,
  type HorizonRowStat,
  type HorizonSegment,
} from "./chart-horizon.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartHorizon" card in plain SVG with zod.
No chart library: a horizon chart is one area path per lane painted N times at
N vertical offsets under a clip, and recharts has no primitive for the fold.

Contract
- One zod schema is the source of truth and the props are its z.infer plus
  presentation options:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; unit?: string; ticks: string[];
    series: { id: string; label: string; values: (number | null)[] }[];
    bandValue?: number }
  values are aligned BY INDEX with ticks. `null` is an explicit gap — "nothing
  was measured here" — which is a different statement from 0 and must be drawn
  as a break, not as a dip to the baseline.
- Extra props: bands (default 3, clamped 1-5), mirrorNegative (default true),
  rowHeight (default 28, clamped 14-96), labelWidth (default 156, clamped
  64-260), formatValue, onRetry, className, plus forwardRef and the native div
  props spread on the card in ALL FOUR states so a consumer can measure or
  annotate the panel without knowing which branch is rendering. Omit the div's
  own `title` from the extended attributes — the contract owns that name.
- Clamp every numeric prop through one helper that treats NaN and Infinity as
  "the caller handed us garbage" and falls back to the documented default
  rather than to the range floor.
- Ship the arithmetic as pure functions beside the schema so the component only
  paints: horizonSegments(values) -> contiguous measured runs;
  inspectHorizonData(series, ticks) -> a typed issue or null;
  buildHorizonLayout(series, ticks, { bands, bandValue }) -> per-lane count /
  gaps / min / max / mean / peak / peakIndex / last / lastIndex / positive and
  negative magnitude / overflow, plus the shared bandValue, ceiling, totals and
  the loudest and quietest lanes; bandOfMagnitude(m, bandValue, bands).
- inspectHorizonData refuses two payloads by name instead of rendering them:
  duplicate series ids, and a series whose values length differs from ticks
  length. Everything here is aligned by index, so one short lane would shift
  every column after the gap and still look completely plausible. Run it on
  unparsed props too — a component handed raw props cannot know whether the
  schema was ever applied.

Behavior
- THE FOLD. Pick a band width b (shared by every lane). Band k of a lane is the
  same area path translated down by k lane-heights and clipped to the lane, so
  band k paints clamp((|v| - k*b)/b, 0, 1) of the lane height measured up from
  the zero rule. Paint band 0 first and each deeper band over it in an opaque
  colour: band k+1's support is always a subset of band k's, so the deepest
  band reaching a column is the one you see there. Emit ONE path per lane and
  sign into <defs> and re-use it with <use> at each depth — folding is a pure
  translation of the same curve, and repeating the d-string N times just makes
  the DOM N times bigger for an identical picture.
- ONE SHARED BAND WIDTH. Derive it as maxMagnitude / bands over the whole
  payload, or take a caller-pinned bandValue. Per-lane scaling is the one thing
  that would destroy the chart: "darker means larger" has to mean the same
  number in every lane or stacking them says nothing. A pinned bandValue lets
  two cards be compared, or stops one outlier setting everyone's scale; samples
  past the top band then saturate, and the count of them is printed rather
  than silently clipped.
- SIGN. mirrorNegative=true reflects values below zero back into the same lane
  in a second hue — maximum compression, and a lane never grows taller by going
  negative. mirrorNegative=false splits the lane at the zero rule, positive
  above and negative below, each folded into its own half: sign becomes a
  position again at the cost of half the resolution per side. Implement both as
  one geometry with a direction of +1 or -1 and a per-sign zero line, not as
  two code paths.
- DEGENERATE DATA, each handled on purpose: a lane that never reported keeps
  its slot and paints nothing (its row still answers the pointer and still
  appears in the table); a lane of exact zeroes likewise paints nothing,
  because 0 lives on the zero rule; an isolated sample between two gaps has no
  width to fill, so give it a fraction of a column on each side or it vanishes
  from the picture entirely; a payload where every value is zero would divide
  by zero deriving the band width, so substitute 1 and say "every measurement
  is zero"; a single tick paints as a constant band across the plot; ready with
  no series, no ticks, or no measurement at all routes to the empty branch, not
  to an axis with nothing on it.
- INTERACTION — one column cursor, three sources. The pointer sets it from the
  plot's real rect (row from the hovered lane, column from x). Focusing a lane
  parks it on the last column; ArrowLeft/Right walk columns, Home/End jump to
  the ends, ArrowUp/Down move between lanes, and Enter (or a click) pins the
  cursor so the numbers survive the mouse leaving; Escape releases. While a
  cursor exists EVERY lane prints its own value at that instant next to its
  label — that comparison is the entire reason the lanes are stacked, and it is
  worth more than any per-lane tooltip.
- KEYBOARD MECHANICS. Roving tabindex over the lanes: one Tab in, one Tab out,
  however many lanes there are. Move focus with .focus() on the target lane
  rather than by re-rendering, so the browser keeps ownership of the focus ring.
  Enter and Space arrive at onClick as a click with detail 0 and clientX 0 —
  read the column from the current cursor in that case, never from the event,
  or the keyboard silently pins column 0.
- FOUR STATES are first-class branches of one bg-card panel: a skeleton built
  on the READY geometry (same label lane width, same lane height, same pitch)
  so nothing shifts sideways when the payload lands, plus an sr-only
  role="status"; an empty branch that distinguishes "no series configured" from
  "series configured, nothing measured" from "no ticks yet"; an error branch
  that shows either the transport failure or the named data issue, with "Try
  again" only when onRetry is passed; and ready.
- CLEANUP: there is deliberately nothing to clean up — no ResizeObserver, no
  rAF, no timers, no window listeners. If you swap the stretched viewBox for a
  measured layout, disconnect the observer on unmount AND when the measured
  node is replaced by a status change.

Rendering & styling
- RESPONSIVE WITHOUT MEASURING. The SVG carries a constant viewBox
  (0 0 1000 plotHeight) with preserveAspectRatio="none", so x stretches to any
  container width while y stays in px. That is what keeps the HTML lanes
  aligned with the painted bands at every width with no observer and no
  first-frame reflow. Consequences to respect: strokes need
  vector-effect="non-scaling-stroke" (it is NOT inherited, so put it on each
  element), and NO text may live in the SVG — glyphs would stretch with x.
  Labels, axis ticks and the readout are HTML; axis ticks are positioned with
  left: N%, which is exactly the mapping the stretched viewBox applies.
- COLOUR DEPTH IS THE LADDER, AND IT MUST BE A LIGHTNESS LADDER. Do not build
  it from neighbouring --chart-* tokens: in this palette adjacent tokens are
  about 1.3:1 apart, which nobody reads as "one band deeper" (measured 1.32:1
  and 1.53:1 for a three-step token ladder). Use ONE hue per sign and mix a
  constant amount of it over a backbone that walks from --card to --foreground:
    color-mix(in oklab, TOKEN 45%,
      color-mix(in oklab, var(--foreground) D%, var(--card)))
  with D running 15 -> 92 across the bands. --card and --foreground are the one
  pair guaranteed to sit at opposite ends of the contrast range in BOTH themes,
  so "deeper" always means "further from the surface" whichever way the surface
  flips. Measured with three bands: 2.15:1 for the shallowest band against the
  card and 2.02:1 / 2.15:1 between neighbours in light, 2.26:1 and
  2.13:1 / 1.96:1 in dark. The same ramp at five bands drops to about 1.4:1 per
  step, which is why bands is clamped at 5 and two to four is the honest range.
- SIGN NEEDS A SECOND CHANNEL. The two hues differ in hue, not in lightness
  (measured 1.3:1 between the positive and negative families at equal depth),
  so in mirror mode colour alone would carry sign. Hatch the negative family
  with horizontal 1px rules in var(--card) every 4px: a full-width horizontal
  tile is the one texture immune to the horizontal stretch, since scaling x
  changes neither its spacing nor its angle. A diagonal hatch would shear with
  the container.
- The lane's own rectangle is painted in fill-muted so an unmeasured stretch and
  an empty lane are still visibly a lane, and the zero rule is a
  stroke-border hairline at the lane floor (mirror) or its middle (split).
- LEGEND IS NOT OPTIONAL HERE. Depth of colour is the only quantitative channel
  in the plot, so print one swatch per band with the magnitude it covers
  ("< 20 ms", "< 40 ms", ">= 40 ms") plus a hatched swatch for "below zero",
  and say in a footnote how wide a band is and that height means nothing. Use
  wrap-safe chips, not a fixed row: at 375px the legend has to become three
  lines rather than overflow.
- DENSITY IS A CONTAINER QUESTION. Put @container on the plot and drop the
  per-lane figure under 28rem — measured at 375px, the figure was taking 55px
  of a 95px lane and crushing every name to 23px. A viewport breakpoint is the
  wrong tool: the card, not the window, is what ran out of room.
- ACCESSIBILITY. The plot is a <figure> with an aria-label that states the
  finding — lane count, window, value range, the biggest excursion and where,
  the flattest lane, unmeasured ticks, saturated samples, and which sign mode
  is in force. role="img" would be wrong: it makes children presentational and
  the lanes are focusable buttons. The SVG is aria-hidden. Each lane's button
  carries a sentence with its own count, range, largest excursion and band. A
  polite live region repeats the cursor readout for KEYBOARD moves only — a
  hover a screen-reader user never made must not talk. Underneath, an sr-only
  per-lane summary table (samples, min, mean, max, largest excursion, latest,
  notes); a full sample-by-tick table would be thousands of cells for a chart
  whose whole purpose is density, so the caption says instead that the arrow
  keys read any single column. Put sr-only on the WRAPPER DIV, never on the
  table: CSS width is only a lower bound for a table box, so width:1px does not
  hold one back and a 375px viewport picks up hundreds of px of horizontal
  scroll.
- MOTION is decorative only: the skeleton pulse carries
  motion-reduce:animate-none. Nothing about reading, walking or pinning depends
  on animation.

Customization levers
- bands is the compression dial: 2 for a glanceable strip, 3 (default) for the
  classic horizon, 4 when the range spans an order of magnitude. Every extra
  band buys range and spends contrast between neighbours — measure the ladder
  before going past 4.
- rowHeight trades height for legibility on the same data; under 22px the
  per-lane figure is dropped automatically and the readout line carries it.
  bands x rowHeight is the effective range in pixels: 3 x 28 reads like an
  84px lane in 28.
- mirrorNegative: keep it on when the window is dominated by one sign or the
  card is very short; turn it off when readers must see sign without learning
  the legend, or when the chart is printed in greyscale and hatching is not
  enough.
- bandValue: pin it to keep two cards comparable, to line the bands up with a
  meaningful threshold (an SLO, a control limit), or to stop one incident from
  setting the scale for a whole week. Saturation is disclosed either way.
- Palette: POSITIVE_TOKEN and NEGATIVE_TOKEN are the two hue anchors, and
  HUE_WEIGHT / DEPTH_FROM / DEPTH_TO are the ladder. Re-point the tokens for a
  valence that matches your domain (up is bad for latency, good for revenue),
  and re-measure the ladder afterwards — the numbers above are properties of
  this palette, not of the formula.
- Interaction: pinning is local state — lift it to drive a table or a log view
  elsewhere in the page, or drop the click handler for a static card. formatValue
  takes over every printed figure (currency, compact notation, locale).

Concepts

  • Band folding — a lane shows bands × rowHeight worth of range in rowHeight pixels because band k is the same area curve translated down by k lane heights and clipped back into the lane. Band k+1's support is always inside band k's, so painting shallow-to-deep in opaque colour leaves the deepest band that reaches a column as the one you see. It is one path re-used at N offsets, not N paths.
  • Depth as a lightness ladder — the quantity is carried by how far the fill sits from the card colour, so the ladder has to be built from --card → --foreground with a constant hue mixed in, not from neighbouring palette tokens. Measured here: 2.02–2.15:1 between neighbouring bands at three bands, against 1.32–1.53:1 for a ladder made of adjacent --chart-* tokens. Past four bands the steps fall to about 1.4:1, which is the real ceiling on how far a horizon chart can be compressed.
  • One shared band width — every lane is folded on the same value scale, so a shade means the same number everywhere. The moment each lane is scaled to its own peak, a dark band in a quiet lane and a dark band in a loud one look identical and stacking them stops saying anything. Pinning bandValue is the escape hatch, and whatever it cannot fit is reported as saturated rather than clipped in silence.
  • Mirror versus split — mirroring negatives back into the same lane is what makes a horizon chart as short as it is, and it spends sign as a channel: only hue and hatching are left to carry it. Splitting the lane at the zero rule buys sign back as a position and pays half the vertical resolution for it. Both are the same geometry with the direction flipped.
  • Gaps are breaks — an unmeasured tick ends the run and starts a new subpath, so a hole in the collector reads as a hole in the picture. Drawing it as zero would put a confident flat line where there is no evidence, and an isolated sample surrounded by gaps gets a fraction of a column of width so it cannot silently disappear into a zero-width area.
  • Column cursor — one instant, read across every lane at once. Pointer, arrow keys and a pin all move the same cursor, and while it exists each lane prints its own value beside its label. Stacking series is only worth the vertical space if you can align them; the cursor is the thing that does the aligning.

On This Page