@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,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
|
+
}
|