ReviewOS

stacks/reportshq

Chart component library on ts-charts

#11
Closed chris opened this 8 days ago ·
8 days ago

Context

An stx chart component library for ReportsHQ, built on ts-charts (~/Code/Libraries/ts-charts - our complete D3 rewrite: @ts-charts/scale, shape, axis, selection, interpolate, format, ...). No third-party chart libs. Where ts-charts lacks something (e.g. a higher-level chart preset, a missing curve, brush polish), improve it upstream and bun run release:patch.

Read the dataviz skill before writing any chart code, and take its color/mark/interaction discipline seriously - these charts ARE the product.

Scope

  • resources/components/charts/: LineChart, AreaChart, BarChart (grouped + stacked), DonutChart, BigNumber (value + delta vs comparison), DataTable, FunnelChart, Heatmap - each an .stx component taking the query-engine result shape (#10) plus viz options (#9).
  • Theming from the #3 tokens only: categorical palette defined as chart tokens in tokens.css (light + dark variants, contrast-checked per the dataviz skill); no hardcoded hex in components.
  • Axes/formatting via @ts-charts/axis + @ts-charts/format: adaptive tick density, compact number formats (1.2k, 3.4M), currency-aware when the measure is sum(value) with currency.
  • Interactions: shared hover tooltip (crosshair for time series), legend toggling, keyboard-focusable data summaries for accessibility; respect usePreferredReducedMotion() - no entrance animation theatrics.
  • SSR-safe: server renders a real SVG (reports must be crawlable/sharable, #16); client hydrates interactions. Remember the auto-imports rule: TS modules imported by the stx entry must import their own dependencies explicitly.
  • Empty/loading/error states per component (skeleton, "no data in range", partial-data notice) - designed, not default-gray.
  • A dev-only gallery route rendering every component against fixture data in both themes; this is the review surface for the design pre-flight.
  • Component tests on fixture data (rendered SVG structure, formats), not just snapshots.

Acceptance

  • Gallery passes the stacks-design-taste pre-flight in light and dark; seeded demo reports (#9) render through these components with real query results (#10).

Depends on #3, #10. Blocks #12, #13. Part of #1 (M3 Reporting core). Ground rules: see #1.

8 days ago

Shipped in 41f5353. 231 tests, CI green.

Eight components (line, area, bar, donut, big number, table, funnel, heatmap, plus a text block), all rendering server-side as real SVG from the same EngineResult every surface already has. That is a requirement rather than a preference: a share link (#16) must draw a chart with no JavaScript, a scheduled email (#15) with no browser at all, and the app should paint a report before it hydrates.

The palette was computed, not chosen, and that changed the design.

Running the dataviz validator rather than trusting my eye caught two colours I had picked in #3 that measured 0.094 and 0.086 chroma against a 0.1 floor: they read as grey to everyone, in a product whose whole job is telling series apart. Then six hues turned out to be impossible. Dark mode's lightness band is narrow (0.48 to 0.67), and six hues cannot clear the colour-vision separation floors inside it without wrapping the wheel and repeating a family, which is a palette that looks varied and is not.

So there are five categorical colours and a neutral "Other", which is what a folded tail should look like anyway. The same five hues serve both themes at different lightness, so a series never changes identity when someone switches theme. Both palettes pass every check against the real surfaces:

LIGHT  #6860f2,#ae1f61,#9d7120,#1c754a,#2383b4   surface #fcfbf9   ALL CHECKS PASS
DARK   #6963ec,#e33783,#9c7228,#349c67,#2d83b0   surface #0b0d12   ALL CHECKS PASS

One adjacent pair sits in the 6 to 8 CVD band, which is permitted only with secondary encoding. That is why every multi-series chart carries a legend, why adjacent bars keep a 2px gap, and why the table view exists. They are the condition of the palette being legal, not decoration, and MAX_SERIES is now 5 with a default of 4 for the same reason.

Two silent bugs, both filed upstream.

<Heatmap> resolved to a built-in stx component rather than mine, and rendered a rainbow legend that could not possibly have come from these tokens. That was the only symptom; nothing warned. Renamed to HeatmapChart, and the rainbow ramp itself is stacksjs/stx#1938: it reads as bands where the data is continuous, and it is not monotonic in lightness, so it collapses in greyscale.

Worse, and worth reading: stx drops every prop passed from one component to another in this version. A wrapper passing an array, a boolean and a plain string attribute delivers an empty array, false and an empty string, silently. Page-to-component works fine, which is why it took a missing legend to notice. A shared ChartFrame was therefore rendering without its legend on every chart, which is precisely the encoding the palette depends on, so the failure looked like a working page while being an accessibility regression. Minimal reproduction filed as stacksjs/stx#1937; the frame is inlined per component until it lands.

Verified in a real browser in both themes, since computed styles prove more than a screenshot: line strokes resolve to rgb(104, 96, 242) in light and rgb(105, 99, 236) in dark, both the theme's own --series-1, with no re-render between them. 2px strokes, 4px bar ends anchored to the baseline, ticks recessive at 10px in --text-3.

/chart-gallery renders all of it against the demo seed rather than fixtures, because a chart built on flat uniform data looks fine and then meets a real project. It is the review surface for the design pre-flight.

Smaller decisions in the code: empty buckets draw as zero rather than being skipped, a zero-value bar draws nothing while a tiny non-zero one still draws a pixel, funnels show share-of-first and share-of-previous because those answer different questions, and a big number pairs its direction with an arrow so the delta never rests on colour.

One fix that travelled: the engine suite anchored fixtures to "today", so after UTC midnight the ingest correctly clamped future timestamps and two events collapsed into one hour bucket. That failed CI on the previous commit. Fixtures now anchor to yesterday; a suite must not depend on what time it runs.

Sign in to comment on this issue.