Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartSpiralCycle, ChartSpiralData, ChartSpiralPoint } from "./chart-spiral.contract"

/** How a reading is drawn on the coil. */
export type SpiralMark = "bar" | "dot"

export interface ChartSpiralProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect" | "title">,
    ChartSpiralData {
  /** Second header line — put the window, the station, the query here. */

Installation

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

Prompt

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 − HUB rather 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.

On This Page