@lovett/ui 0.0.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * LineChart / AreaChart — the time-series plot, and the same plot with a
3
+ * filled region under it.
4
+ *
5
+ * Promoted in ADR-146 D5 (chart layer). One file, shared internals: an area
6
+ * chart IS a line chart plus a gradient fill, and forking them is how the two
7
+ * drift apart on stroke width, marker treatment and hover behaviour. Both
8
+ * compose `ChartFrame` for every piece of chrome (panel, title, legend,
9
+ * y-gutter, gridlines, baseline, x-labels, the four states, the table view),
10
+ * so this file owns exactly one thing: the marks and the interaction.
11
+ *
12
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
13
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
14
+ * NOT count toward the ADR-008 D3 gate — the earlier version of this comment
15
+ * counted it, which is what made the claim false).
16
+ * `AreaChart` TODAY (1 — gate NOT MET):
17
+ * • `components/ui/admin/analytics.tsx` — the weekly tool-run trend
18
+ * `LineChart` TODAY (0): no product consumer at all.
19
+ * PLANNED: a Discovery report trend panel (`lenses/discovery/report-sections/`).
20
+ * `AreaChart` also supersedes the `Trend Line` specimen in
21
+ * `design-system/spec/shell-charts.tsx` (retirement tracked in
22
+ * `promote-spec-charts.md`), but that is gallery code and is not a consumer.
23
+ * Tracked in `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
24
+ *
25
+ * MARK SPEC (Metoric anatomy §6.2 — real measurements, not eyeballed)
26
+ * -------------------------------------------------------------------
27
+ * line 2px stroke in `rgb(var(--series-N))`, round cap + round join,
28
+ * plus a very subtle drop shadow. The shadow is what lifts the
29
+ * series off the gridlines; the kit ships it on every chart.
30
+ * area a vertical gradient from the series hue at 0.18 alpha down to
31
+ * fully transparent, closing on the ZERO line rather than the
32
+ * floor of the plot. Never a flat fill — a flat fill competes
33
+ * with the line for the same ink.
34
+ * point a FILLED dot with a 2px ring in the panel surface colour, 8-10px
35
+ * across, so it stays legible wherever it lands including on top
36
+ * of the area fill. (The small `Sparkline` inverts this — hollow
37
+ * at small size, filled at large.)
38
+ * hover a BAND, not a 1px crosshair (ADR-146 D9): a full-plot-height
39
+ * column of `rgb(var(--chart-band))`, about one x-step wide,
40
+ * `--radius-xs`, snapping to the nearest x. A far larger hit
41
+ * target, and it reads deliberate rather than technical.
42
+ *
43
+ * WHY EVERY GRADIENT GETS A `useId()` SUFFIX
44
+ * ------------------------------------------
45
+ * `design-system/spec/shell-charts.tsx` names its gradients `fill-<index>`.
46
+ * SVG ids are document-global, so two charts on one page define the same id
47
+ * twice and every url() reference in the document resolves to whichever one
48
+ * the browser saw first. Both charts then paint the FIRST chart's colours —
49
+ * with typecheck, lint, build and the token gate all green, because the
50
+ * source is syntactically perfect. Every id here is suffixed with `useId()`
51
+ * (colons stripped: they are legal in an id but hostile inside a URL
52
+ * fragment) and keyed by series INDEX rather than the caller's key, which may
53
+ * contain characters an id may not.
54
+ *
55
+ * WHY THE SERIES SLOT IS REQUIRED AND NOT DEFAULTED
56
+ * -------------------------------------------------
57
+ * ADR-146 D7: colour follows the ENTITY, never its rank — a filter that
58
+ * removes a series must not repaint the survivors. The only default this
59
+ * primitive could pick is the array index, which is precisely the forbidden
60
+ * behaviour, so `slot` is a required field and the caller derives it from its
61
+ * own stable key. Colour then resolves through the frozen `SERIES` lookup in
62
+ * `lib/series.ts` (D8) — no primitive here ever composes a token NAME.
63
+ *
64
+ * EDGE CASES, EACH DELIBERATE (ADR-146 acceptance criterion 8)
65
+ * ------------------------------------------------------------
66
+ * • **One point** — `linePath` emits a zero-length segment which, with
67
+ * `stroke-linecap: round`, paints as a dot. The marker draws on top. No
68
+ * line, no empty plot.
69
+ * • **Two points** — an ordinary segment; nothing special is needed.
70
+ * • **All-zero series** — the domain collapses to `[0, 0]`, `niceTicks`
71
+ * returns the single tick `[0]`, `linearScale` maps everything to the
72
+ * MIDDLE of the range, and `ChartFrame` puts a lone tick label at 50%.
73
+ * Axis and data agree, and nothing divides by zero.
74
+ * • **Negative values** — the baseline is then NOT the bottom of the plot.
75
+ * The domain spans the data, a zero rule is drawn at `y(0)` in
76
+ * `--chart-axis`, and the line goes below it. The AREA fill closes on
77
+ * that zero line too: closing it on the plot floor instead would paint a
78
+ * tall block of ink for a small negative number.
79
+ * • **Gaps / nulls** — the line BREAKS. A gap splits the series into
80
+ * separate path segments rather than being interpolated across, because a
81
+ * line drawn through missing data is a chart that lies.
82
+ * • **No finite data at all** — the frame's `empty` state, in reserved
83
+ * space. Never a white gap.
84
+ *
85
+ * Token discipline: every colour is `rgb(var(--token))`, resolved from the
86
+ * frozen series lookup or a text/structure token; there is no colour literal
87
+ * in this file, and no colour is ever built by string surgery. `--chart-band`
88
+ * and `--chart-axis` carry their own alpha, so they are consumed as
89
+ * `rgb(var(--chart-band))` and never given a second one. Every spacing,
90
+ * radius and duration is a token. The bare numbers that remain are SVG
91
+ * geometry in user units — path arithmetic cannot read a custom property —
92
+ * and each is a named constant recording the measurement it came from.
93
+ * Motion runs through `motion-safe:` utilities so `prefers-reduced-motion`
94
+ * jumps to the final state, and every transition is `--ease-out`: overshoot
95
+ * on a data change reads as imprecise (D9).
96
+ */
97
+
98
+ import {
99
+ useCallback,
100
+ useId,
101
+ useLayoutEffect,
102
+ useMemo,
103
+ useRef,
104
+ useState,
105
+ type KeyboardEvent,
106
+ type PointerEvent,
107
+ type ReactNode,
108
+ } from 'react'
109
+
110
+ import { cn } from './lib/utils'
111
+ import { formatNumber } from './format'
112
+ import {
113
+ areaPath,
114
+ estimateTextWidth,
115
+ fitLabelCount,
116
+ fitTicks,
117
+ linePath,
118
+ linearScale,
119
+ strideIndices,
120
+ type NumericInput,
121
+ type Point,
122
+ } from './lib/chart'
123
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
124
+ import {
125
+ ChartFrame,
126
+ X_LABEL_FONT_PX,
127
+ Y_TICK_MIN_SPACING_PX,
128
+ type ChartFrameState,
129
+ type ChartTableData,
130
+ type ChartTableRow,
131
+ } from './chart-frame'
132
+ import { ChartLegend, type ChartLegendItem } from './chart-legend'
133
+ import { ChartTooltip, type ChartTooltipRow } from './chart-tooltip'
134
+
135
+ /* ── geometry constants ──────────────────────────────────────────────────
136
+ * SVG user units. These cannot be tokens: they feed path and attribute
137
+ * arithmetic, and a custom property is not readable at the point the
138
+ * geometry is built. Each records the measurement it came from. */
139
+
140
+ /** Line stroke. Metoric §6.2 measures the line at `2-3px`. */
141
+ const STROKE = 2
142
+ /** Marker radius. §6.2 measures the point at `10 x 10`, so r = 5. */
143
+ const MARKER_RADIUS = 5
144
+ /** The ring around a marker, in the panel surface colour. §6.2 `stroke:2`. */
145
+ const MARKER_RING = 2
146
+ /** Gap between a direct label's baseline and the marker it labels. */
147
+ const LABEL_GAP = 2
148
+ /** Marker radius + its ring + the gap: how far a label clears its dot. */
149
+ const LABEL_OFFSET = MARKER_RADIUS + MARKER_RING + LABEL_GAP
150
+ /**
151
+ * Approximate cap height of the 12px direct label, in user units.
152
+ *
153
+ * SVG `<text>` has no layout box to measure, and the peak point sits at or
154
+ * near `y = 0` whenever `niceTicks` rounds the domain tight to the data — so
155
+ * without this the label's glyphs paint ABOVE the plot, on top of the legend,
156
+ * because the plot SVG is deliberately `overflow: visible` (so markers at the
157
+ * edge are not clipped). It is the threshold for flipping a label below its
158
+ * marker instead.
159
+ */
160
+ const LABEL_CAP = 9
161
+ /** Nominal plot width for the first paint, before measurement lands. */
162
+ const NOMINAL_WIDTH = 459
163
+ /** Narrowest hover band, ≈ `--space-2`. Below this it stops being a target. */
164
+ const MIN_BAND = 8
165
+ /** Band width as a share of one x-step — the kit's 44 across a ~55 step. */
166
+ const BAND_STEP_RATIO = 0.8
167
+ /** Above this many x positions, `markers="auto"` stops drawing every point. */
168
+ const AUTO_MARKER_LIMIT = 12
169
+ /**
170
+ * Most x labels the axis ROW will render, however wide the plot gets.
171
+ *
172
+ * The row is a fixed-width track: hand it a 90-day series and it draws 90
173
+ * labels a few pixels apart — an unreadable mush, with every gate green.
174
+ * Beyond this cap the axis thins by a uniform STRIDE (`strideIndices`), first
175
+ * and last always kept, which is what the eye reads anyway.
176
+ *
177
+ * The cap is the CEILING, not the answer: the actual count is fitted against
178
+ * the measured plot width and the widest label (`fitLabelCount`), so a 234px
179
+ * panel does not get the same eight labels a 608px one does.
180
+ *
181
+ * This thinning is the ONLY truncation in this file, it applies to LABELS
182
+ * and never to data, and it is reversible in one click: the plot draws every
183
+ * position, and the table view carries one row per position — including
184
+ * positions the caller gave no label for (see `count`).
185
+ */
186
+ const MAX_X_LABELS = 8
187
+
188
+ /** Smallest gap between two x labels before they read as one word. */
189
+ const X_LABEL_GAP_PX = 12
190
+
191
+ /**
192
+ * Font size and weight of a direct value label — `text-[12px] font-semibold`
193
+ * on the `<text>` below. Chromium measures its line box at exactly 15px
194
+ * (`getBoundingClientRect().height`, gallery, 2026-09-06), which is what the
195
+ * collision pass uses for a label's vertical extent.
196
+ */
197
+ const VALUE_LABEL_FONT_PX = 12
198
+ const VALUE_LABEL_BOX_PX = 15
199
+ /** Clear air required between two value labels before they count as apart. */
200
+ const VALUE_LABEL_PAD_PX = 3
201
+ /** What a gap reads as in the tooltip and the table. Not a zero: a missing
202
+ * datum is not a measurement of none. */
203
+ const NO_VALUE = '—'
204
+
205
+ /** One series of the plot. */
206
+ export interface LineChartSeries {
207
+ /**
208
+ * Stable entity key — the React key, the tooltip row key and the legend
209
+ * key. Never an array index.
210
+ */
211
+ key: string
212
+ /** Series name. Shown in the legend, the tooltip and the table header. */
213
+ label: string
214
+ /**
215
+ * One value per x position, aligned to `xLabels` by index. `null` /
216
+ * `undefined` / `NaN` is a GAP: the line breaks rather than interpolating
217
+ * across it.
218
+ *
219
+ * A series LONGER than `xLabels` is plotted in full — the surplus
220
+ * positions simply carry no axis label. Values are never discarded to fit
221
+ * the label count.
222
+ */
223
+ values: readonly NumericInput[]
224
+ /**
225
+ * Palette slot, or `'other'` for the folded remainder.
226
+ *
227
+ * Required on purpose (ADR-146 D7): the only default available here is the
228
+ * array index, and defaulting to it would repaint every surviving series
229
+ * the moment a caller filters one out. Derive it from `key`.
230
+ */
231
+ slot: ChartSlot
232
+ }
233
+
234
+ export interface LineChartProps {
235
+ /** The series, in draw order. */
236
+ series: readonly LineChartSeries[]
237
+ /**
238
+ * x-axis labels, left to right.
239
+ *
240
+ * It does NOT cap the number of x positions: the plot has as many
241
+ * positions as the longest of this list and every series. A short label
242
+ * list leaves the surplus positions unlabelled; it never truncates data.
243
+ */
244
+ xLabels: readonly string[]
245
+ /** Chart title. With a single series this also names it, so no legend box
246
+ * renders (ADR-146 D10.2). */
247
+ title?: string
248
+ /** Optional supporting line under the title. */
249
+ subtitle?: string
250
+ /** Trailing slot in the title row — a range picker, a menu. */
251
+ action?: ReactNode
252
+ /** Frame state. `'loaded'` with no finite datum renders `'empty'`. */
253
+ state?: ChartFrameState
254
+ /** Message for the empty state. */
255
+ emptyMessage?: string
256
+ /** Message for the error state. */
257
+ errorMessage?: string
258
+ /** Adds a retry button to the error state. */
259
+ onRetry?: () => void
260
+ /**
261
+ * Force the y domain. Ticks are still nice-numbered from it, so the drawn
262
+ * domain may round outward. Omit to derive it from the data.
263
+ */
264
+ yDomain?: readonly [number, number]
265
+ /** Target y tick count. Default `5`. */
266
+ yTickCount?: number
267
+ /**
268
+ * Whether the y domain must contain zero. `LineChart` defaults to `false`
269
+ * (a 100-105 series should not be flattened against a phantom zero);
270
+ * `AreaChart` defaults to `true`, because a filled region measured from a
271
+ * non-zero baseline overstates its own magnitude.
272
+ */
273
+ includeZero?: boolean
274
+ /**
275
+ * Formats a data value for the tooltip, the direct labels and the table.
276
+ * Default: thousand-separated, with decimals only where the value has any.
277
+ *
278
+ * `formatPercent` takes PERCENT UNITS, not a 0-1 fraction (ADR-146 D11) —
279
+ * convert at the call site.
280
+ */
281
+ formatValue?: (value: number) => string
282
+ /** Formats a y-axis tick. Defaults to `formatValue`. */
283
+ formatYTick?: (value: number) => string
284
+ /** Plot width ÷ height. Default `16 / 5`, the kit's 459×108 plot. */
285
+ aspectRatio?: number
286
+ /** Reserved width of the y-label gutter. Default `var(--space-10)`. */
287
+ yGutterWidth?: string
288
+ /** Fill the region under each line. `AreaChart` sets this. */
289
+ area?: boolean
290
+ /** Which palette the slots index into. Default `'default'`. */
291
+ palette?: SeriesPalette
292
+ /**
293
+ * Point markers. `'auto'` (default) draws every point up to 12 x positions
294
+ * and only the meaningful ones (first, last, peak, hovered) beyond that — a
295
+ * dot per point on a 90-day series is noise, not information.
296
+ */
297
+ markers?: 'auto' | 'all' | 'none'
298
+ /**
299
+ * Selective value labels at each series' first, last and peak point
300
+ * (ADR-146 D10.3 — never a number on every point). Defaults to on for four
301
+ * or fewer series, off above that, where they would collide.
302
+ */
303
+ directLabels?: boolean
304
+ /**
305
+ * Render the legend. Defaults to on for two or more series and off for one
306
+ * (D10.1 / D10.2 — the title names a single series).
307
+ */
308
+ legend?: boolean
309
+ /** Header for the table view's category column. Default `'Point'`. */
310
+ categoryLabel?: string
311
+ /** Accessible name for the plot. Defaults to `title`. */
312
+ ariaLabel?: string
313
+ /** Notified when the hovered / focused x index changes. `null` on leave. */
314
+ onHoverChange?: (index: number | null) => void
315
+ /** Optional className on the frame. */
316
+ className?: string
317
+ }
318
+
319
+ /** `AreaChart` is `LineChart` with the fill on — the prop is not re-exposed. */
320
+ export type AreaChartProps = Omit<LineChartProps, 'area'>
321
+
322
+ interface Size {
323
+ readonly width: number
324
+ readonly height: number
325
+ }
326
+
327
+ const UNMEASURED: Size = { width: 0, height: 0 }
328
+
329
+ /** Read a datum as a finite number, or `null` for "there is no datum here". */
330
+ function datum(input: NumericInput): number | null {
331
+ return typeof input === 'number' && Number.isFinite(input) ? input : null
332
+ }
333
+
334
+ /**
335
+ * Thousand-separated, with decimals only where the value has any and only as
336
+ * many as its own magnitude needs (capped at 6).
337
+ *
338
+ * `formatNumber` alone defaults to zero decimals, which would render the tick
339
+ * ladder 0, 0.25, 0.5, 0.75, 1 as "0, 0, 1, 1, 1" — five labels, three lies.
340
+ */
341
+ function defaultFormat(input: number): string {
342
+ if (!Number.isFinite(input)) return formatNumber(Number.NaN)
343
+ if (Number.isInteger(input)) return formatNumber(input)
344
+ const magnitude = Math.floor(Math.log10(Math.abs(input)))
345
+ return formatNumber(input, { decimals: Math.min(6, Math.max(1, 1 - magnitude)) })
346
+ }
347
+
348
+ /**
349
+ * One decimal precision for the WHOLE tick ladder.
350
+ *
351
+ * Formatting each tick on its own magnitude renders the 0..1 ladder as
352
+ * "1 / 0.75 / 0.5 / 0.25 / 0" — four different shapes in one column, which is
353
+ * exactly what `tabular-nums` exists to prevent. The ladder shares a step, so
354
+ * it shares a precision.
355
+ */
356
+ function tickFormatter(ticks: readonly number[]): (value: number) => string {
357
+ let decimals = 0
358
+ for (const tick of ticks) {
359
+ // Exponential forms ("1e-7") carry no '.' and are left at 0 rather than
360
+ // being mis-read as a 7-place decimal.
361
+ const text = String(Math.abs(tick))
362
+ const dot = text.indexOf('.')
363
+ if (dot >= 0) {
364
+ decimals = Math.max(decimals, Math.min(6, text.length - dot - 1))
365
+ }
366
+ }
367
+ return (value: number): string => formatNumber(value, { decimals })
368
+ }
369
+
370
+ /**
371
+ * The widest label in the row, estimated, plus the gap it needs from its
372
+ * neighbour — the per-label extent `fitLabelCount` divides the plot by.
373
+ */
374
+ function xLabelExtent(labels: readonly string[]): number {
375
+ let widest = 0
376
+ for (const label of labels) {
377
+ const width = estimateTextWidth(label, X_LABEL_FONT_PX)
378
+ if (width > widest) widest = width
379
+ }
380
+ return widest + X_LABEL_GAP_PX
381
+ }
382
+
383
+ /** A direct value label competing for room, before collisions are resolved. */
384
+ interface LabelCandidate {
385
+ readonly key: string
386
+ readonly seriesKey: string
387
+ readonly index: number
388
+ readonly text: string
389
+ readonly x: number
390
+ readonly y: number
391
+ readonly anchor: 'start' | 'middle' | 'end'
392
+ /** peak 3 > last 2 > first 1. The survivor of a collision. */
393
+ readonly priority: number
394
+ readonly left: number
395
+ readonly right: number
396
+ readonly top: number
397
+ readonly bottom: number
398
+ }
399
+
400
+ /**
401
+ * Drop the direct value labels that would overlap another one, lowest
402
+ * priority first.
403
+ *
404
+ * ADR-146 D10.3 makes direct labels SELECTIVE by design — first, last and
405
+ * peak, never every point — so dropping a colliding one is the contract
406
+ * working, not a compromise. What is NOT acceptable is what shipped: two
407
+ * labels painted over each other. Measured in Chromium on AreaChart's
408
+ * "Spend, filled" demo, "$17,460" and "$17,120" overlapped by 19.6 x 11.8px
409
+ * and rendered as "$17,46O$17,120".
410
+ *
411
+ * Priority is peak > last > first: the peak is the only one of the three that
412
+ * says something the axis cannot, and the last is the value a reader looks
413
+ * for next. Ties break on input order, so the result is deterministic and the
414
+ * same series wins twice rather than flickering between renders.
415
+ *
416
+ * Boxes are estimated, not measured — see `estimateTextWidth` for why, and
417
+ * why the estimate rounds up.
418
+ */
419
+ function resolveLabelCollisions(
420
+ candidates: readonly LabelCandidate[],
421
+ ): Set<string> {
422
+ const ranked = [...candidates].sort((a, b) => b.priority - a.priority)
423
+ const kept: LabelCandidate[] = []
424
+ const keys = new Set<string>()
425
+
426
+ for (const candidate of ranked) {
427
+ let clear = true
428
+ for (const other of kept) {
429
+ const overlapX =
430
+ Math.min(candidate.right, other.right) -
431
+ Math.max(candidate.left, other.left)
432
+ const overlapY =
433
+ Math.min(candidate.bottom, other.bottom) -
434
+ Math.max(candidate.top, other.top)
435
+ if (overlapX > 0 && overlapY > 0) {
436
+ clear = false
437
+ break
438
+ }
439
+ }
440
+ if (clear) {
441
+ kept.push(candidate)
442
+ keys.add(candidate.key)
443
+ }
444
+ }
445
+ return keys
446
+ }
447
+
448
+ /**
449
+ * Measure a box, and keep measuring it. Returns `0 × 0` until the first
450
+ * measurement lands (and in any environment without `ResizeObserver`, jsdom
451
+ * included) — callers then fall back to the nominal plot size, whose aspect
452
+ * ratio matches the box, so the first paint is right and the second is exact.
453
+ */
454
+ function usePlotSize(node: HTMLDivElement | null): Size {
455
+ const [size, setSize] = useState<Size>(UNMEASURED)
456
+
457
+ useLayoutEffect(() => {
458
+ if (node === null) return
459
+
460
+ const measure = () => {
461
+ const next: Size = { width: node.clientWidth, height: node.clientHeight }
462
+ setSize((prev) =>
463
+ prev.width === next.width && prev.height === next.height ? prev : next,
464
+ )
465
+ }
466
+
467
+ measure()
468
+ if (typeof ResizeObserver === 'undefined') return
469
+
470
+ const observer = new ResizeObserver(measure)
471
+ observer.observe(node)
472
+ return () => observer.disconnect()
473
+ }, [node])
474
+
475
+ return size
476
+ }
477
+
478
+ /**
479
+ * Split a series into the runs of consecutive plottable points, so a gap
480
+ * breaks the line instead of being interpolated across.
481
+ *
482
+ * A run of one point is kept: `linePath` renders it as a zero-length segment,
483
+ * which with a round cap paints as a dot. Dropping it would make a lone datum
484
+ * between two gaps silently invisible.
485
+ */
486
+ function segmentsOf(
487
+ values: readonly NumericInput[],
488
+ count: number,
489
+ xAt: (index: number) => number,
490
+ yAt: (value: number) => number,
491
+ ): Point[][] {
492
+ const segments: Point[][] = []
493
+ let run: Point[] = []
494
+
495
+ for (let i = 0; i < count; i++) {
496
+ const value = datum(values[i])
497
+ if (value === null) {
498
+ if (run.length > 0) segments.push(run)
499
+ run = []
500
+ continue
501
+ }
502
+ run.push({ x: xAt(i), y: yAt(value) })
503
+ }
504
+ if (run.length > 0) segments.push(run)
505
+
506
+ return segments
507
+ }
508
+
509
+ export function LineChart({
510
+ series,
511
+ xLabels,
512
+ title,
513
+ subtitle,
514
+ action,
515
+ state = 'loaded',
516
+ emptyMessage,
517
+ errorMessage,
518
+ onRetry,
519
+ yDomain,
520
+ yTickCount = 5,
521
+ includeZero = false,
522
+ formatValue = defaultFormat,
523
+ formatYTick,
524
+ aspectRatio = 16 / 5,
525
+ yGutterWidth,
526
+ area = false,
527
+ palette = 'default',
528
+ markers = 'auto',
529
+ directLabels,
530
+ legend,
531
+ categoryLabel = 'Point',
532
+ ariaLabel,
533
+ onHoverChange,
534
+ className,
535
+ }: LineChartProps) {
536
+ // A CALLBACK ref: ChartFrame renders `children` only in the `loaded` state
537
+ // and swaps the plot out for the table view, so this box unmounts and
538
+ // remounts. A `useRef` + `[ref]` effect binds once and never rebinds, which
539
+ // would leave a chart that started in `loading` (the normal async path)
540
+ // permanently unmeasured and falling back to the nominal size.
541
+ const [plotNode, setPlotNode] = useState<HTMLDivElement | null>(null)
542
+ const measured = usePlotSize(plotNode)
543
+ // Document-global ids. Without this suffix, two charts on one page share a
544
+ // gradient id and both paint the first chart's colours.
545
+ const uid = useId().replace(/:/g, '')
546
+
547
+ const hoveredRef = useRef<number | null>(null)
548
+ const [hovered, setHovered] = useState<number | null>(null)
549
+
550
+ /**
551
+ * How many x POSITIONS the plot has: the longest of the label row and
552
+ * every series.
553
+ *
554
+ * Deriving this from `xLabels.length` alone silently discarded every value
555
+ * past the last label — it never entered the y domain, never drew a mark,
556
+ * and never reached the table view either, so a 30-day series arriving
557
+ * with 7 labels lost 23 points with typecheck, lint and every test green.
558
+ * The label list describes the axis; it does not define the data.
559
+ */
560
+ const count = useMemo(() => {
561
+ let longest = xLabels.length
562
+ for (const entry of series) {
563
+ if (entry.values.length > longest) longest = entry.values.length
564
+ }
565
+ return longest
566
+ }, [xLabels, series])
567
+
568
+ /**
569
+ * One label per POSITION, so index i of this list always names position i.
570
+ *
571
+ * A position past the caller's labels gets `''` rather than being dropped:
572
+ * dropping it would slide every later label onto the wrong point, which is
573
+ * the same lie in a quieter form.
574
+ */
575
+ const positionLabels = useMemo(
576
+ () => Array.from({ length: count }, (_, index) => xLabels[index] ?? ''),
577
+ [count, xLabels],
578
+ )
579
+
580
+ const ratio = aspectRatio > 0 ? aspectRatio : 1
581
+ const width = measured.width > 0 ? measured.width : NOMINAL_WIDTH
582
+ const height = measured.height > 0 ? measured.height : NOMINAL_WIDTH / ratio
583
+
584
+ const geometry = useMemo(() => {
585
+ let min = Number.POSITIVE_INFINITY
586
+ let max = Number.NEGATIVE_INFINITY
587
+ let finiteCount = 0
588
+
589
+ for (const entry of series) {
590
+ for (let i = 0; i < count; i++) {
591
+ const value = datum(entry.values[i])
592
+ if (value === null) continue
593
+ finiteCount++
594
+ if (value < min) min = value
595
+ if (value > max) max = value
596
+ }
597
+ }
598
+
599
+ if (finiteCount === 0) {
600
+ min = 0
601
+ max = 0
602
+ }
603
+ if (includeZero) {
604
+ min = Math.min(min, 0)
605
+ max = Math.max(max, 0)
606
+ }
607
+
608
+ const extent: readonly [number, number] = yDomain ?? [min, max]
609
+ // The DRAWN domain is the rounded-out tick range, not the raw extent, so
610
+ // the gridlines land on round numbers and the axis labels are the truth.
611
+ //
612
+ // The count is FITTED to the plot's measured height, not taken as given:
613
+ // `niceTicks` is allowed to overshoot its target (it rounds the domain
614
+ // outward), and a fixed five-tick ladder in a short panel stacks its
615
+ // labels on top of each other — measured at 390, every adjacent pair of
616
+ // "$50,000 / $40,000 / …" overlapped. A short plot gets a shorter ladder.
617
+ const ticks = fitTicks(
618
+ extent[0],
619
+ extent[1],
620
+ height,
621
+ Y_TICK_MIN_SPACING_PX,
622
+ yTickCount,
623
+ )
624
+ const lo = ticks[0] ?? 0
625
+ const hi = ticks[ticks.length - 1] ?? 0
626
+
627
+ // A flat domain maps every value to the middle of the range, which is
628
+ // exactly where ChartFrame puts a lone tick label. Axis and data agree.
629
+ const yAt = linearScale([lo, hi], [height, 0])
630
+ const step = count > 1 ? width / (count - 1) : width
631
+ const xAt = (index: number): number => (count > 1 ? index * step : width / 2)
632
+
633
+ return { finiteCount, lo, hi, ticks, yAt, xAt, step }
634
+ }, [series, count, includeZero, yDomain, yTickCount, width, height])
635
+
636
+ const { finiteCount, lo, hi, ticks, yAt, xAt, step } = geometry
637
+
638
+ // A caller's own tick formatter wins; so does a caller's value formatter.
639
+ // The uniform ladder applies only to the built-in default, which formats
640
+ // per value and therefore ragged.
641
+ const formatTick =
642
+ formatYTick ?? (formatValue === defaultFormat ? tickFormatter(ticks) : formatValue)
643
+
644
+ const effectiveState: ChartFrameState =
645
+ state === 'loaded' && (count === 0 || finiteCount === 0) ? 'empty' : state
646
+
647
+ /**
648
+ * The hover index, clamped to the CURRENT data. New data can be shorter
649
+ * than the old — a range switch from 30 days to 7 — and a stale index would
650
+ * otherwise read past the end of every series and paint a readout of
651
+ * nothing while the band sat outside the plot.
652
+ */
653
+ const active = hovered !== null && hovered >= 0 && hovered < count ? hovered : null
654
+
655
+ const showLegend = legend ?? series.length >= 2
656
+ const showDirectLabels = directLabels ?? series.length <= 4
657
+ const drawEveryMarker =
658
+ markers === 'all' || (markers === 'auto' && count <= AUTO_MARKER_LIMIT)
659
+
660
+ /** Zero rule position, when zero sits strictly inside the drawn domain. */
661
+ const zeroY = lo < 0 && hi > 0 ? yAt(0) : null
662
+
663
+ /**
664
+ * Where an area's fill CLOSES TO — zero, not the floor of the plot.
665
+ *
666
+ * `AreaChart` already forces zero into the domain because "a filled region
667
+ * measured from a non-zero baseline overstates its own magnitude". With
668
+ * negative data that reasoning cuts the other way and the plot floor is no
669
+ * longer zero: closing a −10 point down to a −40 floor paints a tall block
670
+ * of ink for a small negative number, which is the same lie in the opposite
671
+ * direction. Clamped into the plot, so an all-positive domain still closes
672
+ * on the floor (`yAt(0) === height`) and an all-negative one closes on the
673
+ * ceiling — in both cases exactly where the zero line is.
674
+ */
675
+ const areaBaseline = Math.max(0, Math.min(height, yAt(0)))
676
+
677
+ const setHoverIndex = useCallback(
678
+ (next: number | null) => {
679
+ // The ref, not the state, is the comparison: this runs from a pointer
680
+ // handler on every mouse move, and a side effect inside a state updater
681
+ // would fire twice under StrictMode.
682
+ if (hoveredRef.current === next) return
683
+ hoveredRef.current = next
684
+ setHovered(next)
685
+ onHoverChange?.(next)
686
+ },
687
+ [onHoverChange],
688
+ )
689
+
690
+ const handlePointerMove = useCallback(
691
+ (event: PointerEvent<HTMLDivElement>) => {
692
+ if (count === 0) return
693
+ const box = event.currentTarget.getBoundingClientRect()
694
+ if (box.width <= 0) return
695
+ const position = ((event.clientX - box.left) / box.width) * Math.max(1, count - 1)
696
+ setHoverIndex(Math.max(0, Math.min(count - 1, Math.round(position))))
697
+ },
698
+ [count, setHoverIndex],
699
+ )
700
+
701
+ const handleKeyDown = useCallback(
702
+ (event: KeyboardEvent<HTMLDivElement>) => {
703
+ if (count === 0) return
704
+ // Arrowing into an unhovered chart lands on the current index rather
705
+ // than stepping past it, so the first keypress always reveals a point.
706
+ const current = active ?? 0
707
+ const stepped = (delta: number): number =>
708
+ active === null ? current : Math.max(0, Math.min(count - 1, current + delta))
709
+
710
+ if (event.key === 'ArrowRight') setHoverIndex(stepped(1))
711
+ else if (event.key === 'ArrowLeft') setHoverIndex(stepped(-1))
712
+ else if (event.key === 'Home') setHoverIndex(0)
713
+ else if (event.key === 'End') setHoverIndex(count - 1)
714
+ else if (event.key === 'Escape') setHoverIndex(null)
715
+ else return
716
+
717
+ event.preventDefault()
718
+ },
719
+ [count, active, setHoverIndex],
720
+ )
721
+
722
+ /** Per-series draw data. One pass, reused by every layer below. */
723
+ const drawn = series.map((entry, index) => {
724
+ const points: (Point | null)[] = []
725
+ let firstIndex: number | null = null
726
+ let lastIndex: number | null = null
727
+ let peakIndex: number | null = null
728
+ let peak = Number.NEGATIVE_INFINITY
729
+
730
+ for (let i = 0; i < count; i++) {
731
+ const value = datum(entry.values[i])
732
+ if (value === null) {
733
+ points.push(null)
734
+ continue
735
+ }
736
+ points.push({ x: xAt(i), y: yAt(value) })
737
+ if (firstIndex === null) firstIndex = i
738
+ lastIndex = i
739
+ if (value > peak) {
740
+ peak = value
741
+ peakIndex = i
742
+ }
743
+ }
744
+
745
+ return {
746
+ series: entry,
747
+ color: slotColor(entry.slot, palette),
748
+ gradientId: `lovett-chart-area-${uid}-${index}`,
749
+ segments: segmentsOf(entry.values, count, xAt, yAt),
750
+ points,
751
+ firstIndex,
752
+ lastIndex,
753
+ peakIndex,
754
+ }
755
+ })
756
+
757
+ /**
758
+ * The direct value labels, as boxes, BEFORE any of them is drawn.
759
+ *
760
+ * They have to be resolved together rather than per series: the overlap
761
+ * that shipped was between two different series' labels, so a per-series
762
+ * pass cannot see it. Anchoring matches the `<text>` below exactly — a box
763
+ * computed from a different anchor than the one that renders is a
764
+ * collision check of something that is not on screen.
765
+ */
766
+ const valueLabels: LabelCandidate[] = !showDirectLabels
767
+ ? []
768
+ : drawn.flatMap((entry): LabelCandidate[] => {
769
+ // Peak first, so a point that is BOTH the peak and the last is
770
+ // ranked as the peak. De-duplicated by index, as before.
771
+ const marks: readonly { index: number | null; priority: number }[] = [
772
+ { index: entry.peakIndex, priority: 3 },
773
+ { index: entry.lastIndex, priority: 2 },
774
+ { index: entry.firstIndex, priority: 1 },
775
+ ]
776
+ const seen = new Set<number>()
777
+ const out: LabelCandidate[] = []
778
+
779
+ for (const mark of marks) {
780
+ const index = mark.index
781
+ if (index === null || seen.has(index)) continue
782
+ seen.add(index)
783
+
784
+ const point = entry.points[index]
785
+ const value = datum(entry.series.values[index])
786
+ if (point === null || point === undefined || value === null) continue
787
+
788
+ const text = formatValue(value)
789
+ // Above the marker by default, BELOW it when the glyphs would not
790
+ // fit. A peak lands at y = 0 whenever the tick ladder rounds tight
791
+ // to the data, and the plot SVG is overflow: visible, so an
792
+ // unflipped label paints outside the plot and over the legend.
793
+ const above = point.y - LABEL_OFFSET
794
+ const y =
795
+ above - LABEL_CAP >= 0 ? above : point.y + LABEL_OFFSET + LABEL_CAP
796
+ const anchor: LabelCandidate['anchor'] =
797
+ index === 0 ? 'start' : index === count - 1 ? 'end' : 'middle'
798
+
799
+ const textWidth = estimateTextWidth(text, VALUE_LABEL_FONT_PX)
800
+ const boxLeft =
801
+ anchor === 'start'
802
+ ? point.x
803
+ : anchor === 'end'
804
+ ? point.x - textWidth
805
+ : point.x - textWidth / 2
806
+
807
+ out.push({
808
+ key: `${entry.series.key}-label-${index}`,
809
+ seriesKey: entry.series.key,
810
+ index,
811
+ text,
812
+ x: point.x,
813
+ y,
814
+ anchor,
815
+ priority: mark.priority,
816
+ // `y` is the BASELINE, so the box runs up from it. Padded on
817
+ // every side, because two labels a hairline apart still read as
818
+ // one word.
819
+ left: boxLeft - VALUE_LABEL_PAD_PX,
820
+ right: boxLeft + textWidth + VALUE_LABEL_PAD_PX,
821
+ top: y - VALUE_LABEL_BOX_PX,
822
+ bottom: y + VALUE_LABEL_PAD_PX,
823
+ })
824
+ }
825
+ return out
826
+ })
827
+
828
+ const drawnValueLabels = resolveLabelCollisions(valueLabels)
829
+
830
+ const legendItems: ChartLegendItem[] = series.map((entry) => ({
831
+ key: entry.key,
832
+ label: entry.label,
833
+ slot: entry.slot,
834
+ }))
835
+
836
+ const tooltipRows: ChartTooltipRow[] = series.map((entry) => {
837
+ const value = active === null ? null : datum(entry.values[active])
838
+ return {
839
+ key: entry.key,
840
+ label: entry.label,
841
+ value: value === null ? NO_VALUE : formatValue(value),
842
+ // A swatch on a single-series readout is noise — the title names it.
843
+ ...(series.length > 1 ? { slot: entry.slot } : {}),
844
+ }
845
+ })
846
+
847
+ const hoverX = active === null ? 0 : xAt(active)
848
+ const hoverYs =
849
+ active === null
850
+ ? []
851
+ : drawn.flatMap((entry) => {
852
+ const point = entry.points[active]
853
+ return point === null || point === undefined ? [] : [point.y]
854
+ })
855
+ const hoverY =
856
+ hoverYs.length > 0
857
+ ? hoverYs.reduce((total, y) => total + y, 0) / hoverYs.length
858
+ : height / 2
859
+
860
+ const bandWidth = Math.max(MIN_BAND, step * BAND_STEP_RATIO)
861
+ const bandLeft = Math.max(
862
+ 0,
863
+ Math.min(hoverX - bandWidth / 2, Math.max(0, width - bandWidth)),
864
+ )
865
+
866
+ /**
867
+ * MEMOISED, and the memo is load-bearing rather than tidy. This formats
868
+ * every datum of every series — 2,007 `formatValue` calls for a 1,000-point
869
+ * 2-series chart — and `hovered` is component state, so without the memo a
870
+ * single pointermove re-formatted the entire dataset. Measured before the
871
+ * memo: 26.8ms per render at 1,000 points, 315ms at 10,000, of which ~96%
872
+ * was the formatter (the same chart with `formatValue={String}` renders in
873
+ * 1.2ms). That is under 60fps from one chart, on hover, for a table that is
874
+ * usually not even on screen. `BarChart` already memoised its equivalent;
875
+ * the asymmetry was an oversight, not a design.
876
+ */
877
+ const table: ChartTableData = useMemo(
878
+ () => ({
879
+ columns: [categoryLabel, ...series.map((entry) => entry.label)],
880
+ // One row per POSITION, not per label: the table is the chart's
881
+ // accessible representation, so a value the plot draws must be readable
882
+ // here even where the caller supplied no label for it.
883
+ rows: positionLabels.map((label, index): ChartTableRow => {
884
+ const cells = series.map((entry) => {
885
+ const value = datum(entry.values[index])
886
+ return value === null ? NO_VALUE : formatValue(value)
887
+ })
888
+ return { key: `${index}-${label}`, cells: [label, ...cells] }
889
+ }),
890
+ ...(title !== undefined ? { caption: title } : {}),
891
+ }),
892
+ [categoryLabel, series, positionLabels, formatValue, title],
893
+ )
894
+
895
+ /**
896
+ * What the axis ROW shows, and WHERE each of those labels sits.
897
+ *
898
+ * Two decisions, both measured rather than fixed:
899
+ * • how many — the plot's width divided by the widest label, capped at
900
+ * `MAX_X_LABELS`;
901
+ * • which — a uniform stride (`strideIndices`), so the gaps are equal.
902
+ * The predecessor rounded a fraction and produced 0, 2, 3, 5, 6, 8, 9,
903
+ * 11 over 12 weeks: an axis that reads as irregular data.
904
+ *
905
+ * The fraction is the label's real position on the plot, which is what
906
+ * `ChartFrame` centres it on. The table below still carries every position.
907
+ */
908
+ const xCapacity = fitLabelCount(
909
+ width,
910
+ xLabelExtent(positionLabels),
911
+ MAX_X_LABELS,
912
+ // 0, so "not even two fit" is answerable instead of being clamped away.
913
+ 0,
914
+ )
915
+ // A plot 26px wide holds no 32px label, and two of them overlapped by 10px.
916
+ // Below two, the row draws NOTHING and keeps its reserved height — the
917
+ // panel does not resize, and the table view still carries every position.
918
+ const axisIndices =
919
+ count > 1 && xCapacity < 2
920
+ ? []
921
+ : strideIndices(count, Math.max(2, xCapacity))
922
+ const axisLabels = axisIndices.map((index) => positionLabels[index] ?? '')
923
+ const axisFractions = axisIndices.map((index) =>
924
+ count > 1 ? index / (count - 1) : 0.5,
925
+ )
926
+
927
+ // An unlabelled position has no title to show, so the readout carries the
928
+ // values alone rather than a card with an empty heading.
929
+ const labelAt = active === null ? '' : (positionLabels[active] ?? '')
930
+ const hoveredLabel = labelAt === '' ? undefined : labelAt
931
+ const liveText =
932
+ active === null
933
+ ? ''
934
+ : `${hoveredLabel ?? ''}: ${tooltipRows
935
+ .map((row) => `${row.label} ${row.value}`)
936
+ .join(', ')}`
937
+
938
+ return (
939
+ <ChartFrame
940
+ state={effectiveState}
941
+ action={action}
942
+ legend={
943
+ showLegend ? (
944
+ <ChartLegend
945
+ items={legendItems}
946
+ palette={palette}
947
+ activeKey={null}
948
+ label={title === undefined ? 'Chart legend' : `${title} legend`}
949
+ />
950
+ ) : undefined
951
+ }
952
+ yLabels={[...ticks].reverse().map((tick) => formatTick(tick))}
953
+ xLabels={axisLabels}
954
+ xLabelFractions={axisFractions}
955
+ aspectRatio={aspectRatio}
956
+ table={table}
957
+ {...(title !== undefined ? { title } : {})}
958
+ {...(subtitle !== undefined ? { subtitle } : {})}
959
+ {...(emptyMessage !== undefined ? { emptyMessage } : {})}
960
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
961
+ {...(onRetry !== undefined ? { onRetry } : {})}
962
+ {...(yGutterWidth !== undefined ? { yGutterWidth } : {})}
963
+ {...(className !== undefined ? { className } : {})}
964
+ >
965
+ <div
966
+ ref={setPlotNode}
967
+ role="group"
968
+ tabIndex={0}
969
+ aria-label={ariaLabel ?? title ?? 'Chart'}
970
+ onPointerMove={handlePointerMove}
971
+ onPointerLeave={() => setHoverIndex(null)}
972
+ onBlur={() => setHoverIndex(null)}
973
+ onKeyDown={handleKeyDown}
974
+ className={cn(
975
+ 'absolute inset-0 cursor-crosshair',
976
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
977
+ )}
978
+ >
979
+ {/* The hover BAND (ADR-146 D9), not a 1px crosshair: full plot height,
980
+ about one x-step wide, snapped to the nearest x, behind the marks.
981
+ --chart-band already carries its alpha; a second one would be
982
+ invalid CSS that the browser silently discards. It stays mounted
983
+ and toggles visibility, so showing it costs no layout. */}
984
+ <div
985
+ aria-hidden="true"
986
+ className={cn(
987
+ 'pointer-events-none absolute top-0 bottom-0 left-0',
988
+ 'motion-safe:transition-transform motion-safe:duration-[var(--dur-fast)]',
989
+ 'motion-safe:ease-[var(--ease-out)]',
990
+ )}
991
+ style={{
992
+ width: bandWidth,
993
+ transform: `translate3d(${bandLeft}px, 0, 0)`,
994
+ background: 'rgb(var(--chart-band))',
995
+ borderRadius: 'var(--radius-xs)',
996
+ visibility: active === null ? 'hidden' : 'visible',
997
+ }}
998
+ />
999
+
1000
+ <svg
1001
+ aria-hidden="true"
1002
+ className="pointer-events-none absolute inset-0 h-full w-full"
1003
+ viewBox={`0 0 ${width} ${height}`}
1004
+ style={{ overflow: 'visible' }}
1005
+ >
1006
+ {area && (
1007
+ <defs>
1008
+ {drawn.map((entry) => (
1009
+ <linearGradient
1010
+ key={entry.gradientId}
1011
+ id={entry.gradientId}
1012
+ x1="0"
1013
+ y1="0"
1014
+ x2="0"
1015
+ y2="1"
1016
+ >
1017
+ {/* stop-opacity multiplies the stop colour's own alpha, so
1018
+ 0.18 → 0 is the specified ramp without doing string
1019
+ surgery on a token expression to inject an alpha. */}
1020
+ <stop offset="0%" stopColor={entry.color} stopOpacity={0.18} />
1021
+ <stop offset="100%" stopColor={entry.color} stopOpacity={0} />
1022
+ </linearGradient>
1023
+ ))}
1024
+ </defs>
1025
+ )}
1026
+
1027
+ {area &&
1028
+ drawn.map((entry) =>
1029
+ entry.segments.map((segment, segmentIndex) => {
1030
+ const d = areaPath(segment, areaBaseline)
1031
+ // A one-point run has no area; its dot still draws below.
1032
+ if (d === '') return null
1033
+ return (
1034
+ <path
1035
+ key={`${entry.series.key}-area-${segmentIndex}`}
1036
+ d={d}
1037
+ className="motion-safe:transition-[d] motion-safe:duration-[var(--dur-base)] motion-safe:ease-[var(--ease-out)]"
1038
+ style={{ fill: `url(#${entry.gradientId})` }}
1039
+ />
1040
+ )
1041
+ }),
1042
+ )}
1043
+
1044
+ {/* The zero rule. Drawn only when zero sits strictly inside the
1045
+ domain — otherwise the frame's bottom baseline already is it,
1046
+ and two dark rules would stack. It sits ABOVE the area fill and
1047
+ below the lines: an axis rule painted under a translucent fill
1048
+ is dimmed by it, and the fill is what it delimits. */}
1049
+ {zeroY !== null && (
1050
+ <line
1051
+ x1={0}
1052
+ x2={width}
1053
+ y1={zeroY}
1054
+ y2={zeroY}
1055
+ strokeWidth={1}
1056
+ style={{ stroke: 'rgb(var(--chart-axis))' }}
1057
+ />
1058
+ )}
1059
+
1060
+ {drawn.map((entry) =>
1061
+ entry.segments.map((segment, segmentIndex) => (
1062
+ <path
1063
+ key={`${entry.series.key}-line-${segmentIndex}`}
1064
+ d={linePath(segment)}
1065
+ fill="none"
1066
+ strokeWidth={STROKE}
1067
+ strokeLinecap="round"
1068
+ strokeLinejoin="round"
1069
+ className="motion-safe:transition-[d] motion-safe:duration-[var(--dur-base)] motion-safe:ease-[var(--ease-out)]"
1070
+ style={{
1071
+ stroke: entry.color,
1072
+ // Very subtle — enough to lift the series off the
1073
+ // gridlines, not enough to read as a glow. It is the
1074
+ // SHADOW token, not the axis token: --chart-axis is
1075
+ // `255 255 255 / 0.22` in the dark theme, so using it here
1076
+ // painted a white halo under every line on a dark card —
1077
+ // the opposite of a shadow, in the one theme where nobody
1078
+ // would have caught it from the source. --shadow-sm is a
1079
+ // whole `<x> <y> <blur> <color>` value, which is exactly
1080
+ // drop-shadow()'s grammar, and it is dark in both themes.
1081
+ filter: 'drop-shadow(var(--shadow-sm))',
1082
+ }}
1083
+ />
1084
+ )),
1085
+ )}
1086
+
1087
+ {markers !== 'none' &&
1088
+ drawn.map((entry) =>
1089
+ entry.points.map((point, index) => {
1090
+ if (point === null) return null
1091
+ const isHovered = active === index
1092
+ const isEdge =
1093
+ index === entry.firstIndex || index === entry.lastIndex
1094
+ const isPeak = index === entry.peakIndex
1095
+ // Beyond the auto limit only the meaningful points are drawn:
1096
+ // a dot on every one of 90 days is noise, not information.
1097
+ if (!drawEveryMarker && !isHovered && !isEdge && !isPeak) {
1098
+ return null
1099
+ }
1100
+
1101
+ return (
1102
+ <circle
1103
+ key={`${entry.series.key}-dot-${index}`}
1104
+ cx={point.x}
1105
+ cy={point.y}
1106
+ r={MARKER_RADIUS}
1107
+ strokeWidth={MARKER_RING}
1108
+ className="motion-safe:transition-[cx,cy] motion-safe:duration-[var(--dur-base)] motion-safe:ease-[var(--ease-out)]"
1109
+ style={{
1110
+ fill: entry.color,
1111
+ // The ring is the PANEL surface, which is what keeps a
1112
+ // filled dot legible on top of the area fill.
1113
+ stroke: 'rgb(var(--surface-card))',
1114
+ }}
1115
+ />
1116
+ )
1117
+ }),
1118
+ )}
1119
+
1120
+ {/* Direct value labels. Selective by contract (D10.3): first,
1121
+ last and peak only, de-duplicated, and then only the ones that
1122
+ do not land on top of another — across ALL series, since that
1123
+ is where the overlap that shipped came from. The hovered point
1124
+ is covered by the tooltip, so it gets no second label competing
1125
+ with it. */}
1126
+ {valueLabels
1127
+ .filter((label) => drawnValueLabels.has(label.key))
1128
+ .map((label) => (
1129
+ <text
1130
+ key={label.key}
1131
+ x={label.x}
1132
+ y={label.y}
1133
+ textAnchor={label.anchor}
1134
+ className="text-[12px] font-semibold tabular-nums"
1135
+ // Text wears text tokens, never the series colour (D10.4).
1136
+ // The marker below it carries the identity.
1137
+ style={{ fill: 'rgb(var(--text-secondary))' }}
1138
+ >
1139
+ {label.text}
1140
+ </text>
1141
+ ))}
1142
+ </svg>
1143
+
1144
+ {active !== null && (
1145
+ <ChartTooltip
1146
+ x={hoverX}
1147
+ y={hoverY}
1148
+ plotWidth={width}
1149
+ plotHeight={height}
1150
+ rows={tooltipRows}
1151
+ palette={palette}
1152
+ {...(hoveredLabel !== undefined ? { title: hoveredLabel } : {})}
1153
+ />
1154
+ )}
1155
+
1156
+ {/* The keyboard reader's channel. The SVG is aria-hidden and the
1157
+ table view is the chart's accessible representation, so this is
1158
+ what announces where the arrow keys just landed. */}
1159
+ <span aria-live="polite" className="sr-only">
1160
+ {liveText}
1161
+ </span>
1162
+ </div>
1163
+ </ChartFrame>
1164
+ )
1165
+ }
1166
+
1167
+ /**
1168
+ * `AreaChart` — `LineChart` with the gradient region filled in.
1169
+ *
1170
+ * The only behavioural difference beyond the fill is `includeZero`, which
1171
+ * defaults to `true` here: a filled area measured from a non-zero baseline
1172
+ * overstates its own magnitude, which is the classic area-chart lie.
1173
+ */
1174
+ export function AreaChart({ includeZero = true, ...props }: AreaChartProps) {
1175
+ return <LineChart {...props} includeZero={includeZero} area />
1176
+ }