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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-horizon.jsonPrompt
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 × rowHeightworth of range inrowHeightpixels 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 → --foregroundwith 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
bandValueis 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.
Icicle
A four-state linear-partition hierarchy chart in DOM blocks — one level per row or column, length proportional to value, click-to-drill with a breadcrumb, honest label truncation with tooltips, and an sr-only breakdown table.
Punchcard
A weekday × hour dot grid — every Monday 09:00 folded into one bucket, dot area on a square-root scale, switchable size / colour / both encodings and the aggregation timezone stated on the card.