@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,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparkline — an inline trend at cell size. No axes, no gridlines, no frame,
|
|
3
|
+
* no legend, no tooltip. Line or bar.
|
|
4
|
+
*
|
|
5
|
+
* Promoted in ADR-146 D5 (chart layer). It supersedes the **chart half** of
|
|
6
|
+
* `allocation-sparkbar.tsx`, whose labels-under-bars affordance stays with
|
|
7
|
+
* that primitive until the staged migration in ADR-146 D13.
|
|
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 (1 — gate NOT MET):
|
|
13
|
+
* • `lenses/budget-flighting/components/cards/LineItemCard.tsx` — the
|
|
14
|
+
* monthly allocation strip
|
|
15
|
+
* PLANNED second consumer: a trend cell per row in the Keywords term table
|
|
16
|
+
* (`lenses/keywords/results-table.tsx`). Not written. Tracked in
|
|
17
|
+
* `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
|
|
18
|
+
*
|
|
19
|
+
* That one migration also COST something: the budget-flighting strip lost its
|
|
20
|
+
* per-month flight-group tint, because a chart primitive takes a palette slot
|
|
21
|
+
* and never a token name (D8). Logged as DEV-2 in
|
|
22
|
+
* `docs/phases/_completed/adr-146-analytics-primitives.md` and open as
|
|
23
|
+
* ADR-146 OQ-2 (`docs/follow-ups/_pending/adr-146-sparkline-per-bar-colour.md`).
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT INHERITS FROM `AllocationSparkbar`, DELIBERATELY
|
|
26
|
+
* -------------------------------------------------------
|
|
27
|
+
* The bar variant keeps that primitive's three load-bearing behaviours so a
|
|
28
|
+
* migration is a swap and not a redesign:
|
|
29
|
+
*
|
|
30
|
+
* • **Max-normalisation** (`allocation-sparkbar.tsx:40`) — heights are a
|
|
31
|
+
* fraction of the largest value, via `normalizeToMax`. That helper drops
|
|
32
|
+
* the old `Math.max(...values, 1)` fudge, which silently rescaled any
|
|
33
|
+
* series whose max was below 1 (a set of ratios) against a phantom
|
|
34
|
+
* maximum and flattened the whole strip.
|
|
35
|
+
* • **A floor under every bar** (`:48`, `8 + (v/max) * (h - 12)`) — the
|
|
36
|
+
* smallest bar is still a visible mark, not a hairline. At that file's
|
|
37
|
+
* 36px default height this one reproduces the arithmetic exactly (floor
|
|
38
|
+
* 8, headroom 4, span 8..32); below it the floor and headroom scale with
|
|
39
|
+
* the box, because an 8px floor inside a 20px strip is 40% of the chart.
|
|
40
|
+
* • **Paused reads as paused, not as empty** (`:44`) — a value of exactly
|
|
41
|
+
* `0` renders short and dashed in a muted tone, so "we spent nothing that
|
|
42
|
+
* month" is distinguishable from "we have no data". Turn it off with
|
|
43
|
+
* `zeroAsPaused={false}` when zero is a real measurement.
|
|
44
|
+
*
|
|
45
|
+
* WHY THE BAR VARIANT IS DIVS AND THE LINE VARIANT IS SVG
|
|
46
|
+
* ------------------------------------------------------
|
|
47
|
+
* A bar strip is rectangles, and a `<div>` takes `border-radius`,
|
|
48
|
+
* `background` and `border` straight from tokens. An SVG `<rect>` cannot: its
|
|
49
|
+
* `rx` is a geometry attribute, `var()` substitution inside presentation
|
|
50
|
+
* attributes is not reliably implemented, and a radius that fails to parse
|
|
51
|
+
* fails **silently** — the exact class of defect ADR-146 D8 exists to prevent.
|
|
52
|
+
* So bars stay divs, as they were in the primitive this supersedes, and only
|
|
53
|
+
* the line — which genuinely needs path arithmetic — is SVG, where every
|
|
54
|
+
* colour is a plain `fill`/`stroke` value and no token has to survive an
|
|
55
|
+
* attribute parser.
|
|
56
|
+
*
|
|
57
|
+
* THE POINT TREATMENT INVERTS AT SMALL SIZE
|
|
58
|
+
* -----------------------------------------
|
|
59
|
+
* The extracted anatomy (`METORIC-ANALYTICS-SPEC.md` 6.2) draws the large
|
|
60
|
+
* chart's point as a **filled** dot with a 2px surface ring, and the 450×56
|
|
61
|
+
* sparkline's as the **opposite** — a surface fill with a 3px coloured
|
|
62
|
+
* stroke. That inversion is right and we keep it. At sparkline size the mark
|
|
63
|
+
* is a large fraction of the plot, so a filled dot reads as a blob and hides
|
|
64
|
+
* the very inflection it is marking; a hollow ring keeps the line's own path
|
|
65
|
+
* visible through it. The dot and its stroke scale with `height`, so the
|
|
66
|
+
* treatment holds at 60×20 and at 300×60.
|
|
67
|
+
*
|
|
68
|
+
* The hollow dot is filled with `--surface-card` — it is drawn to sit on a
|
|
69
|
+
* `Card`, which is where an inline trend lives. On a tray or a zebra band it
|
|
70
|
+
* reads as a very slightly lighter ring; pass `emphasizeLast={false}` there
|
|
71
|
+
* rather than introducing a fill-colour prop.
|
|
72
|
+
*
|
|
73
|
+
* NO MOTION, ON PURPOSE
|
|
74
|
+
* ---------------------
|
|
75
|
+
* SVG path geometry does not tween under a CSS transition, and animating it
|
|
76
|
+
* properly would mean a per-frame interpolator inside a primitive whose whole
|
|
77
|
+
* job is to be cheap enough to render once per table row. A sparkline
|
|
78
|
+
* redraws; it does not settle. That is reduced-motion-correct by
|
|
79
|
+
* construction. `RankedBars` and the full-size chart layer carry the
|
|
80
|
+
* `--dur-base` / `--ease-out` data tween.
|
|
81
|
+
*
|
|
82
|
+
* Token discipline: every colour is `rgb(var(--token))`; the series hue
|
|
83
|
+
* resolves through the frozen `SERIES` lookup in `lib/series.ts` (ADR-146 D8
|
|
84
|
+
* — never a constructed `--series-${i}`), and defaults to `rgb(var(--accent))`
|
|
85
|
+
* because a lone trend is one series and D7's monochrome default applies.
|
|
86
|
+
* Tints are applied with `opacity` / SVG `fill-opacity` rather than by
|
|
87
|
+
* editing the token string, so no call site can stack a second alpha onto a
|
|
88
|
+
* token that already carries one. Sizes are props applied through inline
|
|
89
|
+
* `style` and SVG attributes, never a template-literal Tailwind class
|
|
90
|
+
* (`h-[${n}px]` is statically unscannable and silently produces nothing).
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
import { cn } from './lib/utils'
|
|
94
|
+
import { areaPath, bandScale, linePath, linearScale, normalizeToMax } from './lib/chart'
|
|
95
|
+
import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
|
|
96
|
+
|
|
97
|
+
/** Which mark the sparkline draws. */
|
|
98
|
+
export type SparklineVariant = 'line' | 'bar'
|
|
99
|
+
|
|
100
|
+
export interface SparklineProps {
|
|
101
|
+
/**
|
|
102
|
+
* The series, oldest first. Non-finite entries are dropped from the line
|
|
103
|
+
* (it closes over the gap) and read as `0` in the bar variant. Negative
|
|
104
|
+
* values plot normally on the line and clamp to the height floor on bars —
|
|
105
|
+
* a bar cannot be shorter than nothing.
|
|
106
|
+
*/
|
|
107
|
+
values: readonly number[]
|
|
108
|
+
/** `'line'` (default) or `'bar'`. */
|
|
109
|
+
variant?: SparklineVariant
|
|
110
|
+
/** Plot width in px. Default `120`. Renders sensibly from about 60. */
|
|
111
|
+
width?: number
|
|
112
|
+
/** Plot height in px. Default `32`. Renders sensibly from about 20. */
|
|
113
|
+
height?: number
|
|
114
|
+
/**
|
|
115
|
+
* Optional palette slot. Omit for the monochrome accent default (ADR-146
|
|
116
|
+
* D7). Pass a slot only when this trend is one of several colour-identified
|
|
117
|
+
* entities, and derive the slot from the entity's stable key — never from
|
|
118
|
+
* an array index after sorting or filtering.
|
|
119
|
+
*/
|
|
120
|
+
slot?: ChartSlot | undefined
|
|
121
|
+
/** Which palette `slot` indexes into. Default `'default'`. */
|
|
122
|
+
palette?: SeriesPalette
|
|
123
|
+
/** Line stroke width in px. Default `2`. Ignored by the bar variant. */
|
|
124
|
+
strokeWidth?: number
|
|
125
|
+
/**
|
|
126
|
+
* Fill the area under the line at a 12% tint. Default `false` — at cell
|
|
127
|
+
* size the line alone is usually clearer. Ignored by the bar variant.
|
|
128
|
+
*/
|
|
129
|
+
area?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* Emphasize one mark: the line gets its hollow end dot, the bar variant
|
|
132
|
+
* draws that bar at full strength and tints the rest. Default `true`.
|
|
133
|
+
*/
|
|
134
|
+
emphasizeLast?: boolean
|
|
135
|
+
/**
|
|
136
|
+
* Which mark is emphasized. Default: the last one. Pass the peak's or the
|
|
137
|
+
* selected period's index when "now" is not the interesting mark. Clamped
|
|
138
|
+
* into range, so a stale selection still points at a real mark.
|
|
139
|
+
*/
|
|
140
|
+
emphasisIndex?: number
|
|
141
|
+
/**
|
|
142
|
+
* Render a value of exactly `0` as a short dashed stub rather than as a
|
|
143
|
+
* floor-height bar — the `AllocationSparkbar` paused treatment. Default
|
|
144
|
+
* `true`. Bar variant only.
|
|
145
|
+
*/
|
|
146
|
+
zeroAsPaused?: boolean
|
|
147
|
+
/**
|
|
148
|
+
* Optional pre-formatted value shown after the plot, in `tabular-nums`.
|
|
149
|
+
* Format through `@lovett/ui/format` — this primitive never formats a
|
|
150
|
+
* number.
|
|
151
|
+
*/
|
|
152
|
+
valueLabel?: string
|
|
153
|
+
/**
|
|
154
|
+
* Reserved width for `valueLabel`, so a value growing from "9" to "11,227"
|
|
155
|
+
* cannot resize the row. Pass a token-derived length.
|
|
156
|
+
* Default `var(--space-10)`.
|
|
157
|
+
*/
|
|
158
|
+
valueMinWidth?: string
|
|
159
|
+
/**
|
|
160
|
+
* Accessible name — e.g. `'Impressions, last 12 weeks'`. With it the plot
|
|
161
|
+
* is `role="img"`; without it the plot is `aria-hidden`, which is correct
|
|
162
|
+
* only when a neighbouring cell already states the number and the
|
|
163
|
+
* sparkline is decorative. Never leave a meaning-carrying trend unlabelled.
|
|
164
|
+
*/
|
|
165
|
+
label?: string
|
|
166
|
+
/** Optional className on the outer container. */
|
|
167
|
+
className?: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Clamp without ever returning a non-finite number. */
|
|
171
|
+
function clamp(value: number, min: number, max: number): number {
|
|
172
|
+
if (!Number.isFinite(value)) return min
|
|
173
|
+
if (value < min) return min
|
|
174
|
+
return value > max ? max : value
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function Sparkline({
|
|
178
|
+
values,
|
|
179
|
+
variant = 'line',
|
|
180
|
+
width = 120,
|
|
181
|
+
height = 32,
|
|
182
|
+
slot,
|
|
183
|
+
palette = 'default',
|
|
184
|
+
strokeWidth = 2,
|
|
185
|
+
area = false,
|
|
186
|
+
emphasizeLast = true,
|
|
187
|
+
emphasisIndex,
|
|
188
|
+
zeroAsPaused = true,
|
|
189
|
+
valueLabel,
|
|
190
|
+
valueMinWidth = 'var(--space-10)',
|
|
191
|
+
label,
|
|
192
|
+
className,
|
|
193
|
+
}: SparklineProps) {
|
|
194
|
+
const w = Math.max(0, Number.isFinite(width) ? width : 0)
|
|
195
|
+
const h = Math.max(0, Number.isFinite(height) ? height : 0)
|
|
196
|
+
const count = values.length
|
|
197
|
+
const color = slot === undefined ? 'rgb(var(--accent))' : slotColor(slot, palette)
|
|
198
|
+
|
|
199
|
+
// Default emphasis is the newest mark.
|
|
200
|
+
const emphasis =
|
|
201
|
+
count === 0
|
|
202
|
+
? -1
|
|
203
|
+
: emphasisIndex === undefined
|
|
204
|
+
? count - 1
|
|
205
|
+
: Math.round(clamp(emphasisIndex, 0, count - 1))
|
|
206
|
+
|
|
207
|
+
// The plot reserves its exact box, so async data cannot reflow the row it
|
|
208
|
+
// sits in — the layout-stability rule, at cell scale.
|
|
209
|
+
const plot = (
|
|
210
|
+
<div
|
|
211
|
+
role={label === undefined ? undefined : 'img'}
|
|
212
|
+
aria-label={label}
|
|
213
|
+
aria-hidden={label === undefined ? true : undefined}
|
|
214
|
+
className="relative shrink-0"
|
|
215
|
+
style={{ width: w, height: h }}
|
|
216
|
+
>
|
|
217
|
+
{variant === 'bar'
|
|
218
|
+
? renderBars({ values, w, h, color, emphasis, emphasizeLast, zeroAsPaused })
|
|
219
|
+
: renderLine({ values, w, h, color, emphasis, emphasizeLast, strokeWidth, area })}
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if (valueLabel === undefined) {
|
|
224
|
+
return <div className={cn('inline-flex', className)}>{plot}</div>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className={cn('inline-flex items-center gap-2', className)}>
|
|
229
|
+
{plot}
|
|
230
|
+
<span
|
|
231
|
+
className="shrink-0 text-right text-[12px] font-semibold tabular-nums"
|
|
232
|
+
style={{ color: 'rgb(var(--foreground))', minWidth: valueMinWidth }}
|
|
233
|
+
>
|
|
234
|
+
{valueLabel}
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ── line ────────────────────────────────────────────────────────────── */
|
|
241
|
+
|
|
242
|
+
interface LineArgs {
|
|
243
|
+
values: readonly number[]
|
|
244
|
+
w: number
|
|
245
|
+
h: number
|
|
246
|
+
color: string
|
|
247
|
+
emphasis: number
|
|
248
|
+
emphasizeLast: boolean
|
|
249
|
+
strokeWidth: number
|
|
250
|
+
area: boolean
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function renderLine({
|
|
254
|
+
values,
|
|
255
|
+
w,
|
|
256
|
+
h,
|
|
257
|
+
color,
|
|
258
|
+
emphasis,
|
|
259
|
+
emphasizeLast,
|
|
260
|
+
strokeWidth,
|
|
261
|
+
area,
|
|
262
|
+
}: LineArgs) {
|
|
263
|
+
if (w === 0 || h === 0) return null
|
|
264
|
+
|
|
265
|
+
// The dot and its ring scale with the box: the extracted 450×56 sparkline
|
|
266
|
+
// uses a 12px dot with a 3px stroke (r ≈ h/9), which at 20px tall would be
|
|
267
|
+
// a 2px dot with a hairline — below the visibility floor. The clamps hold
|
|
268
|
+
// both ends of the range.
|
|
269
|
+
const dotRadius = clamp(h * 0.11, 2.5, 6)
|
|
270
|
+
const dotStroke = clamp(dotRadius * 0.5, 1.5, 3)
|
|
271
|
+
const stroke = Math.max(0.5, Number.isFinite(strokeWidth) ? strokeWidth : 2)
|
|
272
|
+
|
|
273
|
+
// Inset so neither the stroke nor the end dot is clipped by the viewBox.
|
|
274
|
+
// `Math.max` rather than a ternary: with the dot on, a caller-raised
|
|
275
|
+
// `strokeWidth` can exceed the dot's own inset (strokeWidth 8 at h=20 needs
|
|
276
|
+
// 4 of inset, the dot only reserves 3.25), and the line would be shaved
|
|
277
|
+
// against the viewBox edge with every gate green.
|
|
278
|
+
const pad = Math.max(stroke / 2, emphasizeLast ? dotRadius + dotStroke / 2 : 0)
|
|
279
|
+
const plotW = Math.max(0, w - pad * 2)
|
|
280
|
+
const plotH = Math.max(0, h - pad * 2)
|
|
281
|
+
|
|
282
|
+
const finiteValues = values.filter((value) => Number.isFinite(value))
|
|
283
|
+
const first = finiteValues[0]
|
|
284
|
+
if (first === undefined) return null
|
|
285
|
+
|
|
286
|
+
let min = first
|
|
287
|
+
let max = first
|
|
288
|
+
for (const value of finiteValues) {
|
|
289
|
+
if (value < min) min = value
|
|
290
|
+
if (value > max) max = value
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A flat or single-datum series maps to the midpoint of the range — see
|
|
294
|
+
// `linearScale`'s contract. Nothing divides by zero.
|
|
295
|
+
const y = linearScale([min, max], [pad + plotH, pad])
|
|
296
|
+
const xAt = (index: number): number =>
|
|
297
|
+
values.length > 1 ? pad + (index / (values.length - 1)) * plotW : pad + plotW / 2
|
|
298
|
+
|
|
299
|
+
// Non-finite entries are dropped rather than pinned to the floor: a missing
|
|
300
|
+
// datum has no honest position, and plotting it at zero invents a dip. A
|
|
301
|
+
// single plottable point still paints, as a round dot.
|
|
302
|
+
const points = values
|
|
303
|
+
.map((value, index) => ({ value, x: xAt(index), y: y(value) }))
|
|
304
|
+
.filter((point) => Number.isFinite(point.value))
|
|
305
|
+
.map((point) => ({ x: point.x, y: point.y }))
|
|
306
|
+
|
|
307
|
+
const emphasized = values[emphasis]
|
|
308
|
+
const showDot =
|
|
309
|
+
emphasizeLast && emphasis >= 0 && emphasized !== undefined && Number.isFinite(emphasized)
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<svg
|
|
313
|
+
width={w}
|
|
314
|
+
height={h}
|
|
315
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
316
|
+
aria-hidden="true"
|
|
317
|
+
focusable="false"
|
|
318
|
+
style={{ display: 'block' }}
|
|
319
|
+
>
|
|
320
|
+
{area && (
|
|
321
|
+
<path d={areaPath(points, pad + plotH)} fill={color} fillOpacity={0.12} stroke="none" />
|
|
322
|
+
)}
|
|
323
|
+
<path
|
|
324
|
+
d={linePath(points)}
|
|
325
|
+
fill="none"
|
|
326
|
+
stroke={color}
|
|
327
|
+
strokeWidth={stroke}
|
|
328
|
+
strokeLinecap="round"
|
|
329
|
+
strokeLinejoin="round"
|
|
330
|
+
/>
|
|
331
|
+
{showDot && (
|
|
332
|
+
<circle
|
|
333
|
+
cx={xAt(emphasis)}
|
|
334
|
+
cy={y(emphasized)}
|
|
335
|
+
r={dotRadius}
|
|
336
|
+
fill="rgb(var(--surface-card))"
|
|
337
|
+
stroke={color}
|
|
338
|
+
strokeWidth={dotStroke}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</svg>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ── bars ────────────────────────────────────────────────────────────── */
|
|
346
|
+
|
|
347
|
+
interface BarArgs {
|
|
348
|
+
values: readonly number[]
|
|
349
|
+
w: number
|
|
350
|
+
h: number
|
|
351
|
+
color: string
|
|
352
|
+
emphasis: number
|
|
353
|
+
emphasizeLast: boolean
|
|
354
|
+
zeroAsPaused: boolean
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderBars({
|
|
358
|
+
values,
|
|
359
|
+
w,
|
|
360
|
+
h,
|
|
361
|
+
color,
|
|
362
|
+
emphasis,
|
|
363
|
+
emphasizeLast,
|
|
364
|
+
zeroAsPaused,
|
|
365
|
+
}: BarArgs) {
|
|
366
|
+
if (values.length === 0 || w === 0 || h === 0) return null
|
|
367
|
+
|
|
368
|
+
const band = bandScale(values.length, w, 0.28)
|
|
369
|
+
const fractions = normalizeToMax(values)
|
|
370
|
+
|
|
371
|
+
// At h = 36 — `allocation-sparkbar.tsx`'s own default — the ratios resolve
|
|
372
|
+
// to exactly its arithmetic: floor 8, headroom 4, a bar spanning 8..32,
|
|
373
|
+
// i.e. `8 + f * (36 - 12)`. Below that height both scale with the box, so
|
|
374
|
+
// the floor never swallows the chart (an 8px floor in a 20px strip is 40%
|
|
375
|
+
// of the plot). The fractions are 2/9 and 1/9 rather than 0.22 and 0.11
|
|
376
|
+
// precisely so the 36px case lands on the integers and not on 7.92.
|
|
377
|
+
const floor = Math.min(8, (h * 2) / 9)
|
|
378
|
+
const headroom = Math.min(4, h / 9)
|
|
379
|
+
const cap = Math.max(floor, h - headroom)
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
{values.map((value, index) => {
|
|
384
|
+
const paused = zeroAsPaused && value === 0
|
|
385
|
+
const fraction = fractions[index] ?? 0
|
|
386
|
+
const barHeight = paused ? headroom : floor + fraction * (cap - floor)
|
|
387
|
+
|
|
388
|
+
// D7's scarcity rule, sized for this box: the emphasized bar is full
|
|
389
|
+
// strength and the rest are a tint. The tint is 0.55 rather than the
|
|
390
|
+
// full-size chart's 0.14 because at 60×20 a 14% fill is below the
|
|
391
|
+
// visibility floor — the same reason the point treatment inverts.
|
|
392
|
+
const tinted = emphasizeLast && index !== emphasis
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
key={index}
|
|
397
|
+
style={{
|
|
398
|
+
position: 'absolute',
|
|
399
|
+
left: band.at(index),
|
|
400
|
+
bottom: 0,
|
|
401
|
+
width: band.band,
|
|
402
|
+
height: barHeight,
|
|
403
|
+
// A lightly-rounded BAR, not a pill: --radius-xs on a 5px-wide
|
|
404
|
+
// bar clamps to a full capsule, so it is halved — the same
|
|
405
|
+
// correction ChartLegend makes on its 12px swatch.
|
|
406
|
+
borderRadius: 'calc(var(--radius-xs) / 2)',
|
|
407
|
+
background: paused ? 'rgb(var(--text-muted) / 0.18)' : color,
|
|
408
|
+
border: paused ? '1px dashed rgb(var(--text-muted) / 0.4)' : undefined,
|
|
409
|
+
opacity: !paused && tinted ? 0.55 : 1,
|
|
410
|
+
}}
|
|
411
|
+
/>
|
|
412
|
+
)
|
|
413
|
+
})}
|
|
414
|
+
</>
|
|
415
|
+
)
|
|
416
|
+
}
|