Spiral Timeline
Time wound onto an Archimedean coil — one ring per period, so the same phase of every turn lands on one radial line — with bars or dots on the coil, a floating zero baseline, measured zeros told apart from windows the series never covered, and a single-ring series refused rather than drawn.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-spiral.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSpiral" card in plain SVG with zod.
Recharts has no spiral primitive and the layout is not a cartesian series, so
the geometry, the scale and the label fitting are done by hand in small pure
functions exported beside the component. No d3, no new dependency, no runtime
maths that a test cannot print.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string;
cycle: { length: number; turn: string; slot: string; phases?: string[] };
turns?: string[];
points: { id: string; slot: number; label: string;
value: number | null; meta?: string }[] }.
- `slot` is the ONLY positional field and it carries both coordinates:
slot % cycle.length is the PHASE (the angle, which is what makes March land
on the same radial line every year) and floor(slot / cycle.length) is the
TURN (the ring). Slot 0 is the first slot of turn 0, so a series starting in
March of its first year starts at slot 2 — otherwise every phase rotates and
the alignment the chart is built on is a lie. Do not take date strings: that
would put a calendar parser in the render path and give every caller a new
way to be wrong.
- `value: null` is a window that was measured and returned nothing. An ABSENT
slot is a window the series never covered. They are counted separately and
drawn differently, because on a rainfall chart they are the difference
between a drought and a broken gauge.
- `turns[]` is keyed by ABSOLUTE turn index, so a series that starts in turn 2
still finds its own name at turns[2]. cycle.turn / cycle.slot are singular
nouns ("year", "month"); an s is appended for counts.
- superRefine: a ready spiral needs at least one real reading; ids unique;
two entries may not claim one slot (a second mark in a cell is unreachable by
pointer and by keyboard alike); cycle.phases, when given, must have exactly
cycle.length names. Guard every access — sibling refinements all run, so a
ragged payload has to produce an issue rather than a TypeError thrown out of
safeParse.
- Props = z.infer of the schema plus description, mark ("bar" | "dot", default
"bar"), startAngle (degrees, default 0), bandRatio (default 0.78, clamped
0.3-0.92), nowSlot, nowLabel ("now"), linkPhase (default true), locale
("en-US"), formatValue, onSelect, onRetry, className and the div's native
props, forwardRef to the card.
- Export the maths so it is testable and so this prompt can describe it:
buildSpiralGeometry(), spiralPoint(), spiralPolyline(), spiralBandPath(),
spiralValueOffset(), buildSpiralModel(), placeRimLabel().
Behavior
- THE GEOMETRY IS THE COMPONENT. An Archimedean coil parameterised by t in
turns: radius(t) = inner + pitch·t, angle(t) = startAngle + 2π·t, angle 0 at
twelve o'clock growing clockwise. A cell is the patch of coil from t to
t + 1/length, so cells tile the whole disc with no seams and no gaps.
- SOLVE FOR THE PITCH, DO NOT GUESS IT. The band a reading may use is a
fraction of the pitch and the outermost ring must keep half of that band
inside the rim, so pitch·turns + pitch·ratio/2 = RIM − HUB. The naive
pitch = (RIM − HUB)/turns puts the tallest reading of the last ring outside
the viewBox, where SVG clips it without a word. The innermost ring likewise
keeps half a band outside the hub, or a below-zero reading folds through the
centre and comes out the far side pointing at the wrong angle.
- DRAW ALONG THE SPIRAL, NOT AS ANNULAR SECTORS. Across one cell the coil
climbs pitch/length, so a sector at the cell's mean radius misses the curve
by half of that at each end — 1.6 units at twelve cells and a 39-unit pitch,
which reads as a step at every seam. Flatten the arc into a polyline at ~72
samples per turn and close the patch between two radial offsets; one helper
then draws the bar, the outline and the hit region.
- ONE LINEAR SCALE THAT ALWAYS CONTAINS ZERO. offset(v) maps [min(0, data),
max(0, data)] onto the band. With nothing negative the baseline lands on the
inner edge and the whole band is available outward; with something negative
the baseline floats and those readings dip inward at the SAME units per
pixel — never a second scale for the negative half. A dashed coil is drawn at
the baseline so zero is a visible line, not an assumption.
- NOTHING DISAPPEARS QUIETLY:
* a reading that rounds to less than a 1.5-unit mark still gets that tick, so
a measured zero can never read as an empty cell;
* a measured-empty window is OUTLINED and never filled, so it cannot look
like a magnitude;
* a cell the series never covered is left bare, and the count of them is
printed;
* an entry with a non-integer slot, a NaN reading, or a slot already claimed
is dropped, COUNTED and named on the card. Placing NaN would paint nothing
at all — a silent hole in a chart whose whole claim is that the coil is the
calendar.
- REFUSE A SINGLE RING. If every reading falls inside one turn, the radial
alignment encodes nothing and the reader is decoding a wheel drawn the hard
way. Render a refusal that says so and names the chart that fits (a polar
area chart, or a plain bar chart) instead of drawing it anyway. This is the
one shape of data the component will not fit.
- LABELS. Phase labels sit just outside the rim, anchored away from the centre,
each with its own character budget computed from the distance between its
anchor and the edge of the box — sixty-odd characters at twelve o'clock,
a handful at three. Over budget they elide with a <title>; under two
characters they drop out entirely and keep their spoke, their cells and their
table row. The whole set thins by a stride computed from the worst case (two
neighbours lying flat at the top), and the card says how often it printed
them. Ring labels sit on the seam with a --card halo, and thin to first and
last when the rings crowd.
- INTERACTION, AND A GESTURE IS NEVER THE ONLY PATH. Hit regions tile the coil
with the UNPADDED cell, so the gutter between rings is not a dead zone and a
1.5-unit zero tick is as easy to point at as the tallest bar. Roving tabindex
over the cells — one tab stop for the whole chart. Left/Right step one slot
along the timeline and CLAMP at the ends (wrapping would put December next to
the first January and call it one step); Up/Down jump a whole turn to the
SAME phase, skipping rings that never covered it, which is the move the chart
exists for; Home/End go to the ends; Enter/Space select. preventDefault fires
only for keys that were handled, so Tab still leaves the chart.
- While a cell is active — by pointer or by focus, the two share one state —
its phase's spoke lights up and every other ring's cell at that phase is
outlined. That is the comparison the chart is drawn for, and it costs no
extra control.
- The "now" marker comes from an INJECTED nowSlot prop, never from Date.now: a
chart that read the clock while rendering would disagree with the server that
rendered it and would be a different picture in every screenshot.
- Four first-class branches of one card plus the refusal: loading (a
deterministic skeleton coil, aria-hidden, plus one sr-only role=status line),
empty (a valid contract with no readings, worded so it cannot be mistaken for
a failed fetch), error (a Try again button only when onRetry was passed;
pressing it remembers the press in a ref and hands focus to the card once the
new branch mounts, so focus never lands on <body>), ready. A ready chart with
no real reading renders the empty branch.
- CLEANUP: there is none to do — no timers, no rAF, no observers, no
simulation. The viewBox is fixed and the SVG scales itself, so there is no
measurement to keep in sync either.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
for the frame and the grid, muted for the skeleton, muted-foreground for
spokes, labels and the outlines of empty cells, foreground for the active
spoke and the now marker, ring for focus, var(--chart-1..5) cycling per ring.
- COLOUR NEVER CARRIES A VALUE — length does. Colour only separates
neighbouring rings, every ring is named on the seam and in the legend, and
past five rings the ramp repeats, which is harmless precisely because two
rings that share a token are never next to each other. Never use a chart
token as a text colour.
- Focus and hover share one indicator: two stacked strokes, --card under
--foreground, drawn around the whole cell, because a UA outline around a
curved path's bounding box is a rectangle the size of a quadrant.
- Every label carries a --card halo via paint-order:stroke, so it stays legible
wherever a bar happens to reach.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every focusable cell. Use
role="group", labelled with the title plus the keyboard map and described by
an sr-only summary that states the finding in words: how many rings, which
reading is highest, which phase averages highest and lowest, and what was
dropped. Each cell's accessible name is its own sentence: label, phase, turn,
value, and how far it sits from ITS OWN phase mean — a difference, never a
ratio, because a ratio flips sign on a temperature anomaly and divides by
zero on a dry month. The visible readout line is aria-hidden (focus already
announces it); a polite live region carries the SELECTION only, which is the
one commitment focus does not announce. Below the plot, an sr-only WRAPPER
DIV (never sr-only on the table itself: CSS width is only a lower bound for a
table box, so width:1px does not hold one back and a narrow viewport picks up
real horizontal scroll) holds the seasonality matrix — one row per phase, one
column per ring, plus that phase's mean — because a row of that table is
exactly what the picture draws as a radial line.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none, and the retry button's colour transition, carrying
motion-reduce:transition-none. The chart is complete and readable with every
animation off.
Customization levers
- cycle.length is the wind, and it is a decision rather than a fact: the same
sixty readings wound at 12 line up as years and wound at 4 line up as
quarters. Change it and the whole claim of the chart changes with it — say
out loud which one you chose.
- mark: "bar" when levels matter and two rings must be comparable at a glance;
"dot" when the shape of the run matters more, since consecutive slots are
joined into a line wound onto the coil. Only genuinely adjacent slots are
joined, so a gap breaks the run instead of being bridged.
- bandRatio trades reading height against the gutter between rings: 0.9 for a
hero card with three rings, 0.55 when eight rings have to stay legible as
eight. startAngle rotates the seam — pass -180/cycle.length to centre the
first phase on twelve o'clock instead of starting it there.
- linkPhase off when the card is decorative and the phase echo is noise; on
(default) whenever the reader's question is "how does this year compare".
- nowSlot / nowLabel: any injected instant, not only the present — a launch, a
policy change, the start of a backfill. Fractional slots are allowed, so it
can sit between two cells.
- Palette: re-point ringInk to one token for a monochrome coil, or key it off a
status (complete / partial) rather than the ring index when colour should
mean something; the length encoding is unaffected either way.
- Scale: a fixed domain across several cards makes them comparable — add it as
a prop and then decide out loud what happens to readings outside it (count
and name them, never clamp, or a bar would claim a value nothing was measured
at). RIM, HUB and the sample rate are the three constants that trade detail
against file size.
- formatValue / locale / unit carry bytes, currency and durations without
touching the geometry; the summary, the readout and the table all go through
the same formatter, so they can never disagree.Concepts
- Time as a coil — one turn is one period, so a reading has two coordinates from a single number: the angle says where it sits inside the period and the radius says which period it was. That is what buys a long series both readings at once — a line chart has to pick one, and a calendar grid gives up the continuity between the last cell of a row and the first of the next.
- Radial phase alignment — every turn starts at the same angle, so every March lands on one radial line and the seasonal shape becomes a wedge of the disc rather than five bumps a reader has to hold in their head. It is also the axis the keyboard is built around: up and down jump a whole turn to the same phase, skipping the rings that never covered it.
- Solve the pitch, do not guess it — the gap between rings has to leave room for the tallest reading of the outermost ring, so it comes out of
pitch · turns + pitch · ratio / 2 = RIM − HUBrather than out of(RIM − HUB) / turns. Guessing costs the last ring's peak, and SVG clips it without saying anything. - Floating zero baseline — the value scale always contains zero, and the dashed coil is drawn where zero falls. With no negative readings that is the inner edge of the band; with negatives it floats and those readings dip inward at the same units per pixel, so a temperature anomaly never gets a second, kinder scale for the cold months.
- Measured zero versus uncovered cell — a real zero keeps a minimum tick, a window that was measured and came back empty is outlined and never filled, and a cell the series never covered is left bare and counted. Three different facts that an API hands over as the same absence, drawn three different ways, because on a rainfall coil that is the difference between a drought and a broken gauge.
- Refusal as a feature — a series that fits inside one turn is not a spiral: there is no second ring, so the alignment encodes nothing and the picture is a wheel drawn the hard way. The card refuses it by name and points at the polar area chart instead. Fitting it anyway would be the one failure mode a spiral cannot recover from, because the reader cannot see that the comparison is missing.
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.
Ternary Plot
A four-state ternary plot in plain SVG — three-part compositions normalised to 100% on an equilateral triangle, with three gridline families, edge-rotated axis ticks, shaded classification zones and a keyboard-reachable mark per row.