SaaS Pro Max
Sign inStart free
Documentation menu

Charts and data visualisation

Source: apps/web/src/components/charts/**, apps/web/src/styles/tokens/charts.css (colour), apps/web/src/styles/charts.css (timeseries chrome), apps/web/src/styles/charts-categorical.css (bar lists, funnels, grids, tiles), charts-reliability.css (strip, histogram, bullet, scatter), charts-analytics.css (Sankey) and charts-growth.css (bridge). Entrances for all of them live in styles/motion.css. Contract: PLAN §14. Rewritten for UI Wave 4 (audit section E, I2, I5).

components/charts is the only public chart API, and every component and helper is exported from its index; import from @/components/charts, never from a file inside it. The board register charts (components/board/charts) are adapters over it: AreaChart renders the same figure, tooltip and keyboard layer as TimeseriesChart. Everything is dependency-free React that renders on the server; the client islands (hover layer, data menu, StatusStrip, the Sankey and retention hover wrappers) import nothing server-only, so the index is safe in either graph.

TSX
import { ChartDataMenu, InteractiveTimeseries, TimeseriesChart, entityColor, ratingBands } from "@/components/charts";

The shape of a chart#

Every chart renders the same parts:

  1. The marks, in SVG, described by one role="img" element whose aria-label states what is plotted, the series, the range, the peak, and anything provisional ("last bucket in progress", "axis starts at 17K, not zero").
  2. Every word as HTML: axis ticks, the legend, annotation pins and the tooltip. Text renders at its real 11-12 px in tabular Geist Sans at any width, follows browser zoom (200% at 1280 px stays legible, I5), and never shrinks with the drawing.
  3. The table twin, a <ChartTable> that is visually hidden, revealed in print and by the card's data menu. Every value the chart draws is in it, so a tooltip is always an enhancement.

Layout and the CSP#

The figure is a size container (container: spm-chart / inline-size) laid out as a grid: a y gutter beside the plot and an x axis under it.

  • Lines and areas are paths in a 0 0 1000 1000 viewBox stretched to the plot (preserveAspectRatio="none", vector-effect: non-scaling-stroke). Monotone cubics survive the stretch, because scaling one axis scales every slope by the same factor.
  • Bars, gridlines and dots sit in an SVG with no viewBox, whose x="37.4%" geometry attributes resolve against the plot at real pixel size: a dot stays round, a bar keeps a true 3 px corner.
  • HTML labels and pins carry their position as two data attributes, the integer percent and the tenth (data-xp="37" data-xd="4", data-yp, data-yd). charts.css maps them onto registered, non-inheriting custom properties (--spm-xp …) that left and top read. Plot heights travel the same way (data-plot-h="200"). Positions are correct in the server HTML with no script and no style attribute, which the strict CSP forbids.
  • The hover layer (crosshair, dots, tooltip) holds measured pixel offsets, so it uses React style props. That is allowed: it renders only after a pointer or key event, and React writes styles through the CSSOM, which the CSP does not govern.

Sizing. height is the nominal figure height in px (axis included, legend excluded); the plot takes height - 40 on a 20 px step between 120 and 400, so the default 240 gives a 200 px plot. Below a 520 px container the plot stays between 160 and 200 px whatever was asked, and minor x ticks drop by rank; below 340 px only the major ticks remain. The y gutter is as wide as its widest label (an invisible stack of the labels sizes it), so "R 20,000" is never clipped. width is deprecated and ignored: charts size to their container, and the per-page "phone" twins are no longer needed.


Colour#

All chart colour lives in styles/tokens/charts.css, in the tokens layer, with an identical prefers-color-scheme twin for dark. It is content colour: no skin reads or overrides it, and tests/charts/palette.test.ts holds it to the numbers below. Never write a literal colour in a chart.

Categorical slots#

One ramp, --chart-1 … --chart-8, each with a stroke tone (--chart-N) and a fill tone (--chart-N-fill). Contrast is against the card (--bg-surface, L 0.995 light / 0.205 dark, every skin).

SlotHueLight strokeContrastDark strokeContrast
1brand indigo-blueoklch(0.52 0.2 264)5.68oklch(0.7 0.15 264)6.60
2orangeoklch(0.63 0.18 42)3.71oklch(0.74 0.16 45)7.35
3tealoklch(0.58 0.1 182)4.01oklch(0.77 0.12 182)9.11
4purpleoklch(0.54 0.19 305)5.48oklch(0.7 0.16 305)6.30
5greenoklch(0.53 0.15 142)4.90oklch(0.72 0.17 142)7.67
6pinkoklch(0.58 0.19 352)4.69oklch(0.72 0.16 352)6.68
7amberoklch(0.62 0.125 90)3.61oklch(0.85 0.15 92)11.32
8brownoklch(0.48 0.08 55)6.62oklch(0.66 0.08 58)5.64
  • Every stroke clears 3:1 in both themes; there is no lime, and the yellow slot is a line colour that only fills at its tint.
  • Fill tones are 22% of the slot over the card in light and 30% over --bg-raised in dark (24% for amber), so dark areas never turn olive or maroon.
  • The order was searched so neighbouring slots stay at least 10 OKLab units apart for normal vision and simulated deuteranopia, protanopia and tritanopia, in both themes. Assign slots in order and never cycle them; past eight series the tail folds into Other (n).
  • Slot 1 is fixed at hue 264 (the Ocean accent). The accent chart tone resolves to slot 1 and secondary to slot 4, so a skin never repaints data.
  • The board names --bd-chart-1..8 are aliases of --chart-1..8 (styles/board/tokens.css); --bd-chart-9 is the neutral.

Neutrals, levels and ramps#

Token Use
--chart-other, -fill, --chart-hatch-other "Other", "Unattributed", the folded tail; hatched when it fills
--chart-compare, -fill The previous period: dashed line or ghost area
--chart-level-fatal/error/warning/info/debug Error volume by level, a red and amber family
--seq-1 … --seq-7 Sequential, one hue (264), quiet to strong in either theme
--div-neg-3 … --div-0 … --div-pos-3 Diverging, orange-red against blue, for movements
--chart-grid Solid 1 px gridlines at 60% of the border
--chart-axis, --chart-track Zero rule and fitted floor; the opt-in bar track
--chart-marker-* Annotation pins by kind

Helpers in colors.ts: colorVar(color) (stroke), fillVar(color) (fill), seqVar(ratio), divVar(signedRatio), seriesColor(index, explicit?).

Entity colours#

Colour follows the entity, not its rank, so a filter never repaints the survivors and Visitors is the same blue on every page:

TypeScript
entityColor("visitors")    // "chart-1"
entityColor("pageviews")   // "chart-2"
entityColor("other")       // "other" (also "unattributed")
entityColor("fatal")       // "level-fatal" (error, warning, info, debug)
entityColor({ variant: false, index: 0 })   // "other": false is neutral
entityColor({ variant: true, index: 1 })    // "chart-1"
entityColor({ variant: "b", index: 2 })     // "chart-3": named variants by definition order

Status tones (positive, warning, danger) are for series that mean good or bad, always with a word beside them.


<TimeseriesChart>#

Line, area or bar, stacked or not, with the previous period, pins and provisional buckets. Server component; wrap it in <InteractiveTimeseries> for hover and keyboard reading.

TSX
<InteractiveTimeseries>
  <TimeseriesChart
    series={[{ name: "Visitors", points, color: entityColor("visitors") }]}
    kind="area"
    interval="day"
    timeZone={app.timezone}
    compare={{ name: "Previous 30 days", points: previous }}
    partial="end"
    asOf={generatedAt}
    markers={[{ at: release.at, kind: "release", label: "v2.4", href: `/releases/${release.id}` }]}
    label="Visitors"
    id="traffic-chart"
  />
</InteractiveTimeseries>
PropTypeDefaultNotes
series{ name, points: { t, value }[], color?, additive? }[]—Past eight, the tail folds into Other (n)
kind"line" | "area" | "bar""line"Lines and areas curve (monotone) from 6 buckets; shorter ones stay straight
stackedbooleanfalseAreas and bars with two or more series
interval"minute" | "5m" | "15m" | "30m" | "hour" | "day" | "week" | "month""day"Drives ticks, tooltip, caption and table labels. On a clock axis the keys' own spacing wins when it is finer, so 15-minute keys under "hour" tick and caption as quarter hours
timeZoneIANA zonehost zonePass the app's zone; hourly labels always print it
heightnumber240Nominal figure height, see Sizing
valueFormat(n) => stringformatNumberTooltip, legend, table
axisFormat(n) => stringvalueFormat without redundant decimals, else compacty ticks only ("$1,000", not "$1,000.00"). Ticks that format alike are told apart automatically, see Axes and domains
compare{ name, points, style?: "line" | "ghost" }—Aligned by index; ghost under areas, dashed 1.5 px line otherwise. comparison is the deprecated name
partial"start" | "end" | "both" | true—true is the Wave 0 spelling of "end"
asOfdate—"In progress (as of 10:42 AM)" in the tooltip
markers{ at, kind?, label, href?, detail?, until? }[]—Pins on the time axis; until shades the span of something that lasted (an incident, open to resolved; pass the read time while it is open). annotations ({ t, label }) still work as notes
bands{ from?, to?, tone?, label?, extend? }[]—Threshold zones behind the marks, see Bands
domain"zero" | "data" | "symmetric""zero"baseline="data" is the deprecated name
minSpannumbera tenth of the series' size under domain="data"Smallest total y extent, in data units; 0 fits a stock tightly
yMin / yMaxnumber—Pin the axis
legend"inline" | "bottom" | "none"inline for 2+ seriesshowLegend is the deprecated boolean
legendValue"sum" | "avg" | "last" | "none"sum for counts, none once valueFormat is set
tracksbooleanfalseGhost tracks behind one series of bars
labelstringseries namesSubject of the aria-label, caption and interactive group
emptyLabel, emptyAction, sparseLabel, quietLabelSee Empty, sparse and quiet
maxPointsnumber400Longer ranges are average-downsampled (the aria-label says so)
maxTicksnumber8Tick budget at full width
id, classNamestringid also names the table twin ${id}-table
width——Deprecated, ignored

SeriesFigure is the renderer underneath, for buckets that already have names (the board adapter uses it). Prefer TimeseriesChart whenever the axis is time.

Provisional buckets#

partial marks buckets that are still filling up: today, this hour, a week or month cut by the range at either end. The edge into a provisional bucket is dashed, its area lighter, its point hollow, its bar hatched in the slot colour; the table labels it "(in progress)", the tooltip says "In progress (as of 10:42 AM)" when asOf is given, and the tooltip never states a change for it.

Deltas must not compare a partial bucket with a finished one. periodDelta (from compare.ts) drops the partial buckets from both sides, or, when the query can say what the previous period had reached by the same elapsed time, compares like with like:

TypeScript
periodDelta(current, previous, { partial: "end" });                              // basis "complete"
periodDelta(current, previous, { partial: "end", previousAtElapsed: 412 });      // basis "elapsed"

Axes and domains#

  • Calendar ticks. Ticks fall on boundaries in the chart's zone: every hour step aligned to midnight, midnight itself in bold as "Tue 4" inside an hourly axis, Mondays for a month of days, the 1st and the 15th, quarters, and the year turn as "Jan '26". Each tick carries a rank, so narrow charts keep the two or three that matter (day boundaries first). Clock axes test whether a boundary falls inside a bucket, so 24 hours of 15-minute buckets tick every three hours plus the bold midnight, keys that are not aligned to the hour still find their boundaries, and no tick text is ever printed twice. rangeTicks(start, end, tz) gives the same ticks for a continuous axis (the duration scatter).
  • Tick labels never repeat. When adjacent ticks format alike (a $25 step under "$3.5K"), distinctTickLabels tries one more decimal on the same compact form when that states the tick exactly ("$3.45K"), then the fuller valueFormat ("$3,425"), then two more decimals, and finally drops every other tick. Every engine axis goes through it.
  • Gutters are measured from the widest label, never guessed.
  • domain="zero" for flows (visits, errors).
  • domain="data" for stocks (MRR, active subscriptions): a nice domain near the data, for lines and areas. Its span covers at least a tenth of the series' largest value (±5%), so a 1.4% dip on $3.4K of MRR reads as a dip, not a cliff; minSpan overrides it. An area on a fitted domain draws a dashed floor and a break mark on the axis, and the aria-label says the axis does not start at zero, so a fill never implies one. Bars always keep zero.
  • domain="symmetric" for movements: centred on zero and at least minSpan wide (for MRR movements, about 2% of MRR), so one −$49 bucket cannot fill the plot.
  • Gridlines are solid hairlines (--chart-grid); dashes are reserved for the previous period, provisional segments, the quiet baseline and a fitted floor.

Bands#

bands shades a range of the y axis behind the marks: a rating, an alert's firing side, an objective. Leave out from (or to) for a zone that runs to the floor (or the ceiling); from === to draws a dashed threshold line. Tones are good, warning, poor, target and neutral; the label prints in the zone and joins the aria-label. The label is a chip on the card colour at the plot's right edge, at the top of its zone or, when a line or bar runs through that spot, at the bottom; a zone too thin for a label, or one whose both spots are crossed, names itself in a key under the plot instead. extend (default true) widens the domain to show the zone's edges; pass false for an outer zone that would squash the data, or "from" / "to" for one edge. Use the helpers rather than hand-built zones:

Helper Use
ratingBands(good, poor) Web vitals and latencies, lower is better
thresholdBands(value, comparator, label?) An alert rule: the firing side and its line
targetBands(target, { higherIsBetter? }) An objective line with the missing side shaded
budgetBands() Remaining error budget: healthy, watch, nearly spent

Bars#

A 3 px radius on the data end only, the baseline end square; stacked columns round only their outermost segment and separate segments with a hairline of card. Each bar is one shape (a fully rounded rect twice its height, clipped by a nested viewport the bar's size), so a page that sets fill-opacity on a series gets an even tint with no seam, rising or hanging. Bars grow from the baseline edge they carry as data-yp / data-yd. Tracks are opt-in and transparent under forced colours. Fewer buckets get proportionally more air, so five days never read as five slabs.

Legends#

The default legend is inline: key, name and an optional figure, above the plot, without card chrome. The key mirrors the mark (stroke, swatch, dashed stroke for the previous period). When a card head already states the total, pass legend="none" or legendValue="none" rather than printing it again; legend="bottom" keeps the older tile grid. Legend figures roll in with RollingNumber. <Legend items variant="inline" | "tiles" /> is exported for a card that places the legend in its own head.

Annotation pins#

markers draws a circled pin in a strip of its own between the baseline and the tick labels, so a pin never covers a bar or the line, coloured and glyphed by kind: deploy, release, campaign, incident, flag, note. Pins closer than 3% of the width cluster into one numbered pin. Hover or focus reveals the pin's vertical rule and a popover (kind, label, time, optional detail and link); the popover is CSS, so it works before hydration. Markers outside the plotted range are dropped.

Empty, sparse and quiet#

  • No data: a ghost chart (faint grid, a placeholder curve at 15%) about 140 px tall with emptyLabel and an optional emptyAction ("Widen to 30 days", "Install the snippet").
  • Sparse: a line or area with fewer than three buckets shows the ghost with sparseLabel ("Collecting data, check back in an hour") and the figures in words. Bars still draw.
  • Quiet: every bucket zero draws a short plot with only a dashed baseline and the zero tick, and prints quietLabel when given.
  • <Sparkline quietLabel="No evaluations" /> prints the sentence instead of a flat line.
  • <Sparkline partial="end" /> and the board <SparkArea partial /> draw a bucket still filling up as a dashed, unfilled segment with a hollow end dot, and leave it out of the vertical scale, so today's partial count never reads as a plunge. StatTile passes sparkPartial through; a StatCard caller passes partial to the SparkArea it supplies.

<InteractiveTimeseries>#

Wraps a server-rendered TimeseriesChart (or a board AreaChart) without re-rendering it: it reads percent geometry and pre-formatted text from the figure's data-chart payload.

  • Hover dots on every series and on the previous period, a crosshair for lines and a column highlight for bars, snapping to the nearest bucket.
  • The tooltip follows the pointer's height inside the plot and reads "Sep 3, 2:00 PM EDT", then each series' value and name with the change ("+14.7%") on the first, then the previous value with its own date ("1,050 Aug 27"), then "Total" for stacks.
  • The host is role="group" with aria-roledescription="interactive chart" and an aria-label (the label prop, or the chart's caption). Arrow keys, Home, End and Escape walk the buckets, and each step is spoken once through a visually hidden polite live region: "Sep 3: Visitors 1,204, previous 1,050, up 14.7%".

bucketHrefs (one per bucket, null for none) makes each bucket open the rows behind it (E10): a click on the plot, or Enter on the bucket the arrow keys are reading, follows the link. bucketHrefLabel ("Open issues") is shown at the foot of the tooltip and spoken with the bucket ("… Press Enter to open issues."); bucketHint replaces the default keyboard sentence when the page needs to say more. Pins, legends and links inside the figure keep their own clicks. A figure that drew a different number of buckets than there are links (it was downsampled) links nothing rather than open the wrong dates. Board AreaChart / AreaChartCard take the same three props.

TSX
<InteractiveTimeseries bucketHrefs={hrefs} bucketHrefLabel="Open issues">
  <TimeseriesChart … />
</InteractiveTimeseries>

Range selection#

rangeLink turns on brush-to-zoom (F3.1, J2). Dragging across the plot with a mouse or pen paints a selection labelled with its two ends ("Sep 3 – Sep 9"); on release the chart fills the {from} and {to} placeholders of rangeLink.href with the first and last selected buckets' keys and follows the link. The page decides what a range means in its own URL (?range=custom:{from}..{to}, ?from={from}&to={to}), so the zoomed view is an ordinary link that can be shared and reloaded. from and to hold one key per bucket in the chart's order (to is inclusive); label names the action ("Show hours", "Show these days") and defaults to "Zoom in". Placeholders may be raw or percent-encoded, so the href can come from hrefWith.

  • Click a day to zoom. Without bucketHrefs, a click on one bucket, or Enter on the bucket the arrow keys are reading, zooms to that bucket alone. This is how a day drills into its hours. With bucketHrefs, a click opens the bucket's rows instead and only a drag selects a range. Pages whose finest grain is the hour pass no rangeLink on an hourly chart: there is nothing finer to zoom to, so an hour opens its events.
  • Keyboard. Shift with the arrow keys, Home or End extends a selection from the bucket being read, Enter applies it and Escape clears it. The selection and the action are spoken through the live region, and the tooltip names the action on a single bucket.
  • Touch keeps its scroll: a finger reads buckets and a tap zooms, but it never drags a selection.
  • A selection that covers every bucket is the current view and is ignored. A link whose from or to length differs from the drawn buckets (the figure was downsampled) turns selection off rather than open the wrong dates.
  • onRangeSelect receives the selection (start, end, from, to, href, label, count) instead of navigating, for a client parent that keeps the range in its own state. It takes precedence over rangeLink.href.

Board AreaChart / AreaChartCard take rangeLink too. When the page also offers "Why did this move?" (J2), the range link clears ?why= so a zoom does not reopen an explanation of the previous range.

TSX
<InteractiveTimeseries
  rangeLink={{
    href: hrefWith(base, search, { range: "custom:{from}..{to}", why: null }),
    from: buckets.map((bucket) => bucket.from),
    to: buckets.map((bucket) => bucket.to),
    label: range.interval === "day" ? "Show hours" : "Zoom in",
  }}
>
  <TimeseriesChart … />
</InteractiveTimeseries>

<ChartDataMenu>#

A small client menu for the card head: "Show data table" and "Download CSV".

TSX
<CardHead title="Traffic" action={<ChartDataMenu target="traffic-chart" />} />

target is the id of any element that contains a chart's table twin (the chart itself, or its card). Showing the table toggles the twin's is-visible class; the CSV is written from the same cells, with spreadsheet formulas defused. Copy image is not offered: it cannot be done faithfully without a dependency. Raw bucket tables printed under a chart should move behind this menu.

Board AreaChart adapter#

AreaChart / AreaChartCard keep their props and now render SeriesFigure inside InteractiveTimeseries. New optional props: pointLabels (full tooltip names when labels are axis abbreviations), times + interval + timeZone (a real timeseries with calendar ticks and dated, zoned tooltips), partial, markers (by index), timeMarkers and asOf (with times), compareStyle ("line", the default dashed line, or "ghost", the shaded previous period Analytics draws; dashboards use the ghost), and the bucket links above. AreaChartCard pads its body like every card and prints the footer only for two or more series, because a single series' total is already the head figure. SparkArea stays a bare SVG; it takes partial like Sparkline.

KPI tiles#

StatTile and the board StatCard take an optional href that makes the whole tile one link to the rows behind the figure (E10): new issues to the issues sorted by first seen, churn to the churned subscriptions of the range. The focus ring is drawn on the tile (inside its edge on a StatCard, where a row's shared plate would clip an outer ring), and the accessible name is the label and value, then the change and caption when they are text: "Users affected: 1,204, +12%". A linked tile holds no other links or buttons. Link only to an existing route and filter. A tile's sparkline stands on the tile's foot, so a row of tiles lines its trends up even when one change chip wraps.

Other chart types#

Each is documented in its source header; this is when to reach for it.

ComponentUse it forNotes
StatusStripWhen did it break, and for how long: uptime days, cron runs, probe rounds, daily budgetOne cell per period, coloured and worded (status); variant="bars" scales cells by value, lanes stacks probes; cells can link. Client island
BulletBarOne measure against its limit: a latency or error budgetdirection says which side passes; replaces <progress> and meters for budgets
HistogramHow a measure is distributed: request durations, vital scoresLog bins (niceLogEdges), zones for ratings only (neutral measures keep slot 1), secondary for the failed part, p50/p95 markers, bins can link. Up to 16 bins print their count; empty bins at either end are dropped from the plot
DurationScatterEach request as a dot over time on a log axisShows regressions, retry storms and timeouts a p95 line hides; failed dots in the danger tone. Calendar x ticks, compact duration y ticks (axisFormat)
SankeyPaths: where sessions go after (or before) a stepRibbons as wide as their flow; nodes link to re-anchor; hover lights the route
FunnelFlowA funnel as a flow: who continued, and where the rest wentSteps along the top with one accent band for those who continued; each step's loss fans out in the neutral to its destinations (reconcileDropOffs keeps them equal to the loss). Shares are always printed (of the start beside a step, of the step beside a destination or band); the hover card adds counts. Reuses ribbon() and SankeyHover (direction="upstream", tooltip); scrolls inside its card below 220 px per step
BridgeChartWhy a level changed: start, movements, end (the MRR bridge)Horizontal rows, each movement links to its rows; a stock's axis is fitted and says so (resolveBridge)
RetentionCurveThe decay shape above a cohort gridSize-weighted per segment, dashed where no complete cohort has arrived
RetentionLinkedWrap a RetentionGrid and its RetentionCurvePointing at a cell lights its point on the curve

The categorical charts (BarList, FunnelChart, RetentionGrid, Heatmap, GaugeTile, and the board BarListChart, ColumnChart, FunnelChart, RadialChart and grids) share this palette, the table twin and ChartEmpty. Heatmap renders its table twin only with dataTable, because its grid is already a real table; pass it when the card carries a ChartDataMenu. A grid too wide for its card scrolls, and opens where the reading starts: a 24-column hour-of-day Heatmap at 08:00 (openAt to change it, null for the first column) and the board ContributionsGrid at its latest month. ScrollOpen is the small client marker that does it after hydration. A StatusStrip cell with no data is a quiet outlined cell, never a gap.

<ChartEmpty label description? action? height? variant? ghost? />, <ChartTable caption columns rows visible? id? /> and <Legend /> are the building blocks; use them in a bespoke visual so it matches the rest.


Motion#

Charts keep the Wave 3 grammar (DESIGN.md, Motion): gridlines fade in, lines draw from the left (pathLength="1" dashes), areas rise, bars grow from the baseline in data-wave order, dots pop, legend items rise and their figures roll. The drawing is keyed on its data (motionKey), so a range change replays the entrance. Strip cells bloom, histogram bars rise, bullets fill, scatter dots pop, Sankey columns grow before their ribbons fade in, and bridge rows grow from the left. Everything is instant under reduced motion, through the one switch at the end of motion.css. Chart sheets declare no animations and no reduced-motion blocks of their own; a new chart type adds its entrance and its root selector there.


Formatting#

@/lib/format is pure and pinned to en-US. Chart-specific labels live in components/charts/time-axis.ts:

Function Example
bucketLabel(t, "hour", tz) Sep 3, 2:00 PM EDT
bucketLabel(t, "day", tz) Sep 3, 2026
shortBucketLabel(t, "day", tz) Aug 27
clockLabel(t, tz) 10:42 AM
bucketLabel(t, "15m", tz) Sep 3, 2:15 PM EDT
shortBucketLabel(t, "15m", tz) Sep 3, 2:15 PM
calendarTicks(keys, interval, tz, max) ranked calendar ticks, each text once
rangeTicks(start, end, tz, max) the same for a continuous time axis
resolveInterval(interval, keys) the clock interval the keys really are
distinctTickLabels(values, format, alternatives) tick labels that never repeat

Do / don't#

Do

  • Let the data's job pick the form. One number: StatTile. A ratio against a limit: GaugeTile, or BulletBar for a budget. A ranked breakdown: BarList. Change over time: TimeseriesChart. Health over time: StatusStrip. A distribution: Histogram. A flow: Sankey. Why a level changed: BridgeChart.
  • Link a figure to its evidence: bucketHrefs on a timeseries, href on a KPI tile, href on bins, cells, nodes and bridge rows.
  • Pin a series' colour to its entity with entityColor.
  • Pass timeZone, partial and asOf on every range that ends now.
  • Pass compare on dashboards; the tooltip and live region do the rest.
  • Use domain="data" for stocks and domain="symmetric" with minSpan for movements.
  • Give a chart a label and an id, and put a ChartDataMenu in its card head.

Don't

  • Don't build a dual-axis chart.
  • Don't hand-pick a colour or use the skin accent for data.
  • Don't print the same total in the head, the legend and the footer.
  • Don't render a second "phone" copy of a chart; the figure adapts.
  • Don't dash gridlines, and don't use a status colour for a non-status series.
  • Don't rely on the tooltip for a value that is not also in the table twin.

Testing#

apps/web/tests/charts/ renders with react-dom/server under environment: node: timeseries-chart.test.ts (markup and payload), chart-engine.test.ts (ticks, deltas, geometry, the interactive layer's pure functions, the board adapter), palette.test.ts (contrast, gamut, dark fills, colour-vision separation, single definition), board-slots.test.ts (the board palette bridge resolves the slot a figure asks for), reliability.test.ts, sankey.test.ts, growth.test.ts and categorical.test.ts for the other types. tests/motion/motion-css.test.ts keeps every chart sheet free of animations and holds the single reduced-motion switch. Always pass an explicit timeZone in tests.