@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,1214 @@
1
+ /**
2
+ * BarChart — vertical or horizontal bars, monochrome by default.
3
+ *
4
+ * Promoted in ADR-146 D5 (chart layer).
5
+ *
6
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
7
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
8
+ * NOT count toward the ADR-008 D3 gate).
9
+ * TODAY (0 — gate NOT MET). Nothing in product code renders it. The admin
10
+ * analytics tab plots its trend with `AreaChart` and its usage mix with
11
+ * `RankedBars`; neither is this primitive.
12
+ * PLANNED: a keywords volume-by-term panel (`lenses/keywords/detail/`) and
13
+ * an admin volume-by-period panel. `design-system/spec/shell-charts.tsx`'s
14
+ * ranked-bar, split-bar and column specimens retire onto it under ADR-146
15
+ * D13 (`promote-spec-charts.md`), but those are specimens, not consumers.
16
+ * Tracked in `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
17
+ *
18
+ * THE ONE IDEA THIS PRIMITIVE EXISTS TO HOLD (ADR-146 D7)
19
+ * -------------------------------------------------------
20
+ * **The default is monochrome.** Every bar renders in
21
+ * `rgb(var(--accent) / 0.14)` and exactly ONE bar — the selected one, the
22
+ * peak, or the current period — renders at full `rgb(var(--accent))`. No
23
+ * series token is involved, and no palette is needed. That is "the accent
24
+ * earns impact from scarcity" applied to data, and it is why most of our
25
+ * charts need no series colour at all.
26
+ *
27
+ * A palette enters only when the chart genuinely encodes **more than one
28
+ * entity** — that is what `series` means, and it is the only thing that turns
29
+ * this chart polychrome. Colouring nominal bars by their own value is
30
+ * forbidden: it spends the identity channel re-encoding what bar length
31
+ * already shows. There is deliberately no prop for it.
32
+ *
33
+ * Colour follows the ENTITY, never its rank (D7): `BarChartSeries.slot` is
34
+ * required rather than derived from array position, so filtering a series out
35
+ * cannot repaint the survivors.
36
+ *
37
+ * GEOMETRY (Metoric spec §6.1 — extracted measurements, not eyeballed)
38
+ * --------------------------------------------------------------------
39
+ * bar radius `--radius-sm` (8px) on all four corners — a rounded BAR.
40
+ * Not a pill (`--radius-full`) and not a circle.
41
+ * bar gap `--space-4` (16px) at full size, scaling down with the
42
+ * count: the gap is capped at a share of the step, so 4 bars
43
+ * get 16px and 30 bars get a hairline rather than fusing.
44
+ * stack seam a 2px gap between adjacent stacked segments, painted in the
45
+ * panel colour so they read as separate marks.
46
+ *
47
+ * THE EDGE CASES, EACH A NAMED TEST (ADR-146 acceptance criterion 8)
48
+ * ------------------------------------------------------------------
49
+ * • **no data** → the frame's `empty` state. So does data whose
50
+ * every value is ABSENT: nothing was measured, and
51
+ * "every value is zero" would be a different claim.
52
+ * • **an absent value**→ a datum with no `value` (or `undefined` / `NaN`)
53
+ * was never measured, which is NOT the same fact as
54
+ * a measured zero. It draws no bar at all, reads
55
+ * `—` in the readout, the accessible name and the
56
+ * table, is excluded from the domain, and can never
57
+ * win the `peak` emphasis. A zero-length bar on the
58
+ * baseline would be indistinguishable from a real
59
+ * zero, which is the `LineChart` gap defect (its
60
+ * `NO_VALUE`) in bar form.
61
+ * • **one bar** → a legal chart; the single band takes the full
62
+ * width less its padding.
63
+ * • **all zero** → the baseline and gridlines stay (unlabelled — a
64
+ * single nice tick at zero would be drawn at
65
+ * mid-height, which is a lie), the gutter keeps its
66
+ * width so the plot does not resize, and an in-plot
67
+ * message says so. Invisible bars on a blank panel
68
+ * look like a broken chart, not like zero. Because
69
+ * the rules stay live under it, that message is a
70
+ * `PlotNotice` — opaque-backed, so a gridline cannot
71
+ * run through the sentence. Same for "too dense".
72
+ * • **negative values**→ bars descend from the y=0 baseline, and a darker
73
+ * `--chart-axis` zero rule is drawn where the
74
+ * baseline actually is. They are NOT clamped to
75
+ * zero: a clamp is a chart that lies. The ONE
76
+ * exception is `mode="stacked"`, where a running sum
77
+ * has no honest negative — those are clamped, and
78
+ * the primitive warns rather than doing it quietly.
79
+ * • **very many bars** → below a legible bar width we say "too dense" and
80
+ * `console.warn` once, rather than painting mush.
81
+ * The table view stays reachable.
82
+ *
83
+ * INTERACTION (D9) — part of the primitive, not the caller's problem
84
+ * ------------------------------------------------------------------
85
+ * The hit target is the whole **band column** (bar + gap, full plot depth),
86
+ * so it is always at least as large as the bar it selects — which is what
87
+ * makes a 4px bar usable. Hover or focus lifts a `--chart-band` column behind
88
+ * the marks and opens `ChartTooltip`; grouped and stacked charts read out
89
+ * every series at that category in one card. Bars grow with `--dur-base` /
90
+ * `--ease-out` (never `--ease-spring` on a data change), and
91
+ * `prefers-reduced-motion` jumps to the final state.
92
+ *
93
+ * Token discipline: every colour is `rgb(var(--token))` — no literal, no
94
+ * constructed name. Series hues resolve through the frozen `SERIES` lookup in
95
+ * `lib/series.ts` (D8). `--chart-grid` / `--chart-axis` / `--chart-band`
96
+ * carry their own alpha and are consumed as `rgb(var(--chart-grid))`, NEVER
97
+ * with a second one. Every spacing and radius is a token; the only bare
98
+ * numbers are pixel geometry (a gap cap, a hairline, a legibility floor),
99
+ * which cannot read a custom property — each names its token equivalent.
100
+ */
101
+
102
+ import {
103
+ useEffect,
104
+ useLayoutEffect,
105
+ useMemo,
106
+ useState,
107
+ type CSSProperties,
108
+ type ReactNode,
109
+ } from 'react'
110
+
111
+ import { cn } from './lib/utils'
112
+ import {
113
+ bandScale,
114
+ estimateTextWidth,
115
+ fitTickCount,
116
+ fitTicks,
117
+ linearScale,
118
+ normalizeToMax,
119
+ stackTotals,
120
+ } from './lib/chart'
121
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
122
+ import {
123
+ ChartFrame,
124
+ X_LABEL_FONT_PX,
125
+ Y_TICK_MIN_SPACING_PX,
126
+ type ChartFrameProps,
127
+ type ChartFrameState,
128
+ type ChartTableData,
129
+ type ChartTableRow,
130
+ } from './chart-frame'
131
+ import { ChartLegend, type ChartLegendItem } from './chart-legend'
132
+ import { ChartTooltip, type ChartTooltipRow } from './chart-tooltip'
133
+ import { formatNumber } from './format'
134
+
135
+ /* ── public types ────────────────────────────────────────────────────── */
136
+
137
+ /** One category — one band on the categorical axis. */
138
+ export interface BarChartDatum {
139
+ /**
140
+ * Stable identity key. The React key, the `onSelect` payload, and what
141
+ * `selectedKey` matches. Never the array index: colour and emphasis follow
142
+ * the entity, not its rank (ADR-146 D7).
143
+ */
144
+ key: string
145
+ /** The category's axis label. */
146
+ label: string
147
+ /**
148
+ * The bar's value in monochrome mode. Ignored when `series` is set.
149
+ *
150
+ * Omitted, `undefined` or `NaN` means **not measured**, which is a
151
+ * different fact from a measured `0`: no bar is drawn, and every readout
152
+ * of it says `—`. Pass `0` for a real zero.
153
+ */
154
+ value?: number
155
+ /**
156
+ * Grouped / stacked mode: one value per entry in `series`, in the SAME
157
+ * order.
158
+ *
159
+ * A missing or short entry is **absent, not zero** — no segment is drawn
160
+ * and it reads `—` in the readout, the accessible name and the table, the
161
+ * same as `value`. In `stacked` mode an absent segment contributes nothing
162
+ * to the running total (arithmetically what a zero would, but it is not
163
+ * REPORTED as a zero anywhere); the stack simply carries one fewer
164
+ * segment.
165
+ */
166
+ values?: readonly number[]
167
+ }
168
+
169
+ /** One entity — the only thing that makes this chart polychrome (D7). */
170
+ export interface BarChartSeries {
171
+ /** Stable entity key. The React key and the legend's identity. */
172
+ key: string
173
+ /** The entity's name. Always rendered in the legend and the readout. */
174
+ label: string
175
+ /**
176
+ * Palette slot, or `'other'` for the folded remainder.
177
+ *
178
+ * **Required, and assigned from the stable entity key** — never from the
179
+ * array index after a filter, which would repaint every survivor when one
180
+ * series is removed (ADR-146 D7).
181
+ */
182
+ slot: ChartSlot
183
+ }
184
+
185
+ /** Bars up the value axis, or along it. */
186
+ export type BarChartOrientation = 'vertical' | 'horizontal'
187
+
188
+ /** How multiple series share a category band. */
189
+ export type BarChartMode = 'grouped' | 'stacked'
190
+
191
+ /**
192
+ * Which single bar wears the full accent in monochrome mode (D7).
193
+ * `selectedKey` overrides this when it names a datum.
194
+ */
195
+ export type BarChartEmphasis = 'peak' | 'last' | 'none'
196
+
197
+ export interface BarChartProps {
198
+ /** The categories, in draw order. */
199
+ data: readonly BarChartDatum[]
200
+ /**
201
+ * The entities. Omit for the monochrome default — which is the right
202
+ * answer for almost every bar chart we draw. Supplying this is a claim
203
+ * that the chart encodes more than one entity.
204
+ */
205
+ series?: readonly BarChartSeries[]
206
+ /** How `series` share a band. Default `'grouped'`. Ignored without `series`. */
207
+ mode?: BarChartMode
208
+ /** Bars up, or bars along. Default `'vertical'`. */
209
+ orientation?: BarChartOrientation
210
+ /** Which palette `slot` indexes into. Default `'default'`. */
211
+ palette?: SeriesPalette
212
+ /** Which bar wears the full accent. Default `'peak'`. Monochrome only. */
213
+ emphasis?: BarChartEmphasis
214
+ /**
215
+ * The selected datum's `key`. Overrides `emphasis` when it matches a datum;
216
+ * a key that matches nothing simply emphasizes nothing (it does not fall
217
+ * back to the peak, which would make a failed lookup invisible).
218
+ */
219
+ selectedKey?: string | null
220
+ /** Makes each band a button, called with the datum's `key`. */
221
+ onSelect?: (key: string) => void
222
+ /**
223
+ * Draw the value axis — gridlines and tick labels. Default `true`.
224
+ * `false` is the kit's own bare `Data Plot`: bars normalised to the peak,
225
+ * no axis at all.
226
+ */
227
+ showAxis?: boolean
228
+ /**
229
+ * Target tick count for the value axis. Default `5`.
230
+ *
231
+ * A TARGET, and a ceiling — never a promise. The ladder is fitted to the
232
+ * plot that is actually on screen (`fitTicks`), so a short or narrow panel
233
+ * gets fewer ticks rather than a stack of overlapping labels, and no axis
234
+ * draws more than six however many are asked for.
235
+ */
236
+ tickCount?: number
237
+ /**
238
+ * Formats a value for the axis, the readout and the table. Default
239
+ * `formatNumber`. **`formatPercent` takes percent units, not a 0-1
240
+ * fraction** (ADR-146 D11) — convert at the call site.
241
+ */
242
+ formatValue?: (value: number) => string
243
+ /** Chart title. With no `series` this also names the single series (D10.2). */
244
+ title?: string
245
+ /** Supporting line under the title. */
246
+ subtitle?: string
247
+ /** Trailing slot in the title row — a range picker, a menu. */
248
+ action?: ReactNode
249
+ /** Frame state. `'loaded'` with no data resolves to `'empty'`. */
250
+ state?: ChartFrameState
251
+ /** Message for the empty state. */
252
+ emptyMessage?: string
253
+ /** Message for the error state. */
254
+ errorMessage?: string
255
+ /** Adds a retry button to the error state. */
256
+ onRetry?: () => void
257
+ /**
258
+ * Plot width ÷ height. Default `16 / 5` vertical, `16 / 9` horizontal. A
259
+ * horizontal chart with many categories wants a taller ratio — pass one.
260
+ */
261
+ aspectRatio?: number
262
+ /**
263
+ * Header for the table's category column, and the accessible name of the
264
+ * categorical axis. Default `'Category'`.
265
+ */
266
+ categoryLabel?: string
267
+ /**
268
+ * Header for the table's measure column in monochrome mode. Default
269
+ * `'Value'`. Ignored when `series` is set — each series names its own.
270
+ */
271
+ valueLabel?: string
272
+ /**
273
+ * Width of the category-label gutter in horizontal orientation. A
274
+ * PROPORTION by default (`'30%'`) rather than a fixed length, so labels
275
+ * stay legible at every panel width; pass a token-derived length
276
+ * (`'var(--space-12)'`) for a fixed gutter.
277
+ */
278
+ categoryGutter?: string
279
+ /**
280
+ * Below this bar width (px) the chart says "too dense" instead of drawing.
281
+ * Default `4`. Bars thinner than this are mush, and mush that renders is
282
+ * worse than a chart that admits it cannot.
283
+ */
284
+ minBarSize?: number
285
+ /** Optional className on the frame panel. */
286
+ className?: string
287
+ }
288
+
289
+ /* ── geometry constants ──────────────────────────────────────────────── */
290
+ /* Pixel arithmetic cannot read a CSS custom property, so these are numbers.
291
+ Each names the token it mirrors. */
292
+
293
+ /** `--space-4` (16px): the gap between bars at full size (Metoric §6.1). */
294
+ const GAP_FULL_PX = 16
295
+
296
+ /** Floor, so a dense strip separates into bars instead of fusing into a block. */
297
+ const GAP_MIN_PX = 2
298
+
299
+ /** Cap the gap at this share of the step, which is what makes it scale down. */
300
+ const GAP_MAX_RATIO = 0.35
301
+
302
+ /** Gap between the bars WITHIN one group, as a share of the group's band. */
303
+ const GROUP_GAP_RATIO = 0.12
304
+
305
+ /**
306
+ * The seam between adjacent stacked segments — a hairline in the panel
307
+ * colour, the same family of value as the 1px gridline, doubled so it stays
308
+ * visible against two saturated fills.
309
+ */
310
+ const STACK_SEAM = '2px'
311
+
312
+ /** Height reserved for the horizontal chart's value-tick strip. */
313
+ const TICK_STRIP_HEIGHT = 'var(--space-5)'
314
+
315
+ /** Clear air between two labels on the horizontal value-tick strip. */
316
+ const TICK_LABEL_GAP_PX = 12
317
+
318
+ /** Monochrome: every bar. `--accent` is a bare triple, so this alpha is legal. */
319
+ const BAR_REST = 'rgb(var(--accent) / 0.14)'
320
+
321
+ /** Monochrome: the ONE emphasized bar. The whole point of D7. */
322
+ const BAR_EMPHASIS = 'rgb(var(--accent))'
323
+
324
+ /**
325
+ * What an absent value reads as, everywhere a number would go.
326
+ *
327
+ * The same em dash `LineChart` uses for a gap, for the same reason: a datum
328
+ * that was never measured is not a measurement of none, and printing "0" for
329
+ * it makes the two indistinguishable.
330
+ */
331
+ const NO_VALUE = '—'
332
+
333
+ /* ── density guard ───────────────────────────────────────────────────── */
334
+
335
+ const WARNED = new Set<string>()
336
+
337
+ /**
338
+ * Warn once per `key`, process-wide.
339
+ *
340
+ * The key is deliberately NOT the message: a chart re-renders on every hover
341
+ * and re-measures on every resize, and a message that carries a measured
342
+ * pixel value would defeat a message-keyed cache the first time the panel
343
+ * changed width.
344
+ */
345
+ function warnOnce(key: string, message: string): void {
346
+ if (WARNED.has(key)) return
347
+ WARNED.add(key)
348
+ console.warn(message)
349
+ }
350
+
351
+ /* ── internals ───────────────────────────────────────────────────────── */
352
+
353
+ /**
354
+ * Read a datum as a finite number, or `null` for "there is no measurement
355
+ * here" — `undefined`, `NaN` and `±Infinity` all mean absent.
356
+ *
357
+ * It used to fold all three onto `0`, which drew a zero-length bar and
358
+ * printed "0" in the tooltip, the accessible name and the table: a missing
359
+ * measurement reported as a measured zero.
360
+ */
361
+ function num(value: number | undefined): number | null {
362
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
363
+ }
364
+
365
+ /** Format a value, or the em dash when there is nothing to format. */
366
+ function read(value: number | null | undefined, format: (v: number) => string): string {
367
+ return value === null || value === undefined ? NO_VALUE : format(value)
368
+ }
369
+
370
+ /** One drawn rectangle: a span along the value axis, in percent of the plot. */
371
+ interface Span {
372
+ /** The edge nearer the value-axis origin (bottom, or left). */
373
+ readonly start: number
374
+ /** The far edge. */
375
+ readonly end: number
376
+ }
377
+
378
+ interface PlotMetrics {
379
+ /** Plot box size — what `ChartTooltip` clamps against. */
380
+ readonly plotWidth: number
381
+ readonly plotHeight: number
382
+ /** The bar region's box, relative to the plot box. */
383
+ readonly regionLeft: number
384
+ readonly regionTop: number
385
+ readonly regionWidth: number
386
+ readonly regionHeight: number
387
+ }
388
+
389
+ const UNMEASURED: PlotMetrics = {
390
+ plotWidth: 0,
391
+ plotHeight: 0,
392
+ regionLeft: 0,
393
+ regionTop: 0,
394
+ regionWidth: 0,
395
+ regionHeight: 0,
396
+ }
397
+
398
+ function sameMetrics(a: PlotMetrics, b: PlotMetrics): boolean {
399
+ return (
400
+ a.plotWidth === b.plotWidth &&
401
+ a.plotHeight === b.plotHeight &&
402
+ a.regionLeft === b.regionLeft &&
403
+ a.regionTop === b.regionTop &&
404
+ a.regionWidth === b.regionWidth &&
405
+ a.regionHeight === b.regionHeight
406
+ )
407
+ }
408
+
409
+ /**
410
+ * A message drawn INSIDE the plot box while the axis chrome is still live.
411
+ *
412
+ * The all-zero and too-dense states deliberately keep the gridlines and the
413
+ * baseline (that is the point — the axis proves the chart is not broken), so
414
+ * the message lands on top of live rules. Measured on the all-zero demo before
415
+ * this existed: the message box spanned y 241-260 while a 608px-wide
416
+ * `rgb(var(--chart-grid))` rule sat at y=250 — dead centre through the
417
+ * sentence — because both the text and its wrapper computed to a transparent
418
+ * background.
419
+ *
420
+ * So the text gets an OPAQUE knock-out on the panel surface. `--surface-card`
421
+ * is correct and not a guess: `ChartFrame` paints its `<figure>` with exactly
422
+ * `rgb(var(--surface-card))` (`chart-frame.tsx`), and every BarChart renders
423
+ * inside one. The backing therefore matches its ground exactly — it is
424
+ * invisible as a shape and visible only as the gap it cuts in the gridlines,
425
+ * which is what makes it read as a label sitting ON the plot.
426
+ *
427
+ * It stays centred, and the rules keep running behind it on both sides.
428
+ */
429
+ function PlotNotice({ children }: { children: string }) {
430
+ return (
431
+ <div
432
+ className="pointer-events-none absolute inset-0 flex items-center justify-center text-center"
433
+ style={{ padding: 'var(--space-4)' }}
434
+ >
435
+ <span
436
+ className="text-[13px]"
437
+ style={{
438
+ color: 'rgb(var(--text-tertiary))',
439
+ background: 'rgb(var(--surface-card))',
440
+ paddingBlock: 'var(--space-1)',
441
+ paddingInline: 'var(--space-3)',
442
+ borderRadius: 'var(--radius-sm)',
443
+ }}
444
+ >
445
+ {children}
446
+ </span>
447
+ </div>
448
+ )
449
+ }
450
+
451
+ export function BarChart({
452
+ data,
453
+ series,
454
+ mode = 'grouped',
455
+ orientation = 'vertical',
456
+ palette = 'default',
457
+ emphasis = 'peak',
458
+ selectedKey,
459
+ onSelect,
460
+ showAxis = true,
461
+ tickCount = 5,
462
+ formatValue = formatNumber,
463
+ title,
464
+ subtitle,
465
+ action,
466
+ state = 'loaded',
467
+ emptyMessage,
468
+ errorMessage,
469
+ onRetry,
470
+ aspectRatio,
471
+ categoryLabel = 'Category',
472
+ valueLabel = 'Value',
473
+ categoryGutter = '30%',
474
+ minBarSize = 4,
475
+ className,
476
+ }: BarChartProps) {
477
+ // CALLBACK refs, not `useRef`. `ChartFrame` renders `children` only in the
478
+ // `loaded` state and swaps the plot out for the table view, so both boxes
479
+ // unmount and remount. A `useRef` + fixed-dep effect binds once and never
480
+ // rebinds, which leaves a chart that first painted in `loading`/`empty` —
481
+ // the normal async path — with all-zero metrics for the rest of its life:
482
+ // the tooltip would anchor at the plot's top-left instead of the hovered
483
+ // bar, and the density guard would never fire.
484
+ const [plotNode, setPlotNode] = useState<HTMLDivElement | null>(null)
485
+ const [regionNode, setRegionNode] = useState<HTMLDivElement | null>(null)
486
+ const [metrics, setMetrics] = useState<PlotMetrics>(UNMEASURED)
487
+ const [hovered, setHovered] = useState<number | null>(null)
488
+ const [lastHovered, setLastHovered] = useState<number>(0)
489
+
490
+ const vertical = orientation === 'vertical'
491
+ const count = data.length
492
+ // Stable identity when `series` is omitted, so the memos below actually
493
+ // memo instead of recomputing on every hover.
494
+ const seriesList = useMemo(() => series ?? [], [series])
495
+ const multi = seriesList.length > 0
496
+ const stacked = multi && mode === 'stacked'
497
+
498
+ /* — the value matrix: one row per datum, one entry per series —
499
+ `null` is ABSENT, and stays distinguishable from `0` all the way to
500
+ the mark, the readout, the accessible name and the table. */
501
+ const matrix = useMemo<(number | null)[][]>(
502
+ () =>
503
+ data.map((datum) =>
504
+ multi
505
+ ? seriesList.map((_, si) => num(datum.values?.[si]))
506
+ : [num(datum.value)],
507
+ ),
508
+ // `data` and `series` are fresh arrays on every parent render; keying on
509
+ // them directly is correct here because the map is cheap and the result
510
+ // is only ever read synchronously in this render.
511
+ [data, seriesList, multi],
512
+ )
513
+
514
+ const hasNegative = matrix.some((row) => row.some((v) => v !== null && v < 0))
515
+ /** Whether anything at all was measured. All-absent is `empty`, not zero. */
516
+ const hasValue = matrix.some((row) => row.some((v) => v !== null))
517
+ /** Whether anything is missing — which narrows what the all-zero message
518
+ * is allowed to claim. */
519
+ const hasAbsent = matrix.some((row) => row.some((v) => v === null))
520
+
521
+ // A stack is a running sum, so a negative segment would have to eat into
522
+ // the one below it — there is no honest picture of that, and every charting
523
+ // library that tries produces a different one. We clamp to zero, which is
524
+ // exactly the "chart that lies" this primitive refuses everywhere else, so
525
+ // it is not allowed to be SILENT. Grouped mode has no such limitation and
526
+ // draws negatives properly.
527
+ useEffect(() => {
528
+ if (!stacked || !hasNegative) return
529
+ warnOnce(
530
+ 'bar-chart:stacked-negative',
531
+ '[@lovett/ui] BarChart: a stacked chart cannot draw a negative value, ' +
532
+ 'so negative segments are clamped to zero and the total under-reads. ' +
533
+ 'Use mode="grouped", or split the negative measure into its own ' +
534
+ 'chart — see ADR-146 D5.',
535
+ )
536
+ }, [stacked, hasNegative])
537
+
538
+ /* — the value domain, with zero always inside it — */
539
+ // A bar is read as a length from zero, so zero is in the domain whether or
540
+ // not the data reaches it. This is also what makes an all-equal series
541
+ // draw full-height bars rather than collapsing to a flat line.
542
+ const columnTotals = useMemo(
543
+ () =>
544
+ stacked
545
+ ? stackTotals(
546
+ seriesList.map((_, si) => matrix.map((row) => row[si] ?? 0)),
547
+ )
548
+ : [],
549
+ [stacked, seriesList, matrix],
550
+ )
551
+
552
+ let dataMin = 0
553
+ let dataMax = 0
554
+ if (stacked) {
555
+ for (const total of columnTotals) {
556
+ if (total > dataMax) dataMax = total
557
+ }
558
+ } else {
559
+ for (const row of matrix) {
560
+ for (const v of row) {
561
+ // An absent value is not a datum, so it does not get a vote on the
562
+ // domain. (Zero is already in the domain by construction, so this
563
+ // changes no axis today — it is here so the invariant holds if that
564
+ // ever stops being true.)
565
+ if (v === null) continue
566
+ if (v < dataMin) dataMin = v
567
+ if (v > dataMax) dataMax = v
568
+ }
569
+ }
570
+ }
571
+
572
+ const axisTickCount = Math.max(2, Math.min(12, Math.floor(tickCount) || 5))
573
+
574
+ /**
575
+ * How far apart two value-axis labels have to be before they read as two
576
+ * labels — and it is a different question in each orientation.
577
+ *
578
+ * Standing bars put the value axis in the frame's vertical gutter, where
579
+ * labels sit above each other and the constraint is two line boxes. Lying
580
+ * bars put it along the bottom, where they sit beside each other and the
581
+ * constraint is the widest label's own WIDTH plus a gap. The widest is
582
+ * estimated from the domain's endpoints, which is where the longest number
583
+ * on a nice-number ladder lives.
584
+ */
585
+ const axisSpacingPx = vertical
586
+ ? Y_TICK_MIN_SPACING_PX
587
+ : Math.max(
588
+ estimateTextWidth(formatValue(dataMin), X_LABEL_FONT_PX),
589
+ estimateTextWidth(formatValue(dataMax), X_LABEL_FONT_PX),
590
+ ) + TICK_LABEL_GAP_PX
591
+ // The value axis is drawn along the plot's height when bars stand up and
592
+ // along its width when they lie down, so the fit measures whichever one
593
+ // the labels actually run along. Both are `0` until the ResizeObserver
594
+ // lands, which `fitTicks` reads as "unmeasured" and leaves alone.
595
+ const axisLengthPx = vertical ? metrics.regionHeight : metrics.regionWidth
596
+ const ticks = showAxis
597
+ ? fitTicks(dataMin, dataMax, axisLengthPx, axisSpacingPx, axisTickCount)
598
+ : []
599
+ const lo = showAxis ? (ticks[0] ?? dataMin) : dataMin
600
+ const hi = showAxis ? (ticks[ticks.length - 1] ?? dataMax) : dataMax
601
+
602
+ // `hi === lo` can only happen when every MEASURED value is zero, because
603
+ // zero is already inside the domain. `hasValue` is what separates that from
604
+ // "nothing was measured", which collapses the domain the same way but is a
605
+ // different fact and gets the frame's empty state instead.
606
+ const allZero = count > 0 && hasValue && hi === lo
607
+
608
+ const toPct = linearScale([lo, hi], [0, 100])
609
+ const zeroPct = allZero ? 0 : toPct(0)
610
+
611
+ /**
612
+ * The no-axis monochrome strip is `normalizeToMax`'s exact job — heights as
613
+ * a fraction of the peak, no domain and no ticks. It clamps negatives to
614
+ * zero, so it is used ONLY when there are none: a negative bar has to
615
+ * descend from the baseline, not vanish.
616
+ */
617
+ const peakFractions =
618
+ !showAxis && !multi && !hasNegative
619
+ ? normalizeToMax(matrix.map((row) => row[0] ?? 0))
620
+ : null
621
+
622
+ /* — spans, one per drawn rectangle. `null` = nothing to draw here — */
623
+ const spans = useMemo<(Span | null)[][]>(() => {
624
+ if (allZero) {
625
+ return matrix.map((row) =>
626
+ row.map((value) => (value === null ? null : { start: 0, end: 0 })),
627
+ )
628
+ }
629
+ return matrix.map((row, di) => {
630
+ if (peakFractions !== null) {
631
+ const value = row[0] ?? null
632
+ return [value === null ? null : { start: 0, end: (peakFractions[di] ?? 0) * 100 }]
633
+ }
634
+ if (stacked) {
635
+ let cursor = 0
636
+ return row.map((value) => {
637
+ // Absent: no segment, and the cursor does not move. The stack
638
+ // carries one fewer band rather than a seam around nothing.
639
+ if (value === null) return null
640
+ const v = value > 0 ? value : 0
641
+ const span = { start: toPct(cursor), end: toPct(cursor + v) }
642
+ cursor += v
643
+ return span
644
+ })
645
+ }
646
+ return row.map((value) => {
647
+ if (value === null) return null
648
+ const end = toPct(value)
649
+ return value < 0
650
+ ? { start: end, end: zeroPct }
651
+ : { start: zeroPct, end }
652
+ })
653
+ })
654
+ // `toPct` is rebuilt every render; the domain values it closes over are
655
+ // the real dependencies.
656
+ }, [matrix, allZero, peakFractions, stacked, lo, hi, zeroPct]) // eslint-disable-line react-hooks/exhaustive-deps
657
+
658
+ /* — band geometry — */
659
+ // The band axis is the plot's width when bars stand up, its height when
660
+ // they lie down. Both come off the same measurement.
661
+ const bandAxisPx = vertical ? metrics.regionWidth : metrics.regionHeight
662
+ const stepPx = count > 0 && bandAxisPx > 0 ? bandAxisPx / count : 0
663
+ const gapPx =
664
+ stepPx > 0
665
+ ? Math.min(GAP_FULL_PX, Math.max(GAP_MIN_PX, stepPx * GAP_MAX_RATIO))
666
+ : 0
667
+ // Unmeasured, fall back to the ratio: percentages are already correct, so
668
+ // the first paint is a real chart and the measurement only refines the gap.
669
+ const paddingRatio = stepPx > 0 ? Math.min(1, gapPx / stepPx) : GAP_MAX_RATIO
670
+
671
+ // Percent units: `bandScale` over a width of 100 hands back percentages,
672
+ // which is what lets the bars lay out before anything is measured.
673
+ const bands = bandScale(count, 100, paddingRatio)
674
+ const groups = bandScale(
675
+ multi && !stacked ? seriesList.length : 1,
676
+ bands.band,
677
+ multi && !stacked ? GROUP_GAP_RATIO : 0,
678
+ )
679
+
680
+ const drawnBandPct = multi && !stacked ? groups.band : bands.band
681
+ const drawnBandPx = (drawnBandPct / 100) * bandAxisPx
682
+ const tooDense = bandAxisPx > 0 && count > 0 && drawnBandPx < minBarSize
683
+
684
+ useEffect(() => {
685
+ if (!tooDense) return
686
+ warnOnce(
687
+ `bar-chart:too-dense:${count}`,
688
+ `[@lovett/ui] BarChart: ${count} bars leave ${drawnBandPx.toFixed(1)}px ` +
689
+ `per bar, below the ${minBarSize}px legibility floor. Aggregate, page, ` +
690
+ `or use the table view — see ADR-146 D5.`,
691
+ )
692
+ }, [tooDense, count, drawnBandPx, minBarSize])
693
+
694
+ /* — measurement: the tooltip's clamp bounds and the density check — */
695
+ useLayoutEffect(() => {
696
+ const plot = plotNode
697
+ const region = regionNode
698
+ if (plot === null || region === null) return
699
+
700
+ const read = () => {
701
+ const next: PlotMetrics = {
702
+ plotWidth: plot.offsetWidth,
703
+ plotHeight: plot.offsetHeight,
704
+ regionLeft: region.offsetLeft,
705
+ regionTop: region.offsetTop,
706
+ regionWidth: region.offsetWidth,
707
+ regionHeight: region.offsetHeight,
708
+ }
709
+ setMetrics((prev) => (sameMetrics(prev, next) ? prev : next))
710
+ }
711
+
712
+ read()
713
+ if (typeof ResizeObserver === 'undefined') return
714
+ const observer = new ResizeObserver(read)
715
+ observer.observe(plot)
716
+ observer.observe(region)
717
+ return () => observer.disconnect()
718
+ }, [plotNode, regionNode, orientation, categoryGutter, count])
719
+
720
+ /* — emphasis: exactly one bar, and only in monochrome (D7) — */
721
+ let emphasized: number | null = null
722
+ if (!multi && !allZero && count > 0) {
723
+ if (selectedKey !== null && selectedKey !== undefined) {
724
+ const found = data.findIndex((datum) => datum.key === selectedKey)
725
+ emphasized = found >= 0 ? found : null
726
+ } else if (emphasis === 'peak') {
727
+ // Only MEASURED values compete. Folding absent onto zero let a missing
728
+ // reading win the accent outright on an all-negative series — the
729
+ // emphasis landing on a bar that is not drawn at all.
730
+ let best: number | null = null
731
+ let bestValue = Number.NEGATIVE_INFINITY
732
+ for (let i = 0; i < matrix.length; i++) {
733
+ const value = matrix[i]?.[0] ?? null
734
+ if (value === null || value <= bestValue) continue
735
+ best = i
736
+ bestValue = value
737
+ }
738
+ emphasized = best
739
+ } else if (emphasis === 'last') {
740
+ // The last CATEGORY, not the last measurement — but an unmeasured one
741
+ // draws no bar, so emphasizing it would emphasize nothing visible.
742
+ emphasized = (matrix[count - 1]?.[0] ?? null) === null ? null : count - 1
743
+ }
744
+ }
745
+
746
+ /* — frame chrome — */
747
+ // Categories with nothing measured in any of them are EMPTY, not zero.
748
+ // "Every value in this range is zero" is a claim about measurements, and
749
+ // there are none to make it about.
750
+ const frameState: ChartFrameState =
751
+ state === 'loaded' && (count === 0 || !hasValue) ? 'empty' : state
752
+
753
+ const tickLabels = ticks.map((tick) => formatValue(tick))
754
+ // ChartFrame's y-gutter is a VALUE axis. It carries the ticks when bars
755
+ // stand up; when they lie down the value axis is horizontal, so the frame
756
+ // draws no gutter and no gridlines and this chart draws its own.
757
+ const yLabels: string[] = !vertical || !showAxis
758
+ ? []
759
+ : allZero
760
+ ? // All-zero keeps the axis STRUCTURE — the gutter's reserved width and
761
+ // its gridlines — so the plot does not change width between a range
762
+ // with data and one without (D9, reserved space). It carries no
763
+ // NUMBERS: `niceTicks(0, 0)` is a single tick, which the frame would
764
+ // centre at mid-height, drawing a zero line floating above the
765
+ // baseline. Blank labels keep the structure and drop the lie.
766
+ // Fitted like any other ladder, so the blank gutter reserves the
767
+ // same number of rows a measured one would.
768
+ Array.from(
769
+ { length: fitTickCount(axisLengthPx, axisSpacingPx, axisTickCount) },
770
+ () => '',
771
+ )
772
+ : [...tickLabels].reverse()
773
+ // A chart that has declined to draw its bars must not print 400 crushed
774
+ // labels underneath the refusal.
775
+ const xLabels: string[] =
776
+ vertical && !tooDense ? data.map((datum) => datum.label) : []
777
+ /**
778
+ * Where each category label belongs: the CENTRE OF ITS BAND, as a fraction
779
+ * of the plot — the same `bands.center` the bar itself is drawn on.
780
+ *
781
+ * Without it the frame can only spread the labels evenly, which is not the
782
+ * band scale: measured in Chromium at 1440, that put "Paid search" 25px
783
+ * from its bar on the 5-category demo and 65px on the 3-category one.
784
+ */
785
+ const xLabelFractions: number[] = xLabels.map(
786
+ (_, index) => bands.center(index) / 100,
787
+ )
788
+
789
+ const table = useMemo<ChartTableData>(() => {
790
+ const columns = multi
791
+ ? [categoryLabel, ...seriesList.map((entry) => entry.label)]
792
+ : [categoryLabel, valueLabel]
793
+ const rows: ChartTableRow[] = data.map((datum, di) => ({
794
+ key: datum.key,
795
+ cells: [
796
+ datum.label,
797
+ ...(matrix[di] ?? []).map((value) => read(value, formatValue)),
798
+ ],
799
+ }))
800
+ return { columns, rows, caption: `${title ?? 'Chart'} data table` }
801
+ }, [data, matrix, multi, seriesList, categoryLabel, valueLabel, formatValue, title])
802
+
803
+ const legendItems: ChartLegendItem[] = seriesList.map((entry) => ({
804
+ key: entry.key,
805
+ label: entry.label,
806
+ slot: entry.slot,
807
+ }))
808
+
809
+ /* — the readout — */
810
+ const readoutIndex = hovered ?? lastHovered
811
+ const readoutDatum = data[readoutIndex]
812
+ const tooltipRows: ChartTooltipRow[] = multi
813
+ ? seriesList.map((entry, si) => ({
814
+ key: entry.key,
815
+ label: entry.label,
816
+ value: read(matrix[readoutIndex]?.[si], formatValue),
817
+ slot: entry.slot,
818
+ }))
819
+ : [
820
+ {
821
+ key: readoutDatum?.key ?? 'value',
822
+ label: valueLabel,
823
+ value: read(matrix[readoutIndex]?.[0], formatValue),
824
+ },
825
+ ]
826
+
827
+ const readoutSpans = spans[readoutIndex] ?? []
828
+ const readoutFar = readoutSpans.reduce(
829
+ (far, span) => (span === null ? far : Math.max(far, span.end)),
830
+ zeroPct,
831
+ )
832
+ const anchorX = vertical
833
+ ? metrics.regionLeft + (bands.center(readoutIndex) / 100) * metrics.regionWidth
834
+ : metrics.regionLeft + (readoutFar / 100) * metrics.regionWidth
835
+ const anchorY = vertical
836
+ ? metrics.regionTop + ((100 - readoutFar) / 100) * metrics.regionHeight
837
+ : metrics.regionTop + (bands.center(readoutIndex) / 100) * metrics.regionHeight
838
+
839
+ function activate(index: number | null): void {
840
+ setHovered(index)
841
+ if (index !== null) setLastHovered(index)
842
+ }
843
+
844
+ /* — the region box: bars, gridlines and the zero rule live here — */
845
+ // The horizontal chart draws its own value-tick strip along the bottom
846
+ // (the frame's tick row belongs to a vertical value axis), so the region
847
+ // gives that strip its height — and gives it back when there is no axis.
848
+ const tickStrip = !vertical && showAxis ? TICK_STRIP_HEIGHT : 0
849
+
850
+ const regionStyle: CSSProperties = vertical
851
+ ? { left: 0, right: 0, top: 0, bottom: 0 }
852
+ : { left: categoryGutter, right: 0, top: 0, bottom: tickStrip }
853
+
854
+ // The hit layer shares the region's depth but spans the full width, so in
855
+ // horizontal orientation hovering the category label works too.
856
+ const hitBoxStyle: CSSProperties = vertical
857
+ ? { left: 0, right: 0, top: 0, bottom: 0 }
858
+ : { left: 0, right: 0, top: 0, bottom: tickStrip }
859
+
860
+ const showMarks = frameState === 'loaded' && count > 0 && !tooDense && !allZero
861
+
862
+ const frameProps: ChartFrameProps = {
863
+ state: frameState,
864
+ aspectRatio: aspectRatio ?? (vertical ? 16 / 5 : 16 / 9),
865
+ yLabels,
866
+ xLabels,
867
+ xLabelFractions,
868
+ table,
869
+ ...(title !== undefined && { title }),
870
+ ...(subtitle !== undefined && { subtitle }),
871
+ ...(action !== undefined && { action }),
872
+ ...(emptyMessage !== undefined && { emptyMessage }),
873
+ ...(errorMessage !== undefined && { errorMessage }),
874
+ ...(onRetry !== undefined && { onRetry }),
875
+ ...(className !== undefined && { className }),
876
+ ...(legendItems.length >= 2 && {
877
+ legend: (
878
+ <ChartLegend
879
+ items={legendItems}
880
+ palette={palette}
881
+ label={`${title ?? 'Chart'} series`}
882
+ />
883
+ ),
884
+ }),
885
+ }
886
+
887
+ return (
888
+ <ChartFrame {...frameProps}>
889
+ <div ref={setPlotNode} className="absolute inset-0">
890
+ {/* Horizontal: the category-label gutter. Vertical charts get their
891
+ labels from the frame's x-label row instead. */}
892
+ {!vertical && frameState === 'loaded' && (
893
+ <div
894
+ aria-hidden="true"
895
+ className="absolute"
896
+ style={{
897
+ left: 0,
898
+ top: 0,
899
+ width: categoryGutter,
900
+ bottom: tickStrip,
901
+ }}
902
+ >
903
+ {data.map((datum, di) => (
904
+ <span
905
+ key={datum.key}
906
+ className="absolute block truncate text-[14px]"
907
+ style={{
908
+ // The gap between the label and the bar it names lives HERE,
909
+ // not as padding on the gutter: an absolutely-positioned
910
+ // child resolves its offsets against its ancestor's PADDING
911
+ // box, so padding on the gutter would be dead style and the
912
+ // labels would sit flush against the bars.
913
+ right: 'var(--space-2)',
914
+ top: `${bands.center(di)}%`,
915
+ maxWidth: 'calc(100% - var(--space-2))',
916
+ transform: 'translateY(-50%)',
917
+ color: 'rgb(var(--text-tertiary))',
918
+ }}
919
+ >
920
+ {datum.label}
921
+ </span>
922
+ ))}
923
+ </div>
924
+ )}
925
+
926
+ {/* The bar region. Measured, so the readout can be positioned. */}
927
+ <div ref={setRegionNode} className="absolute" style={regionStyle}>
928
+ {/* Horizontal gets its own gridlines: the frame's are horizontal
929
+ rules for a vertical value axis, which is the wrong axis here. */}
930
+ {!vertical &&
931
+ showAxis &&
932
+ frameState === 'loaded' &&
933
+ !allZero &&
934
+ ticks.map((tick, ti) => (
935
+ <div
936
+ key={`grid-${ti}`}
937
+ aria-hidden="true"
938
+ className="pointer-events-none absolute top-0 bottom-0"
939
+ style={{
940
+ left: `${toPct(tick)}%`,
941
+ width: '1px',
942
+ background: 'rgb(var(--chart-grid))',
943
+ }}
944
+ />
945
+ ))}
946
+
947
+ {/* The zero rule. Only drawn where zero is NOT the frame's own
948
+ baseline — a negative-spanning vertical chart, or any
949
+ horizontal one, where the value axis runs the other way. */}
950
+ {frameState === 'loaded' && !allZero && (hasNegative || !vertical) && (
951
+ <div
952
+ aria-hidden="true"
953
+ className="pointer-events-none absolute"
954
+ style={
955
+ vertical
956
+ ? {
957
+ left: 0,
958
+ right: 0,
959
+ bottom: `${zeroPct}%`,
960
+ height: '1px',
961
+ background: 'rgb(var(--chart-axis))',
962
+ }
963
+ : {
964
+ top: 0,
965
+ bottom: 0,
966
+ left: `${zeroPct}%`,
967
+ width: '1px',
968
+ background: 'rgb(var(--chart-axis))',
969
+ }
970
+ }
971
+ />
972
+ )}
973
+
974
+ {/* The hover column, behind the marks. */}
975
+ {showMarks && hovered !== null && (
976
+ <div
977
+ aria-hidden="true"
978
+ className="pointer-events-none absolute"
979
+ style={{
980
+ ...(vertical
981
+ ? {
982
+ left: `${hovered * bands.step}%`,
983
+ width: `${bands.step}%`,
984
+ top: 0,
985
+ bottom: 0,
986
+ }
987
+ : {
988
+ top: `${hovered * bands.step}%`,
989
+ height: `${bands.step}%`,
990
+ left: 0,
991
+ right: 0,
992
+ }),
993
+ background: 'rgb(var(--chart-band))',
994
+ borderRadius: 'var(--radius-sm)',
995
+ }}
996
+ />
997
+ )}
998
+
999
+ {/* The marks. */}
1000
+ {showMarks &&
1001
+ data.map((datum, di) => {
1002
+ const rowSpans = spans[di] ?? []
1003
+ return rowSpans.map((span, si) => {
1004
+ // Absent: no mark. A zero-length bar on the baseline would
1005
+ // read as a measured zero, which is the whole defect.
1006
+ if (span === null) return null
1007
+ const entry = seriesList[si]
1008
+ const bandStart =
1009
+ multi && !stacked
1010
+ ? bands.at(di) + groups.at(si)
1011
+ : bands.at(di)
1012
+ const bandSize = multi && !stacked ? groups.band : bands.band
1013
+ const length = Math.abs(span.end - span.start)
1014
+ const near = Math.min(span.start, span.end)
1015
+ const fill = multi
1016
+ ? slotColor(entry?.slot ?? 'other', palette)
1017
+ : di === emphasized
1018
+ ? BAR_EMPHASIS
1019
+ : BAR_REST
1020
+ // The seam between stacked segments: a hairline painted in
1021
+ // the panel colour so two fills read as two marks. The top
1022
+ // DRAWN segment has nothing above it, so it gets none —
1023
+ // absent segments above it do not count, or the topmost bar
1024
+ // would wear a seam against empty space.
1025
+ const seam =
1026
+ stacked && rowSpans.slice(si + 1).some((next) => next !== null)
1027
+ ? `${STACK_SEAM} solid rgb(var(--surface-card))`
1028
+ : undefined
1029
+ return (
1030
+ <div
1031
+ key={`${datum.key}-${entry?.key ?? 'value'}`}
1032
+ aria-hidden="true"
1033
+ className={cn(
1034
+ 'pointer-events-none absolute',
1035
+ // Named properties, never `transition-all`: `all` would
1036
+ // also tween the seam's border-width and every inherited
1037
+ // property, and it makes what is animating unreadable.
1038
+ // The list is static, so Tailwind can scan it.
1039
+ 'motion-safe:transition-[left,top,right,bottom,width,height,background-color]',
1040
+ )}
1041
+ style={{
1042
+ ...(vertical
1043
+ ? {
1044
+ left: `${bandStart}%`,
1045
+ width: `${bandSize}%`,
1046
+ bottom: `${near}%`,
1047
+ height: `${length}%`,
1048
+ ...(seam !== undefined && { borderTop: seam }),
1049
+ }
1050
+ : {
1051
+ top: `${bandStart}%`,
1052
+ height: `${bandSize}%`,
1053
+ left: `${near}%`,
1054
+ width: `${length}%`,
1055
+ ...(seam !== undefined && { borderRight: seam }),
1056
+ }),
1057
+ boxSizing: 'border-box',
1058
+ background: fill,
1059
+ borderRadius: 'var(--radius-sm)',
1060
+ // Data changes tween with ease-out. Never ease-spring:
1061
+ // overshoot on a measured length reads as imprecise.
1062
+ transitionDuration: 'var(--dur-base)',
1063
+ transitionTimingFunction: 'var(--ease-out)',
1064
+ }}
1065
+ />
1066
+ )
1067
+ })
1068
+ })}
1069
+
1070
+ {/* All-zero: the axis stays, and we say so. Invisible bars on a
1071
+ blank panel read as a broken chart, not as zero. */}
1072
+ {frameState === 'loaded' && allZero && (
1073
+ <PlotNotice>
1074
+ {hasAbsent
1075
+ ? 'Every value measured in this range is zero.'
1076
+ : 'Every value in this range is zero.'}
1077
+ </PlotNotice>
1078
+ )}
1079
+
1080
+ {/* Too dense to draw. The frame's table toggle is still there. */}
1081
+ {frameState === 'loaded' && tooDense && (
1082
+ <PlotNotice>
1083
+ {`Too many categories to chart legibly (${count}). Narrow the range or open the table.`}
1084
+ </PlotNotice>
1085
+ )}
1086
+ </div>
1087
+
1088
+ {/* Horizontal: the value-tick strip, aligned to the gridlines. */}
1089
+ {!vertical && showAxis && frameState === 'loaded' && !allZero && (
1090
+ <div
1091
+ aria-hidden="true"
1092
+ className="absolute"
1093
+ style={{
1094
+ left: categoryGutter,
1095
+ right: 0,
1096
+ bottom: 0,
1097
+ height: TICK_STRIP_HEIGHT,
1098
+ }}
1099
+ >
1100
+ {ticks.map((tick, ti) => {
1101
+ const pct = toPct(tick)
1102
+ const shift =
1103
+ ti === 0
1104
+ ? 'translateX(0)'
1105
+ : ti === ticks.length - 1
1106
+ ? 'translateX(-100%)'
1107
+ : 'translateX(-50%)'
1108
+ return (
1109
+ <span
1110
+ key={`tick-${ti}`}
1111
+ className="absolute top-0 text-[14px] tabular-nums tracking-[-0.5px] whitespace-nowrap"
1112
+ style={{
1113
+ left: `${pct}%`,
1114
+ transform: shift,
1115
+ color: 'rgb(var(--text-tertiary))',
1116
+ }}
1117
+ >
1118
+ {tickLabels[ti]}
1119
+ </span>
1120
+ )
1121
+ })}
1122
+ </div>
1123
+ )}
1124
+
1125
+ {/* The hit layer. One target per category, spanning the full band
1126
+ (bar + gap) and the full plot depth — always at least as large as
1127
+ the bar it selects, which is what makes a 4px bar usable. */}
1128
+ {frameState === 'loaded' && count > 0 && !tooDense && (
1129
+ <div className="absolute" style={hitBoxStyle}>
1130
+ {data.map((datum, di) => {
1131
+ const summary = multi
1132
+ ? seriesList
1133
+ .map(
1134
+ (entry, si) =>
1135
+ `${entry.label} ${read(matrix[di]?.[si], formatValue)}`,
1136
+ )
1137
+ .join(', ')
1138
+ : read(matrix[di]?.[0], formatValue)
1139
+ const name = `${datum.label}: ${summary}`
1140
+ const position: CSSProperties = vertical
1141
+ ? {
1142
+ left: `${di * bands.step}%`,
1143
+ width: `${bands.step}%`,
1144
+ top: 0,
1145
+ bottom: 0,
1146
+ }
1147
+ : {
1148
+ top: `${di * bands.step}%`,
1149
+ height: `${bands.step}%`,
1150
+ left: 0,
1151
+ right: 0,
1152
+ }
1153
+ const shared = {
1154
+ className: cn(
1155
+ 'absolute bg-transparent',
1156
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
1157
+ ),
1158
+ style: { ...position, borderRadius: 'var(--radius-sm)' },
1159
+ onMouseEnter: () => activate(di),
1160
+ onMouseLeave: () => activate(null),
1161
+ onFocus: () => activate(di),
1162
+ onBlur: () => activate(null),
1163
+ }
1164
+ return onSelect !== undefined ? (
1165
+ <button
1166
+ key={datum.key}
1167
+ {...shared}
1168
+ type="button"
1169
+ aria-label={name}
1170
+ // Only a chart that models selection is a toggle. A bare
1171
+ // drill-down button announcing "not pressed" on every band
1172
+ // is noise, so the attribute appears only when the caller
1173
+ // passes `selectedKey` at all.
1174
+ {...(selectedKey !== undefined && {
1175
+ 'aria-pressed': selectedKey === datum.key,
1176
+ })}
1177
+ className={cn(shared.className, 'cursor-pointer')}
1178
+ onClick={() => onSelect(datum.key)}
1179
+ />
1180
+ ) : (
1181
+ // Not a button: clicking does nothing, and a control that
1182
+ // advertises an action it does not have is worse than none.
1183
+ // It stays focusable because focus is what reveals the
1184
+ // readout for a keyboard user; the accessible name carries
1185
+ // the datum either way, and ChartFrame's table view is the
1186
+ // full accessible representation (D10.5).
1187
+ <div
1188
+ key={datum.key}
1189
+ {...shared}
1190
+ role="img"
1191
+ tabIndex={0}
1192
+ aria-label={name}
1193
+ />
1194
+ )
1195
+ })}
1196
+ </div>
1197
+ )}
1198
+
1199
+ {readoutDatum !== undefined && count > 0 && !tooDense && !allZero && (
1200
+ <ChartTooltip
1201
+ x={anchorX}
1202
+ y={anchorY}
1203
+ plotWidth={metrics.plotWidth}
1204
+ plotHeight={metrics.plotHeight}
1205
+ title={readoutDatum.label}
1206
+ rows={tooltipRows}
1207
+ palette={palette}
1208
+ visible={hovered !== null}
1209
+ />
1210
+ )}
1211
+ </div>
1212
+ </ChartFrame>
1213
+ )
1214
+ }