@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
}
|