@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,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DonutChart — arc segments plus a divided legend list.
|
|
3
|
+
*
|
|
4
|
+
* Promoted in ADR-146 D5 (chart layer). It consumes `arcSegments` from
|
|
5
|
+
* `lib/chart.ts` rather than writing a fourth copy of the cumulative
|
|
6
|
+
* dasharray accumulator that `spec/shell-charts.tsx:63-91` and
|
|
7
|
+
* `completion-ring.tsx:58-64` already write (ADR-146 D6).
|
|
8
|
+
*
|
|
9
|
+
* CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
|
|
10
|
+
* `apps/workspace/src`; the design-system gallery is a demo harness and does
|
|
11
|
+
* NOT count toward the ADR-008 D3 gate).
|
|
12
|
+
* TODAY (0 — gate NOT MET).
|
|
13
|
+
* PLANNED: an admin usage-mix panel (`components/ui/admin/analytics.tsx`
|
|
14
|
+
* renders that share as `RankedBars` today) and a discovery traffic-source
|
|
15
|
+
* panel. Neither is written. Tracked in
|
|
16
|
+
* `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
|
|
17
|
+
*
|
|
18
|
+
* ANATOMY (Metoric spec §6.3, real measurements)
|
|
19
|
+
* ----------------------------------------------
|
|
20
|
+
* plot square, 150-160px, stroke-based arcs on ONE centreline circle
|
|
21
|
+
* gap 2px of the SURFACE colour between adjacent segments, so the
|
|
22
|
+
* ring reads as separate marks rather than one continuous band
|
|
23
|
+
* centre optional: value large and **set solid** (line-height 1 — the
|
|
24
|
+
* kit's own rule, and what stops a metric looking loose), a
|
|
25
|
+
* muted caption below it, both `tabular-nums`
|
|
26
|
+
* legend swatch + label + value, 1px dividers between rows,
|
|
27
|
+
* `--space-4` vertical padding, and the **percentage inside the
|
|
28
|
+
* label string** ("Website (50%)") rather than in a third
|
|
29
|
+
* column. That is the kit's choice and it is the right one: a
|
|
30
|
+
* third column makes the row scan as a table, not a key.
|
|
31
|
+
*
|
|
32
|
+
* WHAT THIS PRIMITIVE DELIBERATELY DOES NOT DO
|
|
33
|
+
* --------------------------------------------
|
|
34
|
+
* It does **not** compose `ChartFrame`. A donut has no axis, no gridlines and
|
|
35
|
+
* no baseline, and `ChartFrame` always draws its darker `--chart-axis`
|
|
36
|
+
* baseline under the plot box — correct for every other chart in the set and
|
|
37
|
+
* wrong under a ring. Its plot box is also a single full-width column, which
|
|
38
|
+
* cannot hold the kit's plot-beside-legend layout. So `DonutChart` renders
|
|
39
|
+
* the plot and its legend only, and a caller who wants a titled panel with a
|
|
40
|
+
* table view wraps it in a `Card`. (`chart-frame.tsx`'s header currently
|
|
41
|
+
* lists `DonutChart` among its consumers; that line is stale — logged as a
|
|
42
|
+
* follow-up rather than edited here, since another phase owns that file.)
|
|
43
|
+
*
|
|
44
|
+
* THE SERIES CAP (ADR-146 D2) — WHY THE FOLD IS A FUNCTION
|
|
45
|
+
* --------------------------------------------------------
|
|
46
|
+
* Beyond eight entities the remainder folds into ONE "Other" segment in a
|
|
47
|
+
* neutral grey. We never generate a ninth hue: two entities drawn in one
|
|
48
|
+
* colour is a wrong chart that no gate can see. The fold is `foldDonutData`,
|
|
49
|
+
* exported and pure, so the folded count is available to a caller ("and 4
|
|
50
|
+
* more") without re-deriving it, and so the cap is unit-testable without a
|
|
51
|
+
* DOM. It is also visible in the legend, as a `meta` line under the "Other"
|
|
52
|
+
* row — a silent fold would be a data loss the reader cannot detect.
|
|
53
|
+
*
|
|
54
|
+
* Colour follows the ENTITY, never its rank (D7). Slot defaults to the
|
|
55
|
+
* datum's position in `data`, and a datum may pin its own `slot` so a filter
|
|
56
|
+
* that removes a series cannot repaint the survivors.
|
|
57
|
+
*
|
|
58
|
+
* EDGE CASES, ALL DELIBERATE
|
|
59
|
+
* --------------------------
|
|
60
|
+
* - **One category** → a single complete ring, and no separators. A lone cut
|
|
61
|
+
* in a full ring reads as a rendering fault.
|
|
62
|
+
* - **All values zero (or empty `data`)** → the track ring paints in `--muted`
|
|
63
|
+
* and the legend is replaced by `emptyMessage`. `arcSegments` returns
|
|
64
|
+
* fractions of `0` here rather than dividing by zero, and it does not
|
|
65
|
+
* split the ring evenly, which would invent data that is not there.
|
|
66
|
+
* - **A slice under 1%** → it still gets a **minimum visible arc**
|
|
67
|
+
* (`minSliceFraction`, default `0.012` ≈ 4.3°). Without this a 0.3% slice
|
|
68
|
+
* is a sub-pixel dash: present in the legend, invisible in the ring, which
|
|
69
|
+
* reads as a bug. The lift is taken proportionally out of the slices that
|
|
70
|
+
* are above the floor, so the ring still closes exactly. **The legend
|
|
71
|
+
* percentage is always the TRUE fraction** — the lift is a drawing
|
|
72
|
+
* correction and must never leak into a number the reader compares.
|
|
73
|
+
*
|
|
74
|
+
* TWO NARROW-WIDTH DEFECTS, FOUND BY MEASURING A BROWSER (fixed 2026-09-06)
|
|
75
|
+
* -------------------------------------------------------------------------
|
|
76
|
+
* Both were measured at a 390px viewport in BOTH themes, and neither is
|
|
77
|
+
* visible to a jsdom test — the source read correctly in both cases.
|
|
78
|
+
*
|
|
79
|
+
* 1. **The legend labels collapsed to zero width.** The plot is `shrink-0`,
|
|
80
|
+
* so on a 164px-wide root it kept all 160px and the `flex-1` legend beside
|
|
81
|
+
* it got 0. Every label rendered at `width: 0` (`scrollWidth` 73-124),
|
|
82
|
+
* leaving swatch + value and five series told apart by hue alone. The row
|
|
83
|
+
* now wraps (`flex-wrap` + `LEGEND_BASIS`): below ~372px the legend moves
|
|
84
|
+
* UNDER the plot at full width instead of being starved.
|
|
85
|
+
*
|
|
86
|
+
* 2. **The centre readout overflowed the hole and painted on the arcs.** It
|
|
87
|
+
* was `inset-0` on the whole 160px plot box, so "$116,120" spanned x
|
|
88
|
+
* 96-212 across a hole of x 100-208 — the "$" on one arc, the trailing "0"
|
|
89
|
+
* on another. It is now sized to the hole (inner diameter at the RAISED
|
|
90
|
+
* stroke, less `--space-1`), and its type is scaled once, from a measured
|
|
91
|
+
* gauge, against the WIDEST string the slot can ever hold — the resting
|
|
92
|
+
* total and every value a hover can swap in. That is what makes the slot
|
|
93
|
+
* genuinely reserved: hovering changes the string, never the size.
|
|
94
|
+
*
|
|
95
|
+
* Token discipline: every colour is `rgb(var(--token))`. Series hues resolve
|
|
96
|
+
* through `slotColor` and the frozen `SERIES` lookup in `lib/series.ts` (D8 —
|
|
97
|
+
* never a constructed `--series-${i}`), which is also what makes the arc and
|
|
98
|
+
* its legend swatch the same colour by construction. The "Other" fold takes
|
|
99
|
+
* that module's shared `OTHER_SLOT_COLOR` neutral for the same reason. Every
|
|
100
|
+
* spacing, radius, duration and easing is a token. The only bare numbers are
|
|
101
|
+
* SVG geometry — a viewBox cannot read a CSS custom property — and each one
|
|
102
|
+
* is named.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
import { useEffect, useRef, useState } from 'react'
|
|
106
|
+
|
|
107
|
+
import { ChartLegend, type ChartLegendItem } from './chart-legend'
|
|
108
|
+
import { ChartTooltip } from './chart-tooltip'
|
|
109
|
+
import { formatNumber, formatPercent } from './format'
|
|
110
|
+
import { arcGeometry, arcSegments } from './lib/chart'
|
|
111
|
+
import {
|
|
112
|
+
SERIES_SLOT_COUNT,
|
|
113
|
+
isSeriesSlot,
|
|
114
|
+
slotColor,
|
|
115
|
+
type ChartSlot,
|
|
116
|
+
type SeriesPalette,
|
|
117
|
+
type SeriesSlot,
|
|
118
|
+
} from './lib/series'
|
|
119
|
+
import { cn } from './lib/utils'
|
|
120
|
+
|
|
121
|
+
/* ── geometry constants ──────────────────────────────────────────────────
|
|
122
|
+
* Pixel arithmetic in the SVG's own coordinate space. These cannot be
|
|
123
|
+
* tokens: a `viewBox` is unitless and cannot resolve `var()`.
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/** Extra stroke a hovered/focused segment gains, in viewBox units. */
|
|
127
|
+
const HOVER_BUMP = 4
|
|
128
|
+
/** Extra stroke the focus halo gains over the base stroke. */
|
|
129
|
+
const FOCUS_HALO = 8
|
|
130
|
+
/**
|
|
131
|
+
* Half the widest extra stroke, reserved inside the square so a raised or
|
|
132
|
+
* focused segment cannot paint outside the plot box. This is the layout-
|
|
133
|
+
* stability rule applied to a ring: the box never grows, the mark does.
|
|
134
|
+
*/
|
|
135
|
+
const EDGE_RESERVE = FOCUS_HALO / 2
|
|
136
|
+
/** Stroke width of the surface-coloured cut drawn between adjacent segments. */
|
|
137
|
+
const SEGMENT_GAP = 2
|
|
138
|
+
/** Opacity of a non-active segment while another one is active. Matches
|
|
139
|
+
* `ChartLegend`'s row dim, so plot and legend fade together. */
|
|
140
|
+
const DIM_OPACITY = 0.55
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Base type size of the centre value, in px, before the fit scale below is
|
|
144
|
+
* applied. On the type ramp (24 / 18 / 16 / 14 / 12); it is a number rather
|
|
145
|
+
* than a `text-[24px]` class because the fit multiplies it.
|
|
146
|
+
*/
|
|
147
|
+
const CENTER_VALUE_FONT_PX = 24
|
|
148
|
+
/**
|
|
149
|
+
* Floor for the centre fit. Below this the readout would be smaller than the
|
|
150
|
+
* caption under it and the hierarchy would invert; no plausible formatted
|
|
151
|
+
* value reaches it (at 12px a tabular string clears a 94px hole up to ~15
|
|
152
|
+
* characters).
|
|
153
|
+
*/
|
|
154
|
+
const CENTER_MIN_SCALE = 0.5
|
|
155
|
+
/** Base type size of the centre caption, in px, before its own fit scale. */
|
|
156
|
+
const CENTER_CAPTION_FONT_PX = 12
|
|
157
|
+
/**
|
|
158
|
+
* Floor for the caption fit. A caption is prose and may ellipsis as a last
|
|
159
|
+
* resort; it must not shrink past legibility to avoid one.
|
|
160
|
+
*/
|
|
161
|
+
const CAPTION_MIN_SCALE = 0.8
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Flex basis of the legend column in the `row` layout. Below this the legend
|
|
165
|
+
* WRAPS under the plot instead of being starved.
|
|
166
|
+
*
|
|
167
|
+
* Measured 2026-09-06, light and dark, 390px viewport: the plot is
|
|
168
|
+
* `shrink-0` at `size` px, so on a 164px-wide root it kept all 160px and the
|
|
169
|
+
* `flex-1` legend beside it was allotted 0 — every label rendered at
|
|
170
|
+
* `width: 0` against a `scrollWidth` of 73-124px, leaving five series
|
|
171
|
+
* identified by colour alone (ADR-146 D10.1). `flex-wrap` plus this basis is
|
|
172
|
+
* what makes the row reflow instead.
|
|
173
|
+
*
|
|
174
|
+
* Token-derived, and sized from the legend row it has to hold: swatch
|
|
175
|
+
* (`--space-3`) + two `--space-3` gaps + a readable label + the reserved
|
|
176
|
+
* value column (`--space-12`).
|
|
177
|
+
*/
|
|
178
|
+
const LEGEND_BASIS = 'calc(var(--space-12) * 4)'
|
|
179
|
+
|
|
180
|
+
/** The synthetic key of the folded "Other" slice. */
|
|
181
|
+
export const DONUT_OTHER_KEY = 'donut:other'
|
|
182
|
+
|
|
183
|
+
/** Which surface the inter-segment gap is cut in. */
|
|
184
|
+
export type DonutGapSurface = 'card' | 'frame' | 'none'
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Frozen literal lookup, the same shape as `metric-card.tsx`'s `TONE_COLOR`.
|
|
188
|
+
* The gap has to be painted in whatever surface the donut actually sits on,
|
|
189
|
+
* and a caller cannot be trusted with a raw colour — so it picks a surface by
|
|
190
|
+
* name and the token is resolved here.
|
|
191
|
+
*/
|
|
192
|
+
const GAP_COLOR: Record<Exclude<DonutGapSurface, 'none'>, string> = {
|
|
193
|
+
card: 'rgb(var(--surface-card))',
|
|
194
|
+
frame: 'rgb(var(--surface-frame))',
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface DonutChartDatum {
|
|
198
|
+
/**
|
|
199
|
+
* Stable identity key. Also the React key, the `onSelect` payload, and —
|
|
200
|
+
* per ADR-146 D7 — what colour follows. Keep it stable across filters.
|
|
201
|
+
*/
|
|
202
|
+
key: string
|
|
203
|
+
/** Category name. Rendered in the legend and the segment's accessible
|
|
204
|
+
* name; identity is never colour alone (D10.1). */
|
|
205
|
+
label: string
|
|
206
|
+
/** The measure. Negative and non-finite values read as `0` — an arc
|
|
207
|
+
* cannot sweep backwards, and a share chart with negative shares is a
|
|
208
|
+
* different chart. */
|
|
209
|
+
value: number
|
|
210
|
+
/**
|
|
211
|
+
* Pin this entity's palette slot. Omit and the slot is the datum's
|
|
212
|
+
* position in `data`. Pass it when the same entity must keep its hue
|
|
213
|
+
* across a filter that changes its position (D7 — colour follows the
|
|
214
|
+
* entity, never its rank).
|
|
215
|
+
*/
|
|
216
|
+
slot?: SeriesSlot
|
|
217
|
+
/** Optional muted second line under the legend label. */
|
|
218
|
+
meta?: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One rendered slice: a datum, or the folded "Other" remainder. */
|
|
222
|
+
export interface DonutSlice {
|
|
223
|
+
readonly key: string
|
|
224
|
+
readonly label: string
|
|
225
|
+
readonly value: number
|
|
226
|
+
readonly slot: ChartSlot
|
|
227
|
+
readonly meta: string | undefined
|
|
228
|
+
/** `0` for a real entity; the number of categories folded in for "Other". */
|
|
229
|
+
readonly foldedCount: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The result of applying the ADR-146 D2 series cap. */
|
|
233
|
+
export interface DonutFold {
|
|
234
|
+
/** Slices to draw, in order. At most `maxSlices` long. */
|
|
235
|
+
readonly slices: readonly DonutSlice[]
|
|
236
|
+
/** How many categories were folded into "Other". `0` when nothing folded. */
|
|
237
|
+
readonly foldedCount: number
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Apply the series cap: keep the first `maxSlices - 1` categories and fold
|
|
242
|
+
* every remaining one into a single neutral "Other" slice (ADR-146 D2).
|
|
243
|
+
*
|
|
244
|
+
* Exported and pure so a caller can read `foldedCount` without a DOM, and so
|
|
245
|
+
* the cap is testable on its own. `maxSlices` is clamped into
|
|
246
|
+
* `1..SERIES_SLOT_COUNT`: a cap above eight would ask the palette for a hue
|
|
247
|
+
* it does not have, and a cap below one has no meaning.
|
|
248
|
+
*
|
|
249
|
+
* Input order is preserved, and a datum's own `slot` always wins over its
|
|
250
|
+
* position.
|
|
251
|
+
*/
|
|
252
|
+
export function foldDonutData(
|
|
253
|
+
data: readonly DonutChartDatum[],
|
|
254
|
+
maxSlices: number = SERIES_SLOT_COUNT,
|
|
255
|
+
otherLabel = 'Other',
|
|
256
|
+
): DonutFold {
|
|
257
|
+
const cap = Number.isFinite(maxSlices)
|
|
258
|
+
? Math.max(1, Math.min(Math.floor(maxSlices), SERIES_SLOT_COUNT))
|
|
259
|
+
: SERIES_SLOT_COUNT
|
|
260
|
+
|
|
261
|
+
const toSlice = (datum: DonutChartDatum, index: number): DonutSlice => ({
|
|
262
|
+
key: datum.key,
|
|
263
|
+
label: datum.label,
|
|
264
|
+
value: datum.value,
|
|
265
|
+
slot: datum.slot ?? (isSeriesSlot(index) ? index : 'other'),
|
|
266
|
+
meta: datum.meta,
|
|
267
|
+
foldedCount: 0,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (data.length <= cap) {
|
|
271
|
+
return { slices: data.map(toSlice), foldedCount: 0 }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const kept = data.slice(0, cap - 1).map(toSlice)
|
|
275
|
+
const folded = data.slice(cap - 1)
|
|
276
|
+
let total = 0
|
|
277
|
+
for (const datum of folded) {
|
|
278
|
+
// Same clamp `arcSegments` applies, so the "Other" arc and the "Other"
|
|
279
|
+
// value can never disagree.
|
|
280
|
+
if (Number.isFinite(datum.value) && datum.value > 0) total += datum.value
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
slices: [
|
|
285
|
+
...kept,
|
|
286
|
+
{
|
|
287
|
+
key: DONUT_OTHER_KEY,
|
|
288
|
+
label: otherLabel,
|
|
289
|
+
value: total,
|
|
290
|
+
slot: 'other',
|
|
291
|
+
meta: undefined,
|
|
292
|
+
foldedCount: folded.length,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
foldedCount: folded.length,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Lift every non-zero fraction to at least `minFraction`, taking the lift
|
|
301
|
+
* proportionally out of the slices that sit above the floor so the total
|
|
302
|
+
* stays exactly `1` and the ring still closes.
|
|
303
|
+
*
|
|
304
|
+
* Exported for tests: "a 0.3% slice is still visible AND the ring is still
|
|
305
|
+
* full" is the contract, and it is far easier to assert as arithmetic than as
|
|
306
|
+
* a rendered dasharray.
|
|
307
|
+
*
|
|
308
|
+
* The floor is itself capped at `1 / n`, so a chart of many tiny slices
|
|
309
|
+
* degrades to an even split rather than demanding more ring than exists.
|
|
310
|
+
*/
|
|
311
|
+
export function withMinimumArc(
|
|
312
|
+
fractions: readonly number[],
|
|
313
|
+
minFraction: number,
|
|
314
|
+
): number[] {
|
|
315
|
+
const positives = fractions.filter((f) => f > 0).length
|
|
316
|
+
if (positives === 0) return fractions.map(() => 0)
|
|
317
|
+
if (!Number.isFinite(minFraction) || minFraction <= 0) return [...fractions]
|
|
318
|
+
|
|
319
|
+
const floor = Math.min(minFraction, 1 / positives)
|
|
320
|
+
const lifted = fractions.map((f) => (f > 0 && f < floor ? floor : f))
|
|
321
|
+
|
|
322
|
+
let total = 0
|
|
323
|
+
for (const f of lifted) total += f
|
|
324
|
+
const deficit = total - 1
|
|
325
|
+
if (deficit <= 0) return lifted
|
|
326
|
+
|
|
327
|
+
let surplus = 0
|
|
328
|
+
for (const f of lifted) if (f > floor) surplus += f - floor
|
|
329
|
+
if (surplus <= 0) return lifted.map((f) => (total > 0 ? f / total : 0))
|
|
330
|
+
|
|
331
|
+
const ratio = Math.min(1, deficit / surplus)
|
|
332
|
+
return lifted.map((f) => (f > floor ? f - (f - floor) * ratio : f))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface DonutChartProps {
|
|
336
|
+
/** The categories, in draw order (twelve o'clock, clockwise). */
|
|
337
|
+
data: readonly DonutChartDatum[]
|
|
338
|
+
/**
|
|
339
|
+
* Coloured slices before the "Other" fold. Clamped to `1..8`; default `8`
|
|
340
|
+
* (ADR-146 D2 — the palette has eight slots and never generates a ninth).
|
|
341
|
+
*/
|
|
342
|
+
maxSlices?: number
|
|
343
|
+
/** Label for the folded slice. Default `'Other'`. */
|
|
344
|
+
otherLabel?: string
|
|
345
|
+
/**
|
|
346
|
+
* Meta line under the "Other" legend row, so the fold is visible rather
|
|
347
|
+
* than silent. Receives the folded count. Default
|
|
348
|
+
* `` (n) => `${n} smaller categories` ``.
|
|
349
|
+
*/
|
|
350
|
+
otherMeta?: (foldedCount: number) => string
|
|
351
|
+
/** Plot edge in px. Default `160` — the kit's donut is 150-160. */
|
|
352
|
+
size?: number
|
|
353
|
+
/** Ring stroke in px. Default `size / 6`, floored at 6. */
|
|
354
|
+
thickness?: number
|
|
355
|
+
/**
|
|
356
|
+
* The smallest fraction of the ring any non-zero slice may occupy, so a
|
|
357
|
+
* sub-1% category is still a visible mark. Default `0.012` (≈4.3°). Set
|
|
358
|
+
* `0` to draw true fractions only. Never affects the legend percentage.
|
|
359
|
+
*/
|
|
360
|
+
minSliceFraction?: number
|
|
361
|
+
/** Which palette the slots index into. Default `'default'`. */
|
|
362
|
+
palette?: SeriesPalette
|
|
363
|
+
/**
|
|
364
|
+
* Surface the 2px inter-segment gap is cut in — it has to match whatever
|
|
365
|
+
* the donut sits on. Default `'card'`; `'none'` draws one continuous ring.
|
|
366
|
+
*/
|
|
367
|
+
gapSurface?: DonutGapSurface
|
|
368
|
+
/**
|
|
369
|
+
* Formats a value for the legend, the tooltip and the centre. Default
|
|
370
|
+
* `formatNumber`. Format through `@lovett/ui/format`.
|
|
371
|
+
*/
|
|
372
|
+
formatValue?: (value: number) => string
|
|
373
|
+
/** Decimal places on the legend/tooltip percentage. Default `0`. */
|
|
374
|
+
percentDecimals?: number
|
|
375
|
+
/**
|
|
376
|
+
* Centre value when nothing is hovered — usually the total. Omit for a
|
|
377
|
+
* plain ring. While a segment is active the centre shows THAT segment
|
|
378
|
+
* instead, which is the whole reason the slot is reserved rather than
|
|
379
|
+
* conditionally rendered.
|
|
380
|
+
*/
|
|
381
|
+
centerValue?: string
|
|
382
|
+
/** Muted caption under the centre value. Often the measure's name. */
|
|
383
|
+
centerCaption?: string
|
|
384
|
+
/** Renders the legend. Default `true`. */
|
|
385
|
+
showLegend?: boolean
|
|
386
|
+
/** Legend beside the plot (`'row'`, default) or under it (`'column'`). */
|
|
387
|
+
orientation?: 'row' | 'column'
|
|
388
|
+
/** Reserved width of the legend's value column. Default `var(--space-12)`. */
|
|
389
|
+
legendValueMinWidth?: string
|
|
390
|
+
/**
|
|
391
|
+
* Controlled active (hovered/focused) key. Omit for uncontrolled. `null`
|
|
392
|
+
* means nothing is active.
|
|
393
|
+
*/
|
|
394
|
+
activeKey?: string | null
|
|
395
|
+
/** Fires whenever the active segment changes, controlled or not. */
|
|
396
|
+
onActiveChange?: (key: string | null) => void
|
|
397
|
+
/**
|
|
398
|
+
* Makes segments and legend rows activatable. Receives the slice key —
|
|
399
|
+
* `DONUT_OTHER_KEY` for the fold. Omit for a read-only chart: an inert
|
|
400
|
+
* mark must not advertise an interaction it does not have.
|
|
401
|
+
*/
|
|
402
|
+
onSelect?: (key: string) => void
|
|
403
|
+
/** Shown in place of the legend when nothing has a positive value. */
|
|
404
|
+
emptyMessage?: string
|
|
405
|
+
/** Accessible name for the plot. Default `'Donut chart'`. */
|
|
406
|
+
label?: string
|
|
407
|
+
/** Optional className on the outer container. */
|
|
408
|
+
className?: string
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function DonutChart({
|
|
412
|
+
data,
|
|
413
|
+
maxSlices = SERIES_SLOT_COUNT,
|
|
414
|
+
otherLabel = 'Other',
|
|
415
|
+
otherMeta = (count) => `${count} smaller categories`,
|
|
416
|
+
size = 160,
|
|
417
|
+
thickness,
|
|
418
|
+
minSliceFraction = 0.012,
|
|
419
|
+
palette = 'default',
|
|
420
|
+
gapSurface = 'card',
|
|
421
|
+
formatValue = (value) => formatNumber(value),
|
|
422
|
+
percentDecimals = 0,
|
|
423
|
+
centerValue,
|
|
424
|
+
centerCaption,
|
|
425
|
+
showLegend = true,
|
|
426
|
+
orientation = 'row',
|
|
427
|
+
legendValueMinWidth = 'var(--space-12)',
|
|
428
|
+
activeKey,
|
|
429
|
+
onActiveChange,
|
|
430
|
+
onSelect,
|
|
431
|
+
emptyMessage = 'No data to chart.',
|
|
432
|
+
label = 'Donut chart',
|
|
433
|
+
className,
|
|
434
|
+
}: DonutChartProps) {
|
|
435
|
+
const [internalActive, setInternalActive] = useState<string | null>(null)
|
|
436
|
+
const controlled = activeKey !== undefined
|
|
437
|
+
const active = controlled ? activeKey : internalActive
|
|
438
|
+
|
|
439
|
+
function setActive(next: string | null) {
|
|
440
|
+
if (!controlled) setInternalActive(next)
|
|
441
|
+
onActiveChange?.(next)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* ── geometry ─────────────────────────────────────────────────────── */
|
|
445
|
+
|
|
446
|
+
const box = Math.max(0, size)
|
|
447
|
+
const stroke = Math.max(6, thickness ?? Math.round(box / 6))
|
|
448
|
+
// The raised/focused stroke has to fit, so the ring is inset by half the
|
|
449
|
+
// widest extra stroke. The box never changes size; the mark does.
|
|
450
|
+
const outerRadius = Math.max(0, box / 2 - EDGE_RESERVE)
|
|
451
|
+
const { radius } = arcGeometry(outerRadius, stroke)
|
|
452
|
+
const center = box / 2
|
|
453
|
+
|
|
454
|
+
const { slices, foldedCount } = foldDonutData(data, maxSlices, otherLabel)
|
|
455
|
+
|
|
456
|
+
// TRUE fractions — what the legend and the tooltip report. `arcSegments`
|
|
457
|
+
// clamps negatives to 0 and returns 0 for every slice when the total is 0,
|
|
458
|
+
// so there is no divide-by-zero to guard here.
|
|
459
|
+
const trueSegments = arcSegments(
|
|
460
|
+
slices.map((slice) => slice.value),
|
|
461
|
+
outerRadius,
|
|
462
|
+
stroke,
|
|
463
|
+
)
|
|
464
|
+
const positiveCount = trueSegments.filter((seg) => seg.fraction > 0).length
|
|
465
|
+
const isEmpty = positiveCount === 0
|
|
466
|
+
|
|
467
|
+
// DISPLAY fractions — the same shares with a minimum visible arc applied.
|
|
468
|
+
// Drawn geometry only; never reported as a number.
|
|
469
|
+
const drawnSegments = arcSegments(
|
|
470
|
+
withMinimumArc(
|
|
471
|
+
trueSegments.map((seg) => seg.fraction),
|
|
472
|
+
minSliceFraction,
|
|
473
|
+
),
|
|
474
|
+
outerRadius,
|
|
475
|
+
stroke,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
/* ── the plot's rendered width, for tooltip anchoring ─────────────── */
|
|
479
|
+
|
|
480
|
+
const plotRef = useRef<HTMLDivElement>(null)
|
|
481
|
+
const [plotPx, setPlotPx] = useState(box)
|
|
482
|
+
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
const el = plotRef.current
|
|
485
|
+
if (el === null) return
|
|
486
|
+
|
|
487
|
+
function measure() {
|
|
488
|
+
if (el === null) return
|
|
489
|
+
const width = el.getBoundingClientRect().width
|
|
490
|
+
setPlotPx(width > 0 ? width : box)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
measure()
|
|
494
|
+
// jsdom ships no ResizeObserver (the suite stubs a no-op one), and a
|
|
495
|
+
// fixed-size donut never resizes — the measurement is only here so the
|
|
496
|
+
// tooltip still lands on its segment when the plot shrinks under
|
|
497
|
+
// `max-width: 100%` on a narrow viewport.
|
|
498
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
499
|
+
const observer = new ResizeObserver(measure)
|
|
500
|
+
observer.observe(el)
|
|
501
|
+
return () => observer.disconnect()
|
|
502
|
+
}, [box])
|
|
503
|
+
|
|
504
|
+
const scale = box > 0 ? plotPx / box : 1
|
|
505
|
+
|
|
506
|
+
/* ── the hole, and the centre readout that has to fit inside it ─────── */
|
|
507
|
+
|
|
508
|
+
// The hole is the ring's INNER diameter, and it is measured against the
|
|
509
|
+
// RAISED stroke, not the resting one: a hovered segment grows inward by
|
|
510
|
+
// HOVER_BUMP / 2, so a reserve computed from the resting ring would let the
|
|
511
|
+
// centre collide with an arc at exactly the moment a value is being read
|
|
512
|
+
// off it. Using the raised figure makes the reserve constant in every
|
|
513
|
+
// state, which is the point of reserving it at all.
|
|
514
|
+
//
|
|
515
|
+
// Measured 2026-09-06 at 390px, before this: the plot spanned x 74-234 with
|
|
516
|
+
// a hole of x 100-208, while "$116,120" spanned x 96-212 — the leading "$"
|
|
517
|
+
// sat on the violet arc and the trailing "0" on the blue one, because the
|
|
518
|
+
// readout was sized `inset-0` against the whole 160px plot box.
|
|
519
|
+
const holePx = Math.max(0, 2 * radius - stroke - HOVER_BUMP) * scale
|
|
520
|
+
|
|
521
|
+
/* ── derived text ─────────────────────────────────────────────────── */
|
|
522
|
+
|
|
523
|
+
// formatPercent takes PERCENT UNITS, not a 0-1 fraction (ADR-146 D11).
|
|
524
|
+
const zeroPercent = formatPercent(0, { decimals: percentDecimals })
|
|
525
|
+
const percentOf = (fraction: number) => {
|
|
526
|
+
const pct = fraction * 100
|
|
527
|
+
const text = formatPercent(pct, { decimals: percentDecimals })
|
|
528
|
+
// A slice that is DRAWN — it cleared `minSliceFraction`, so the reader can
|
|
529
|
+
// see it — but whose share rounds to "0%" reads as a rendering fault: a
|
|
530
|
+
// visible arc labelled as nothing. Report the true share as "<1%" (or
|
|
531
|
+
// "<0.1%" at one decimal) instead. Still true, never rounded UP into a
|
|
532
|
+
// share the slice does not have, and it is the number the reader compares.
|
|
533
|
+
if (pct > 0 && text === zeroPercent) {
|
|
534
|
+
const smallest = 10 ** -percentDecimals
|
|
535
|
+
return `<${formatPercent(smallest, { decimals: percentDecimals })}`
|
|
536
|
+
}
|
|
537
|
+
return text
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const activeIndex = active === null
|
|
541
|
+
? -1
|
|
542
|
+
: slices.findIndex((slice) => slice.key === active)
|
|
543
|
+
const activeSlice = activeIndex >= 0 ? slices[activeIndex] : undefined
|
|
544
|
+
const activeFraction = trueSegments[activeIndex]?.fraction ?? 0
|
|
545
|
+
// A controlled `activeKey` can outlive the datum it names — a filter drops
|
|
546
|
+
// the series while it is hovered. Resolve emphasis through the slice we
|
|
547
|
+
// actually found, so a stale key dims nothing rather than dimming the whole
|
|
548
|
+
// chart and its legend with no segment raised to explain why.
|
|
549
|
+
const resolvedActive = activeSlice !== undefined ? active : null
|
|
550
|
+
|
|
551
|
+
const legendItems: ChartLegendItem[] = slices.map((slice, index) => {
|
|
552
|
+
const fraction = trueSegments[index]?.fraction ?? 0
|
|
553
|
+
const meta =
|
|
554
|
+
slice.foldedCount > 0 ? otherMeta(slice.foldedCount) : slice.meta
|
|
555
|
+
return {
|
|
556
|
+
key: slice.key,
|
|
557
|
+
label: `${slice.label} (${percentOf(fraction)})`,
|
|
558
|
+
slot: slice.slot,
|
|
559
|
+
value: formatValue(slice.value),
|
|
560
|
+
...(meta !== undefined ? { meta } : {}),
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const showCenter = centerValue !== undefined || activeSlice !== undefined
|
|
565
|
+
const centerMain =
|
|
566
|
+
activeSlice !== undefined ? formatValue(activeSlice.value) : centerValue
|
|
567
|
+
const centerSub =
|
|
568
|
+
activeSlice !== undefined ? activeSlice.label : centerCaption
|
|
569
|
+
|
|
570
|
+
/* ── centre fit ───────────────────────────────────────────────────────
|
|
571
|
+
* Every string the centre slot can EVER show: the resting value plus the
|
|
572
|
+
* value of every slice a hover can swap in. The type size is chosen from
|
|
573
|
+
* the widest of them and then never changes, which is what makes the slot
|
|
574
|
+
* genuinely reserved — hovering swaps the string, never the size.
|
|
575
|
+
*/
|
|
576
|
+
|
|
577
|
+
const valueSet = new Set<string>()
|
|
578
|
+
if (centerValue !== undefined && centerValue !== '') valueSet.add(centerValue)
|
|
579
|
+
for (const slice of slices) {
|
|
580
|
+
const text = formatValue(slice.value)
|
|
581
|
+
if (text !== '') valueSet.add(text)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const captionSet = new Set<string>()
|
|
585
|
+
if (centerCaption !== undefined && centerCaption !== '') {
|
|
586
|
+
captionSet.add(centerCaption)
|
|
587
|
+
}
|
|
588
|
+
for (const slice of slices) {
|
|
589
|
+
if (slice.label !== '') captionSet.add(slice.label)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// The candidates travel into the effect as ONE string rather than an array:
|
|
593
|
+
// an array literal is a new identity on every render and would re-run the
|
|
594
|
+
// effect forever, and a \u0000-joined key is both the dependency and the
|
|
595
|
+
// payload. (No formatter can emit a NUL, so the split is lossless.)
|
|
596
|
+
const valueKey = [...valueSet].join('\u0000')
|
|
597
|
+
const captionKey = [...captionSet].join('\u0000')
|
|
598
|
+
|
|
599
|
+
const fitRef = useRef<HTMLDivElement>(null)
|
|
600
|
+
const valueGaugeRef = useRef<HTMLSpanElement>(null)
|
|
601
|
+
const captionGaugeRef = useRef<HTMLSpanElement>(null)
|
|
602
|
+
const [centerScale, setCenterScale] = useState(1)
|
|
603
|
+
const [captionScale, setCaptionScale] = useState(1)
|
|
604
|
+
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
const fit = fitRef.current
|
|
607
|
+
if (fit === null) return
|
|
608
|
+
|
|
609
|
+
// The gauges are rendered EMPTY and filled one candidate at a time here,
|
|
610
|
+
// rather than rendering every candidate as its own hidden node. Hidden
|
|
611
|
+
// duplicates of the value would put the same string in the DOM twice,
|
|
612
|
+
// which is a trap for anything that reads the tree by text — a test, a
|
|
613
|
+
// find-in-page, a scraper — for no benefit.
|
|
614
|
+
function widestOf(gauge: HTMLSpanElement | null, key: string): number {
|
|
615
|
+
if (gauge === null || key === '') return 0
|
|
616
|
+
let widest = 0
|
|
617
|
+
for (const candidate of key.split('\u0000')) {
|
|
618
|
+
gauge.textContent = candidate
|
|
619
|
+
widest = Math.max(widest, gauge.getBoundingClientRect().width)
|
|
620
|
+
}
|
|
621
|
+
gauge.textContent = ''
|
|
622
|
+
return widest
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function recompute() {
|
|
626
|
+
if (fit === null) return
|
|
627
|
+
const available = fit.getBoundingClientRect().width
|
|
628
|
+
const valueWidest = widestOf(valueGaugeRef.current, valueKey)
|
|
629
|
+
const captionWidest = widestOf(captionGaugeRef.current, captionKey)
|
|
630
|
+
|
|
631
|
+
// jsdom reports 0 for every box; leaving the scales at 1 there is
|
|
632
|
+
// correct — there is nothing to fit to.
|
|
633
|
+
const fit1 =
|
|
634
|
+
available > 0 && valueWidest > 0
|
|
635
|
+
? Math.max(CENTER_MIN_SCALE, Math.min(1, available / valueWidest))
|
|
636
|
+
: 1
|
|
637
|
+
const fit2 =
|
|
638
|
+
available > 0 && captionWidest > 0
|
|
639
|
+
? Math.max(CAPTION_MIN_SCALE, Math.min(1, available / captionWidest))
|
|
640
|
+
: 1
|
|
641
|
+
|
|
642
|
+
// Quantized so sub-pixel resize noise cannot chase its own tail — and
|
|
643
|
+
// quantized DOWNWARD, never to nearest. Rounding 0.7465 up to 0.75 put
|
|
644
|
+
// "$116,120" at 86.4px inside an 86px slot: no overflow, no failing
|
|
645
|
+
// `scrollWidth` check, and a visible "$116,1…" in the screenshot. A
|
|
646
|
+
// number that ellipsises is a number the reader cannot trust.
|
|
647
|
+
const nextValue = Math.floor(fit1 * 100) / 100
|
|
648
|
+
const nextCaption = Math.floor(fit2 * 100) / 100
|
|
649
|
+
setCenterScale((prev) => (Math.abs(prev - nextValue) < 0.005 ? prev : nextValue))
|
|
650
|
+
setCaptionScale((prev) =>
|
|
651
|
+
Math.abs(prev - nextCaption) < 0.005 ? prev : nextCaption,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
recompute()
|
|
656
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
657
|
+
const observer = new ResizeObserver(recompute)
|
|
658
|
+
observer.observe(fit)
|
|
659
|
+
return () => observer.disconnect()
|
|
660
|
+
// `showCenter` is load-bearing, not decoration: on a donut with no
|
|
661
|
+
// `centerValue` the whole readout is unmounted at rest and only appears
|
|
662
|
+
// when a segment becomes active. Without this dep the effect would have
|
|
663
|
+
// run once against a null ref and never again, and that donut's value
|
|
664
|
+
// would render unscaled at the base size — measured, before this: a 120px
|
|
665
|
+
// donut showed "48,250" at 24px, ellipsised inside a 60px slot.
|
|
666
|
+
}, [valueKey, captionKey, holePx, showCenter])
|
|
667
|
+
|
|
668
|
+
/* ── tooltip anchor ───────────────────────────────────────────────── */
|
|
669
|
+
|
|
670
|
+
const anchorFraction =
|
|
671
|
+
activeIndex >= 0
|
|
672
|
+
? (drawnSegments[activeIndex]?.startFraction ?? 0) +
|
|
673
|
+
(drawnSegments[activeIndex]?.fraction ?? 0) / 2
|
|
674
|
+
: 0
|
|
675
|
+
const anchorAngle = anchorFraction * Math.PI * 2 - Math.PI / 2
|
|
676
|
+
const anchorX = (center + radius * Math.cos(anchorAngle)) * scale
|
|
677
|
+
const anchorY = (center + radius * Math.sin(anchorAngle)) * scale
|
|
678
|
+
|
|
679
|
+
/* ── render ───────────────────────────────────────────────────────── */
|
|
680
|
+
|
|
681
|
+
const interactive = onSelect !== undefined
|
|
682
|
+
const gapColor = gapSurface === 'none' ? null : GAP_COLOR[gapSurface]
|
|
683
|
+
|
|
684
|
+
return (
|
|
685
|
+
<div
|
|
686
|
+
className={cn(
|
|
687
|
+
'flex',
|
|
688
|
+
// `flex-wrap` in the row layout is what stops the legend being
|
|
689
|
+
// starved: the plot cannot shrink (it is `shrink-0`), so on a narrow
|
|
690
|
+
// root the legend either wraps under it or gets nothing. See
|
|
691
|
+
// LEGEND_BASIS for the measurement.
|
|
692
|
+
orientation === 'row' ? 'flex-row flex-wrap items-center' : 'flex-col',
|
|
693
|
+
className,
|
|
694
|
+
)}
|
|
695
|
+
style={{ gap: 'var(--space-5)' }}
|
|
696
|
+
>
|
|
697
|
+
<div
|
|
698
|
+
ref={plotRef}
|
|
699
|
+
className="relative shrink-0"
|
|
700
|
+
// Geometry, not spacing: the plot is a square of `size` px that may
|
|
701
|
+
// shrink on a narrow viewport. `aspect-ratio` is what reserves its
|
|
702
|
+
// height so async data cannot reflow the page (ADR-146 D9).
|
|
703
|
+
style={{ width: box, maxWidth: '100%', aspectRatio: '1' }}
|
|
704
|
+
>
|
|
705
|
+
<svg
|
|
706
|
+
viewBox={`0 0 ${box} ${box}`}
|
|
707
|
+
className="h-full w-full"
|
|
708
|
+
role="group"
|
|
709
|
+
aria-label={label}
|
|
710
|
+
>
|
|
711
|
+
{/* Track. Always painted, so an all-zero chart still reads as a
|
|
712
|
+
ring rather than as a missing element. */}
|
|
713
|
+
<circle
|
|
714
|
+
aria-hidden="true"
|
|
715
|
+
cx={center}
|
|
716
|
+
cy={center}
|
|
717
|
+
r={radius}
|
|
718
|
+
fill="none"
|
|
719
|
+
stroke="rgb(var(--muted))"
|
|
720
|
+
strokeWidth={stroke}
|
|
721
|
+
/>
|
|
722
|
+
|
|
723
|
+
{/* Segments. Rotated -90° so slice one starts at twelve o'clock —
|
|
724
|
+
the same convention both hand-rolled accumulators used. */}
|
|
725
|
+
<g transform={`rotate(-90 ${center} ${center})`}>
|
|
726
|
+
{!isEmpty &&
|
|
727
|
+
drawnSegments.map((segment, index) => {
|
|
728
|
+
const slice = slices[index]
|
|
729
|
+
if (slice === undefined || segment.fraction <= 0) return null
|
|
730
|
+
|
|
731
|
+
const trueFraction = trueSegments[index]?.fraction ?? 0
|
|
732
|
+
const raised = active === slice.key
|
|
733
|
+
const color = slotColor(slice.slot, palette)
|
|
734
|
+
const name = [
|
|
735
|
+
slice.label,
|
|
736
|
+
formatValue(slice.value),
|
|
737
|
+
percentOf(trueFraction),
|
|
738
|
+
slice.foldedCount > 0 ? otherMeta(slice.foldedCount) : null,
|
|
739
|
+
]
|
|
740
|
+
.filter((part) => part !== null)
|
|
741
|
+
.join(', ')
|
|
742
|
+
|
|
743
|
+
return (
|
|
744
|
+
<g
|
|
745
|
+
key={slice.key}
|
|
746
|
+
// No `focus-visible:[box-shadow:var(--ring-focus)]` here:
|
|
747
|
+
// `box-shadow` does not paint on an SVG element — it has
|
|
748
|
+
// no CSS box — so the class would be an inert claim to a
|
|
749
|
+
// focus ring with every gate green. The visible ring is
|
|
750
|
+
// the halo <circle> below, which has the added virtue of
|
|
751
|
+
// tracing THIS arc rather than boxing the whole plot.
|
|
752
|
+
className={cn(
|
|
753
|
+
'group cursor-default outline-none',
|
|
754
|
+
interactive && 'cursor-pointer',
|
|
755
|
+
)}
|
|
756
|
+
tabIndex={0}
|
|
757
|
+
role={interactive ? 'button' : 'img'}
|
|
758
|
+
aria-label={name}
|
|
759
|
+
onMouseEnter={() => setActive(slice.key)}
|
|
760
|
+
onMouseLeave={() => setActive(null)}
|
|
761
|
+
onFocus={() => setActive(slice.key)}
|
|
762
|
+
onBlur={() => setActive(null)}
|
|
763
|
+
onClick={
|
|
764
|
+
onSelect !== undefined
|
|
765
|
+
? () => onSelect(slice.key)
|
|
766
|
+
: undefined
|
|
767
|
+
}
|
|
768
|
+
onKeyDown={
|
|
769
|
+
onSelect !== undefined
|
|
770
|
+
? (event) => {
|
|
771
|
+
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
event.preventDefault()
|
|
775
|
+
onSelect(slice.key)
|
|
776
|
+
}
|
|
777
|
+
: undefined
|
|
778
|
+
}
|
|
779
|
+
>
|
|
780
|
+
{/* Focus halo. Same radius and same dash as the arc, so
|
|
781
|
+
it traces exactly this segment and not the whole
|
|
782
|
+
ring. Painted first, therefore behind. Kept in the
|
|
783
|
+
DOM at zero opacity so focusing costs no layout. */}
|
|
784
|
+
<circle
|
|
785
|
+
aria-hidden="true"
|
|
786
|
+
className="opacity-0 group-focus-visible:opacity-100"
|
|
787
|
+
cx={center}
|
|
788
|
+
cy={center}
|
|
789
|
+
r={radius}
|
|
790
|
+
fill="none"
|
|
791
|
+
stroke="rgb(var(--ring))"
|
|
792
|
+
strokeWidth={stroke + FOCUS_HALO}
|
|
793
|
+
strokeDasharray={segment.dasharray}
|
|
794
|
+
strokeDashoffset={segment.dashoffset}
|
|
795
|
+
/>
|
|
796
|
+
<circle
|
|
797
|
+
className="motion-safe:transition-[stroke-width,opacity]"
|
|
798
|
+
cx={center}
|
|
799
|
+
cy={center}
|
|
800
|
+
r={radius}
|
|
801
|
+
fill="none"
|
|
802
|
+
stroke={color}
|
|
803
|
+
// The "raise": the stroke grows around the SAME
|
|
804
|
+
// centreline, so the dash geometry stays valid and the
|
|
805
|
+
// segment thickens on both edges instead of moving.
|
|
806
|
+
strokeWidth={raised ? stroke + HOVER_BUMP : stroke}
|
|
807
|
+
strokeDasharray={segment.dasharray}
|
|
808
|
+
strokeDashoffset={segment.dashoffset}
|
|
809
|
+
opacity={
|
|
810
|
+
activeSlice === undefined || raised ? 1 : DIM_OPACITY
|
|
811
|
+
}
|
|
812
|
+
style={{
|
|
813
|
+
transitionDuration: 'var(--dur-fast)',
|
|
814
|
+
// Data and hover changes tween with ease-out. Never
|
|
815
|
+
// ease-spring: overshoot reads as imprecise.
|
|
816
|
+
transitionTimingFunction: 'var(--ease-out)',
|
|
817
|
+
}}
|
|
818
|
+
/>
|
|
819
|
+
</g>
|
|
820
|
+
)
|
|
821
|
+
})}
|
|
822
|
+
|
|
823
|
+
{/* The 2px surface-coloured cuts between adjacent segments. Drawn
|
|
824
|
+
last so they sit above every arc, and skipped entirely for a
|
|
825
|
+
single slice — one cut in a full ring reads as a fault. */}
|
|
826
|
+
{gapColor !== null &&
|
|
827
|
+
positiveCount > 1 &&
|
|
828
|
+
drawnSegments.map((segment, index) => {
|
|
829
|
+
const slice = slices[index]
|
|
830
|
+
if (slice === undefined || segment.fraction <= 0) return null
|
|
831
|
+
const angle = segment.startFraction * Math.PI * 2
|
|
832
|
+
// Reach past the RAISED stroke, not just the resting one. A
|
|
833
|
+
// hovered/focused segment grows by HOVER_BUMP around the same
|
|
834
|
+
// centreline, so a cut sized for the base stroke leaves a
|
|
835
|
+
// coloured nub at each radial end of it the moment the reader
|
|
836
|
+
// hovers. The overshoot paints surface colour onto surface
|
|
837
|
+
// colour, so covering the widest case costs nothing.
|
|
838
|
+
const reach = stroke / 2 + HOVER_BUMP / 2 + 1
|
|
839
|
+
const inner = radius - reach
|
|
840
|
+
const outer = radius + reach
|
|
841
|
+
const cos = Math.cos(angle)
|
|
842
|
+
const sin = Math.sin(angle)
|
|
843
|
+
return (
|
|
844
|
+
<line
|
|
845
|
+
key={`gap-${slice.key}`}
|
|
846
|
+
aria-hidden="true"
|
|
847
|
+
x1={center + inner * cos}
|
|
848
|
+
y1={center + inner * sin}
|
|
849
|
+
x2={center + outer * cos}
|
|
850
|
+
y2={center + outer * sin}
|
|
851
|
+
stroke={gapColor}
|
|
852
|
+
strokeWidth={SEGMENT_GAP}
|
|
853
|
+
/>
|
|
854
|
+
)
|
|
855
|
+
})}
|
|
856
|
+
</g>
|
|
857
|
+
</svg>
|
|
858
|
+
|
|
859
|
+
{/* Centre readout. Absolutely positioned and pointer-events-none so
|
|
860
|
+
it can never steal a hover from a segment underneath it, and both
|
|
861
|
+
lines always occupy their slot so swapping total ↔ hovered
|
|
862
|
+
segment does not resize anything.
|
|
863
|
+
|
|
864
|
+
The reserve is the HOLE, not the plot box: the readout is sized to
|
|
865
|
+
`holePx` (inner diameter at the raised stroke) less the inline
|
|
866
|
+
padding, and the value's type is scaled once to the widest string
|
|
867
|
+
the slot can ever hold. */}
|
|
868
|
+
{showCenter && (
|
|
869
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
870
|
+
<div
|
|
871
|
+
className="flex flex-col items-center text-center"
|
|
872
|
+
style={{
|
|
873
|
+
width: holePx,
|
|
874
|
+
maxWidth: '100%',
|
|
875
|
+
// --space-1, not --space-2: the reserve already uses the
|
|
876
|
+
// RAISED hole, which is 2px tighter on each side than the
|
|
877
|
+
// resting one, so 4px here is 6px of real clearance from the
|
|
878
|
+
// arc as drawn at rest — and it buys the value line enough
|
|
879
|
+
// width to stay near its full type size.
|
|
880
|
+
paddingInline: 'var(--space-1)',
|
|
881
|
+
boxSizing: 'border-box',
|
|
882
|
+
}}
|
|
883
|
+
>
|
|
884
|
+
{/* `fitRef` measures the available content width; the gauges
|
|
885
|
+
below render every candidate at the BASE size, off-flow and
|
|
886
|
+
invisible, so the fit is measured rather than estimated. */}
|
|
887
|
+
<div ref={fitRef} className="relative w-full">
|
|
888
|
+
<span
|
|
889
|
+
ref={valueGaugeRef}
|
|
890
|
+
aria-hidden="true"
|
|
891
|
+
className="absolute top-0 left-0 block leading-none font-semibold tabular-nums whitespace-nowrap opacity-0"
|
|
892
|
+
style={{ fontSize: `${CENTER_VALUE_FONT_PX}px` }}
|
|
893
|
+
/>
|
|
894
|
+
<span
|
|
895
|
+
ref={captionGaugeRef}
|
|
896
|
+
aria-hidden="true"
|
|
897
|
+
className="absolute top-0 left-0 block leading-tight whitespace-nowrap opacity-0"
|
|
898
|
+
style={{ fontSize: `${CENTER_CAPTION_FONT_PX}px` }}
|
|
899
|
+
/>
|
|
900
|
+
<span
|
|
901
|
+
className="block truncate leading-none font-semibold tabular-nums"
|
|
902
|
+
style={{
|
|
903
|
+
color: 'rgb(var(--foreground))',
|
|
904
|
+
maxWidth: '100%',
|
|
905
|
+
fontSize: `${CENTER_VALUE_FONT_PX * centerScale}px`,
|
|
906
|
+
}}
|
|
907
|
+
>
|
|
908
|
+
{centerMain}
|
|
909
|
+
</span>
|
|
910
|
+
<span
|
|
911
|
+
className="block truncate leading-tight"
|
|
912
|
+
style={{
|
|
913
|
+
marginTop: 'var(--space-1)',
|
|
914
|
+
// Reserved even when empty, so the value never jumps.
|
|
915
|
+
minHeight: 'var(--space-4)',
|
|
916
|
+
maxWidth: '100%',
|
|
917
|
+
color: 'rgb(var(--text-tertiary))',
|
|
918
|
+
fontSize: `${CENTER_CAPTION_FONT_PX * captionScale}px`,
|
|
919
|
+
visibility: centerSub === undefined ? 'hidden' : 'visible',
|
|
920
|
+
}}
|
|
921
|
+
>
|
|
922
|
+
{centerSub ?? ''}
|
|
923
|
+
</span>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
)}
|
|
928
|
+
|
|
929
|
+
{activeSlice !== undefined && (
|
|
930
|
+
<ChartTooltip
|
|
931
|
+
x={anchorX}
|
|
932
|
+
y={anchorY}
|
|
933
|
+
plotWidth={plotPx}
|
|
934
|
+
plotHeight={plotPx}
|
|
935
|
+
title={activeSlice.label}
|
|
936
|
+
palette={palette}
|
|
937
|
+
rows={[
|
|
938
|
+
{
|
|
939
|
+
key: activeSlice.key,
|
|
940
|
+
label: percentOf(activeFraction),
|
|
941
|
+
value: formatValue(activeSlice.value),
|
|
942
|
+
slot: activeSlice.slot,
|
|
943
|
+
},
|
|
944
|
+
]}
|
|
945
|
+
/>
|
|
946
|
+
)}
|
|
947
|
+
</div>
|
|
948
|
+
|
|
949
|
+
{showLegend && (
|
|
950
|
+
<div
|
|
951
|
+
className="min-w-0"
|
|
952
|
+
style={
|
|
953
|
+
orientation === 'row'
|
|
954
|
+
? { flex: `1 1 ${LEGEND_BASIS}` }
|
|
955
|
+
: { flex: '1 1 auto' }
|
|
956
|
+
}
|
|
957
|
+
>
|
|
958
|
+
{isEmpty ? (
|
|
959
|
+
<p
|
|
960
|
+
className="text-[14px]"
|
|
961
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
962
|
+
>
|
|
963
|
+
{emptyMessage}
|
|
964
|
+
</p>
|
|
965
|
+
) : (
|
|
966
|
+
<ChartLegend
|
|
967
|
+
items={legendItems}
|
|
968
|
+
orientation="vertical"
|
|
969
|
+
palette={palette}
|
|
970
|
+
activeKey={resolvedActive}
|
|
971
|
+
valueMinWidth={legendValueMinWidth}
|
|
972
|
+
label={`${label} legend`}
|
|
973
|
+
{...(onSelect !== undefined ? { onSelect } : {})}
|
|
974
|
+
/>
|
|
975
|
+
)}
|
|
976
|
+
</div>
|
|
977
|
+
)}
|
|
978
|
+
|
|
979
|
+
{/* The fold has to be readable, not just visible: a screen reader hears
|
|
980
|
+
this even though the legend's "Other" row already shows it. */}
|
|
981
|
+
{foldedCount > 0 && !isEmpty && (
|
|
982
|
+
<span className="sr-only">
|
|
983
|
+
{`${foldedCount} smaller categories are grouped as ${otherLabel}.`}
|
|
984
|
+
</span>
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
)
|
|
988
|
+
}
|