@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
package/src/lib/chart.ts
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chart.ts — the scale / tick / path arithmetic behind every chart primitive.
|
|
3
|
+
*
|
|
4
|
+
* Promoted in ADR-146 D6. Before this module the same seven pieces of
|
|
5
|
+
* arithmetic were inlined in seven places, each with its own edge-case
|
|
6
|
+
* behaviour (or none):
|
|
7
|
+
*
|
|
8
|
+
* • `completion-ring.tsx:58-64` circumference + dashoffset, and its clamp
|
|
9
|
+
* • `allocation-sparkbar.tsx:40,48` max-normalised bar height (`Math.max(...values, 1)`)
|
|
10
|
+
* • `progress-bar.tsx:27` the 0-100 clamp
|
|
11
|
+
* • `spec/shell-charts.tsx:63-91` the cumulative donut dasharray accumulator
|
|
12
|
+
* • `spec/shell-charts.tsx:134,151` ranked-bar + stacked-bar normalisation
|
|
13
|
+
* • `spec/shell-charts.tsx:174` stacked column totals (the `|| 1` divide guard)
|
|
14
|
+
* • `spec/shell-charts.tsx:236-254` linear scale, M/L path, area close, and the
|
|
15
|
+
* literal `[0.25, 0.5, 0.75]` gridline list
|
|
16
|
+
* • `budget-flighting/.../TimelineView.tsx:24-45` the month band domain
|
|
17
|
+
*
|
|
18
|
+
* `niceTicks` is the one function with no ancestor in the repo: we have never
|
|
19
|
+
* had a tick generator, which is exactly why every y-axis today is either a
|
|
20
|
+
* hardcoded fraction list or absent. It is the point of the module.
|
|
21
|
+
*
|
|
22
|
+
* CONSUMERS — measured 2026-09-06, all seven verified as real imports inside
|
|
23
|
+
* `@lovett/ui`: `line-chart.tsx` (`LineChart` / `AreaChart`),
|
|
24
|
+
* `bar-chart.tsx`, `donut-chart.tsx`, `ranked-bars.tsx`, `sparkline.tsx`,
|
|
25
|
+
* `gauge-ring.tsx`, `chart-frame.tsx` (`niceTicks`). This module is not on
|
|
26
|
+
* the public barrel, so it has no product consumers by design.
|
|
27
|
+
*
|
|
28
|
+
* Pure functions. No React, no DOM, no dependencies, no clock, no randomness —
|
|
29
|
+
* every function is deterministic for a given input.
|
|
30
|
+
*
|
|
31
|
+
* Token discipline: this module produces NUMBERS and SVG PATH DATA only. It
|
|
32
|
+
* never produces, reads, names or composes a colour, a token, or a CSS custom
|
|
33
|
+
* property (ADR-146 D8 — a constructed token name is invalid CSS that the
|
|
34
|
+
* browser silently discards). Series colour is resolved by a frozen literal
|
|
35
|
+
* lookup in the primitive, never here.
|
|
36
|
+
*
|
|
37
|
+
* THE OUTPUT CONTRACT — the whole reason this module exists
|
|
38
|
+
* ---------------------------------------------------------
|
|
39
|
+
* A chart library would have handled the degenerate cases for us. Hand-rolled
|
|
40
|
+
* SVG means we own them, so every function here is total:
|
|
41
|
+
*
|
|
42
|
+
* 1. **No output is ever `NaN` or `±Infinity`.** Every numeric return is
|
|
43
|
+
* finite. A `NaN` in an SVG attribute is not a visual bug — it drops the
|
|
44
|
+
* whole element, silently, with every gate green.
|
|
45
|
+
* 2. **Nothing divides by zero.** An empty series, an all-zero series and a
|
|
46
|
+
* `min === max` domain each have a documented, deliberate answer.
|
|
47
|
+
* 3. **Non-finite input is absorbed, not propagated.** `null`, `undefined`,
|
|
48
|
+
* `NaN` and `±Infinity` are read as `0` (or dropped, where dropping is the
|
|
49
|
+
* honest answer — see `linePath`).
|
|
50
|
+
* 4. **Negative values are clamped to zero in share/height maths**
|
|
51
|
+
* (`normalizeToMax`, `arcSegments`, `stackTotals`) because a bar cannot be
|
|
52
|
+
* shorter than nothing and an arc cannot sweep backwards. A chart that
|
|
53
|
+
* genuinely spans zero uses `linearScale` with a domain that includes it.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** What a caller may realistically hand us for one datum. */
|
|
57
|
+
export type NumericInput = number | null | undefined
|
|
58
|
+
|
|
59
|
+
/** A plotted point in screen space (y grows downward, as in SVG). */
|
|
60
|
+
export interface Point {
|
|
61
|
+
readonly x: number
|
|
62
|
+
readonly y: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `[start, end]`. `end` may be less than `start` (an inverted axis is legal). */
|
|
66
|
+
export type Extent = readonly [number, number]
|
|
67
|
+
|
|
68
|
+
/** Maps a domain value to a range position. Always returns a finite number. */
|
|
69
|
+
export type ScaleFn = (value: NumericInput) => number
|
|
70
|
+
|
|
71
|
+
/* ── internals ───────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
/** Read a datum as a finite number, or fall back. The absorber for rule 3. */
|
|
74
|
+
function finite(value: NumericInput, fallback = 0): number {
|
|
75
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Round to `decimals` places without ever returning a non-finite number.
|
|
80
|
+
* Keeps path data compact and — more importantly — deterministic, so path
|
|
81
|
+
* strings can be asserted directly in tests.
|
|
82
|
+
*/
|
|
83
|
+
function roundTo(value: number, decimals: number): number {
|
|
84
|
+
const factor = 10 ** Math.max(0, Math.min(12, Math.trunc(decimals)))
|
|
85
|
+
const scaled = value * factor
|
|
86
|
+
if (!Number.isFinite(scaled)) return value
|
|
87
|
+
const out = Math.round(scaled) / factor
|
|
88
|
+
return out === 0 ? 0 : out // normalise -0, which stringifies inconsistently
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One SVG path coordinate: finite, 2dp, no `-0`. */
|
|
92
|
+
function coord(value: number): string {
|
|
93
|
+
return String(roundTo(finite(value), 2))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drop points that cannot be plotted. See `linePath` for why dropping wins. */
|
|
97
|
+
function plottable(points: readonly Point[]): Point[] {
|
|
98
|
+
const out: Point[] = []
|
|
99
|
+
for (const p of points) {
|
|
100
|
+
if (Number.isFinite(p.x) && Number.isFinite(p.y)) out.push(p)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The "nice number" ladder: 1, 2, 5 × 10ⁿ (Heckbert). Given a rough step,
|
|
107
|
+
* returns the nearest nice step. Returns 0 for a non-positive input, which
|
|
108
|
+
* `niceTicks` treats as its degenerate branch.
|
|
109
|
+
*/
|
|
110
|
+
function niceStep(rough: number): number {
|
|
111
|
+
if (!(rough > 0) || !Number.isFinite(rough)) return 0
|
|
112
|
+
const exponent = Math.floor(Math.log10(rough))
|
|
113
|
+
const magnitude = 10 ** exponent
|
|
114
|
+
const fraction = rough / magnitude
|
|
115
|
+
const nice = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10
|
|
116
|
+
return nice * magnitude
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How many decimals a tick on `step` needs. Capped; see `niceTicks`. */
|
|
120
|
+
function decimalsForStep(step: number): number {
|
|
121
|
+
if (!(step > 0)) return 0
|
|
122
|
+
return -Math.floor(Math.log10(step))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── scales ──────────────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A continuous linear scale: `domain → range`.
|
|
129
|
+
*
|
|
130
|
+
* Not clamped — a value outside the domain maps outside the range, which is
|
|
131
|
+
* what an out-of-domain point should do (the caller decides whether to clip).
|
|
132
|
+
*
|
|
133
|
+
* Edge cases, all deliberate:
|
|
134
|
+
* - **`min === max` (every value identical, or a single datum):** there is no
|
|
135
|
+
* span to scale against, so *every* input maps to the **midpoint of the
|
|
136
|
+
* range**. A flat series draws through the middle of the plot rather than
|
|
137
|
+
* pinned to an edge, and nothing divides by zero.
|
|
138
|
+
* - **Non-finite input value:** maps to `range[0]`. There is no honest
|
|
139
|
+
* position for a missing datum; `range[0]` is finite and predictable.
|
|
140
|
+
* Filter or drop such points before plotting if a gap is what you want.
|
|
141
|
+
* - **Non-finite domain or range bound:** read as `0`.
|
|
142
|
+
* - **Overflow:** if the arithmetic would produce `±Infinity` (an absurd
|
|
143
|
+
* domain/range magnitude), returns `range[0]` rather than propagating.
|
|
144
|
+
*/
|
|
145
|
+
export function linearScale(domain: Extent, range: Extent): ScaleFn {
|
|
146
|
+
const d0 = finite(domain[0])
|
|
147
|
+
const d1 = finite(domain[1])
|
|
148
|
+
const r0 = finite(range[0])
|
|
149
|
+
const r1 = finite(range[1])
|
|
150
|
+
|
|
151
|
+
const span = d1 - d0
|
|
152
|
+
if (span === 0 || !Number.isFinite(span)) {
|
|
153
|
+
const mid = (r0 + r1) / 2
|
|
154
|
+
const flat = Number.isFinite(mid) ? mid : r0
|
|
155
|
+
return () => flat
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const slope = (r1 - r0) / span
|
|
159
|
+
return (value: NumericInput): number => {
|
|
160
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return r0
|
|
161
|
+
const out = r0 + (value - d0) * slope
|
|
162
|
+
return Number.isFinite(out) ? out : r0
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What `bandScale` hands back. `band` is the mark width; `step` includes the gap. */
|
|
167
|
+
export interface BandScale {
|
|
168
|
+
/** Width of one mark (bar / column). Never negative, never non-finite. */
|
|
169
|
+
readonly band: number
|
|
170
|
+
/** Distance between the left edges of adjacent marks (`band` + gap). */
|
|
171
|
+
readonly step: number
|
|
172
|
+
/** Left edge of mark `index`. Index is clamped into `[0, count - 1]`. */
|
|
173
|
+
at(index: NumericInput): number
|
|
174
|
+
/** Centre of mark `index` — the x for a point/line over a categorical axis. */
|
|
175
|
+
center(index: NumericInput): number
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A categorical band scale over `count` marks across `width` pixels.
|
|
180
|
+
*
|
|
181
|
+
* `step = width / count`, `band = step × (1 − paddingRatio)`, and each band is
|
|
182
|
+
* centred inside its step — so the gap is split evenly and the strip carries a
|
|
183
|
+
* half-gap of outer padding at each end. That is the layout every hand-rolled
|
|
184
|
+
* bar strip in the repo was approximating with `flex: 1` and a `gap`.
|
|
185
|
+
*
|
|
186
|
+
* Edge cases:
|
|
187
|
+
* - **`count <= 0`, or a non-finite `count`/`width`:** `band` and `step` are
|
|
188
|
+
* `0` and `at`/`center` return `0`. An empty chart draws nothing; it does
|
|
189
|
+
* not divide by zero.
|
|
190
|
+
* - **`count === 1`:** the single band takes the full width less its padding
|
|
191
|
+
* and sits centred. A one-category bar chart is a legal chart.
|
|
192
|
+
* - **`paddingRatio`** is clamped to `[0, 1]`. At `1` the band is `0` wide —
|
|
193
|
+
* that is what was asked for, not an error.
|
|
194
|
+
* - **`index` out of range, fractional, or non-finite:** floored and clamped
|
|
195
|
+
* into `[0, count - 1]`. Never `NaN`.
|
|
196
|
+
*/
|
|
197
|
+
export function bandScale(
|
|
198
|
+
count: NumericInput,
|
|
199
|
+
width: NumericInput,
|
|
200
|
+
paddingRatio: NumericInput = 0.2,
|
|
201
|
+
): BandScale {
|
|
202
|
+
const n = Math.max(0, Math.floor(finite(count)))
|
|
203
|
+
const w = Math.max(0, finite(width))
|
|
204
|
+
const padding = Math.max(0, Math.min(1, finite(paddingRatio)))
|
|
205
|
+
|
|
206
|
+
if (n === 0 || w === 0) {
|
|
207
|
+
return { band: 0, step: 0, at: () => 0, center: () => 0 }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const step = w / n
|
|
211
|
+
const band = step * (1 - padding)
|
|
212
|
+
const inset = (step - band) / 2
|
|
213
|
+
|
|
214
|
+
const clampIndex = (index: NumericInput): number =>
|
|
215
|
+
Math.max(0, Math.min(n - 1, Math.floor(finite(index))))
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
band,
|
|
219
|
+
step,
|
|
220
|
+
at: (index) => clampIndex(index) * step + inset,
|
|
221
|
+
center: (index) => clampIndex(index) * step + inset + band / 2,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ── ticks ───────────────────────────────────────────────────────────── */
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Nice-number axis ticks over `[min, max]`, aiming for about `targetCount`.
|
|
229
|
+
*
|
|
230
|
+
* Steps come off the 1 / 2 / 5 × 10ⁿ ladder, and the first and last tick are
|
|
231
|
+
* the *rounded-out* bounds — so **the returned ticks may extend beyond `min`
|
|
232
|
+
* and `max`, and that is the point**: use `ticks[0]` and the last tick as the
|
|
233
|
+
* plot's actual domain and the gridlines land on round numbers.
|
|
234
|
+
*
|
|
235
|
+
* This replaces the literal `[0.25, 0.5, 0.75]` gridline fraction list.
|
|
236
|
+
*
|
|
237
|
+
* Edge cases:
|
|
238
|
+
* - **`min === max`** (a flat series, or a single datum): returns exactly
|
|
239
|
+
* `[min]` — one gridline at the value. Matches `d3.ticks(x, x, n)`. It is
|
|
240
|
+
* honest: there is no range to divide.
|
|
241
|
+
* - **`min > max`:** the bounds are swapped, then treated normally.
|
|
242
|
+
* - **Non-finite `min`/`max`:** read as `0`, so `niceTicks(NaN, NaN)` is
|
|
243
|
+
* `[0]`.
|
|
244
|
+
* - **`targetCount` below 2, fractional, or non-finite:** clamped/floored to a
|
|
245
|
+
* minimum of 2. An axis needs at least two ticks to have a step at all.
|
|
246
|
+
* - **Very small magnitudes:** ticks are rounded to the step's own decimal
|
|
247
|
+
* place, so `0.1, 0.2, 0.30000000000000004` never happens. Below `1e-12`
|
|
248
|
+
* rounding is skipped rather than collapsing distinct ticks onto each other.
|
|
249
|
+
* - **Very large magnitudes:** steps stay exact multiples; no rounding is
|
|
250
|
+
* applied where it would be a no-op.
|
|
251
|
+
* - The emitted count is hard-capped at 1000 so no input can produce an
|
|
252
|
+
* unbounded array.
|
|
253
|
+
*/
|
|
254
|
+
export function niceTicks(
|
|
255
|
+
min: NumericInput,
|
|
256
|
+
max: NumericInput,
|
|
257
|
+
targetCount: NumericInput = 5,
|
|
258
|
+
): number[] {
|
|
259
|
+
const a = finite(min)
|
|
260
|
+
const b = finite(max)
|
|
261
|
+
const lo = Math.min(a, b)
|
|
262
|
+
const hi = Math.max(a, b)
|
|
263
|
+
|
|
264
|
+
if (lo === hi) return [lo]
|
|
265
|
+
|
|
266
|
+
const count = Math.max(2, Math.floor(finite(targetCount, 5)))
|
|
267
|
+
const step = niceStep((hi - lo) / (count - 1))
|
|
268
|
+
if (step <= 0 || !Number.isFinite(step)) return [lo, hi]
|
|
269
|
+
|
|
270
|
+
// The 1e-9 slack is load-bearing, not defensive. `0.3 / 0.1` is
|
|
271
|
+
// 2.9999999999999996 in IEEE 754, so a bare `Math.floor` reads 2 and the
|
|
272
|
+
// axis for a 0.3..0.7 domain starts at a phantom 0.2 tick. The ceiling
|
|
273
|
+
// carries the same slack for the symmetric case.
|
|
274
|
+
// Covered by the `0.3..0.7` case in chart-math.test.ts — remove either
|
|
275
|
+
// epsilon and that test goes red.
|
|
276
|
+
const start = Math.floor(lo / step + 1e-9) * step
|
|
277
|
+
const end = Math.ceil(hi / step - 1e-9) * step
|
|
278
|
+
const spanSteps = Math.round((end - start) / step)
|
|
279
|
+
if (!Number.isFinite(spanSteps) || spanSteps < 1) return [lo, hi]
|
|
280
|
+
|
|
281
|
+
const decimals = decimalsForStep(step)
|
|
282
|
+
const round = decimals <= 12
|
|
283
|
+
const total = Math.min(spanSteps, 1000)
|
|
284
|
+
|
|
285
|
+
const ticks: number[] = []
|
|
286
|
+
for (let i = 0; i <= total; i++) {
|
|
287
|
+
const raw = start + i * step
|
|
288
|
+
ticks.push(round ? roundTo(raw, decimals) : raw)
|
|
289
|
+
}
|
|
290
|
+
return ticks
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ── paths ───────────────────────────────────────────────────────────── */
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* SVG path data for a polyline through `points` (`M` then `L`s).
|
|
297
|
+
*
|
|
298
|
+
* Coordinates are rounded to 2dp — compact, and deterministic enough to
|
|
299
|
+
* assert a whole path string in a test.
|
|
300
|
+
*
|
|
301
|
+
* Edge cases:
|
|
302
|
+
* - **Empty input:** returns `''`. An empty `d` renders nothing, which is the
|
|
303
|
+
* correct picture of no data. It is also the only value that cannot throw.
|
|
304
|
+
* - **A single point:** returns `M x,y L x,y` — a zero-length segment, which
|
|
305
|
+
* with `stroke-linecap="round"` paints a dot. A one-datum series is visible
|
|
306
|
+
* rather than silently blank (ADR-146 acceptance criterion 8).
|
|
307
|
+
* - **Points with a non-finite `x` or `y`:** dropped. The line closes over the
|
|
308
|
+
* gap. Dropping beats emitting `NaN`, which would void the entire `<path>`.
|
|
309
|
+
* A caller that wants a visible break should split the series and emit two
|
|
310
|
+
* paths.
|
|
311
|
+
*/
|
|
312
|
+
export function linePath(points: readonly Point[]): string {
|
|
313
|
+
const pts = plottable(points)
|
|
314
|
+
const [first] = pts
|
|
315
|
+
if (first === undefined) return ''
|
|
316
|
+
|
|
317
|
+
const head = `M${coord(first.x)},${coord(first.y)}`
|
|
318
|
+
if (pts.length === 1) return `${head} L${coord(first.x)},${coord(first.y)}`
|
|
319
|
+
|
|
320
|
+
let d = head
|
|
321
|
+
for (let i = 1; i < pts.length; i++) {
|
|
322
|
+
const p = pts[i]
|
|
323
|
+
if (p === undefined) continue
|
|
324
|
+
d += ` L${coord(p.x)},${coord(p.y)}`
|
|
325
|
+
}
|
|
326
|
+
return d
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* SVG path data for the filled area between the polyline and `baselineY`
|
|
331
|
+
* (the line, then down to the baseline, back along it, and closed).
|
|
332
|
+
*
|
|
333
|
+
* Edge cases:
|
|
334
|
+
* - **Fewer than two plottable points:** returns `''`. A single point has no
|
|
335
|
+
* area; the line path still renders its dot. This is deliberate rather than
|
|
336
|
+
* emitting a zero-width sliver that reads as a stray tick.
|
|
337
|
+
* - **Non-finite `baselineY`:** read as `0`.
|
|
338
|
+
* - Same point-dropping rule as `linePath`.
|
|
339
|
+
*/
|
|
340
|
+
export function areaPath(points: readonly Point[], baselineY: NumericInput): string {
|
|
341
|
+
const pts = plottable(points)
|
|
342
|
+
if (pts.length < 2) return ''
|
|
343
|
+
|
|
344
|
+
const [first] = pts
|
|
345
|
+
const last = pts[pts.length - 1]
|
|
346
|
+
if (first === undefined || last === undefined) return ''
|
|
347
|
+
|
|
348
|
+
const base = coord(finite(baselineY))
|
|
349
|
+
return `${linePath(pts)} L${coord(last.x)},${base} L${coord(first.x)},${base} Z`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* ── arcs ────────────────────────────────────────────────────────────── */
|
|
353
|
+
|
|
354
|
+
/** The stroked-circle geometry a donut or ring arc is drawn on. */
|
|
355
|
+
export interface ArcGeometry {
|
|
356
|
+
/** Centreline radius — what goes on the `<circle r>`. Never negative. */
|
|
357
|
+
readonly radius: number
|
|
358
|
+
/** `2πr` of the centreline: the dash length of a full turn. */
|
|
359
|
+
readonly circumference: number
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Centreline radius and circumference for a ring of `outerRadius` drawn with a
|
|
364
|
+
* `strokeWidth` stroke. A stroke straddles its path, so the centreline sits
|
|
365
|
+
* half a stroke inside the outer edge — this is `completion-ring.tsx:60`'s
|
|
366
|
+
* `(size - sw) / 2`, generalised.
|
|
367
|
+
*
|
|
368
|
+
* Edge cases: negative or non-finite inputs read as `0`; a stroke wider than
|
|
369
|
+
* the ring clamps the radius to `0` (circumference `0`) instead of going
|
|
370
|
+
* negative and producing a backwards dash; and the radius is capped at the
|
|
371
|
+
* largest value whose circumference is still a finite double, so an absurd
|
|
372
|
+
* radius cannot hand `2πr === Infinity` to a `stroke-dasharray`.
|
|
373
|
+
*/
|
|
374
|
+
export function arcGeometry(
|
|
375
|
+
outerRadius: NumericInput,
|
|
376
|
+
strokeWidth: NumericInput = 0,
|
|
377
|
+
): ArcGeometry {
|
|
378
|
+
const outer = Math.max(0, finite(outerRadius))
|
|
379
|
+
const stroke = Math.max(0, finite(strokeWidth))
|
|
380
|
+
const radius = Math.min(MAX_ARC_RADIUS, Math.max(0, outer - stroke / 2))
|
|
381
|
+
return { radius, circumference: 2 * Math.PI * radius }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Beyond this, `2πr` overflows to `Infinity` and every dash becomes `NaN`. */
|
|
385
|
+
const MAX_ARC_RADIUS = Number.MAX_VALUE / (2 * Math.PI)
|
|
386
|
+
|
|
387
|
+
/** One donut slice, ready to drop onto a `<circle>`. */
|
|
388
|
+
export interface ArcSegment {
|
|
389
|
+
/** Share of the total, `0..1`. */
|
|
390
|
+
readonly fraction: number
|
|
391
|
+
/** Cumulative share *before* this slice, `0..1` — the slice's start angle. */
|
|
392
|
+
readonly startFraction: number
|
|
393
|
+
/** `stroke-dasharray`: `"<dash> <gap>"`. */
|
|
394
|
+
readonly dasharray: string
|
|
395
|
+
/** `stroke-dashoffset` — negative or zero, never `-0`. */
|
|
396
|
+
readonly dashoffset: number
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The cumulative donut accumulator: one `stroke-dasharray` /
|
|
401
|
+
* `stroke-dashoffset` pair per slice, drawn on a single circle of
|
|
402
|
+
* `arcGeometry(outerRadius, strokeWidth)`. Written twice by hand before this
|
|
403
|
+
* (`shell-charts.tsx:63-91` and the geo ring).
|
|
404
|
+
*
|
|
405
|
+
* Slices are laid head-to-tail in input order; rotate the `<circle>` by -90°
|
|
406
|
+
* to start at twelve o'clock, exactly as the two hand-rolled versions did.
|
|
407
|
+
*
|
|
408
|
+
* Edge cases:
|
|
409
|
+
* - **Empty input:** `[]`.
|
|
410
|
+
* - **All values zero** (or all non-positive): every `fraction` is `0` and
|
|
411
|
+
* every dash is `0` — the ring renders empty. It does **not** divide by
|
|
412
|
+
* zero, and it does not silently split the ring evenly, which would invent
|
|
413
|
+
* data that isn't there.
|
|
414
|
+
* - **One category:** a single slice with `fraction === 1`, `dasharray`
|
|
415
|
+
* `"<C> 0"`, `dashoffset 0` — a complete ring.
|
|
416
|
+
* - **Negative values:** clamped to `0` and excluded from the total. An arc
|
|
417
|
+
* cannot sweep backwards, and a share chart with negative shares is a
|
|
418
|
+
* different chart.
|
|
419
|
+
* - **Non-finite values:** read as `0`.
|
|
420
|
+
* - **A zero or negative radius:** circumference `0`, so every dash is `0`.
|
|
421
|
+
* Fractions are still correct, so a legend built off them stays right.
|
|
422
|
+
*/
|
|
423
|
+
export function arcSegments(
|
|
424
|
+
values: readonly NumericInput[],
|
|
425
|
+
outerRadius: NumericInput,
|
|
426
|
+
strokeWidth: NumericInput = 0,
|
|
427
|
+
): ArcSegment[] {
|
|
428
|
+
const { circumference } = arcGeometry(outerRadius, strokeWidth)
|
|
429
|
+
|
|
430
|
+
const positive = values.map((value) => {
|
|
431
|
+
const v = finite(value)
|
|
432
|
+
return v > 0 ? v : 0
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
let total = 0
|
|
436
|
+
for (const v of positive) total += v
|
|
437
|
+
|
|
438
|
+
let cursor = 0
|
|
439
|
+
return positive.map((v) => {
|
|
440
|
+
const fraction = total > 0 ? v / total : 0
|
|
441
|
+
const dash = roundTo(fraction * circumference, 3)
|
|
442
|
+
const gap = roundTo(Math.max(0, circumference - fraction * circumference), 3)
|
|
443
|
+
const segment: ArcSegment = {
|
|
444
|
+
fraction,
|
|
445
|
+
startFraction: cursor,
|
|
446
|
+
dasharray: `${dash} ${gap}`,
|
|
447
|
+
dashoffset: cursor === 0 ? 0 : roundTo(-cursor * circumference, 3),
|
|
448
|
+
}
|
|
449
|
+
cursor += fraction
|
|
450
|
+
return segment
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A single-arc progress ring: the whole `<circle>` dash contract. */
|
|
455
|
+
export interface RingDash extends ArcGeometry {
|
|
456
|
+
/** `stroke-dasharray` — the full circumference. */
|
|
457
|
+
readonly dasharray: number
|
|
458
|
+
/** `stroke-dashoffset` — how much of the turn stays unpainted. */
|
|
459
|
+
readonly dashoffset: number
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The one-arc case of `arcSegments`: a 0-100 progress ring
|
|
464
|
+
* (`completion-ring.tsx:58-64`, and its hand-rolled twin in the geo panel).
|
|
465
|
+
*
|
|
466
|
+
* Edge cases: `percent` runs through `clampPct`, so `NaN`, `null`, `-20` and
|
|
467
|
+
* `140` are `0`, `0`, `0` and `100`. A zero radius yields dashes of `0`.
|
|
468
|
+
*/
|
|
469
|
+
export function ringDash(
|
|
470
|
+
percent: NumericInput,
|
|
471
|
+
outerRadius: NumericInput,
|
|
472
|
+
strokeWidth: NumericInput = 0,
|
|
473
|
+
): RingDash {
|
|
474
|
+
const geometry = arcGeometry(outerRadius, strokeWidth)
|
|
475
|
+
const pct = clampPct(percent)
|
|
476
|
+
return {
|
|
477
|
+
...geometry,
|
|
478
|
+
dasharray: roundTo(geometry.circumference, 3),
|
|
479
|
+
dashoffset: roundTo(geometry.circumference * (1 - pct / 100), 3),
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* ── normalisation ───────────────────────────────────────────────────── */
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Clamp to `0..100`. Written three times before this
|
|
487
|
+
* (`progress-bar.tsx:27`, `completion-ring.tsx:58`, and the geo ring).
|
|
488
|
+
*
|
|
489
|
+
* Non-finite input — `NaN`, `±Infinity`, `null`, `undefined` — is `0`, not
|
|
490
|
+
* `NaN`. A progress bar with an unknown value reads as empty, never as a
|
|
491
|
+
* dropped element.
|
|
492
|
+
*/
|
|
493
|
+
export function clampPct(value: NumericInput): number {
|
|
494
|
+
const v = finite(value)
|
|
495
|
+
if (v < 0) return 0
|
|
496
|
+
if (v > 100) return 100
|
|
497
|
+
return v
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Each value as a fraction `0..1` of the **largest** value in the array — the
|
|
502
|
+
* bar-height normalisation behind `allocation-sparkbar.tsx:40` and
|
|
503
|
+
* `shell-charts.tsx:134`, both of which used `Math.max(...values, 1)`.
|
|
504
|
+
*
|
|
505
|
+
* Two improvements over the inlined versions:
|
|
506
|
+
* - No spread. `Math.max(...values)` overflows the call stack on a large
|
|
507
|
+
* array; this walks it.
|
|
508
|
+
* - No `, 1` fudge. That guard silently rescaled any series whose max was
|
|
509
|
+
* below 1 (a set of ratios, a set of percentages-as-fractions) against a
|
|
510
|
+
* phantom maximum, flattening the whole chart.
|
|
511
|
+
*
|
|
512
|
+
* Edge cases:
|
|
513
|
+
* - **Empty input:** `[]`.
|
|
514
|
+
* - **All values zero, or all non-positive:** every fraction is `0` — a flat
|
|
515
|
+
* empty strip. No division happens.
|
|
516
|
+
* - **All values identical and positive:** every fraction is `1` — a flat
|
|
517
|
+
* full strip, which is the truth.
|
|
518
|
+
* - **Negative values:** clamped to `0`. A bar cannot be shorter than nothing;
|
|
519
|
+
* a series that genuinely crosses zero belongs on `linearScale` with a
|
|
520
|
+
* domain that spans it.
|
|
521
|
+
* - **Non-finite values:** read as `0`.
|
|
522
|
+
* - Output is always within `0..1` inclusive.
|
|
523
|
+
*/
|
|
524
|
+
export function normalizeToMax(values: readonly NumericInput[]): number[] {
|
|
525
|
+
let max = 0
|
|
526
|
+
for (const value of values) {
|
|
527
|
+
const v = finite(value)
|
|
528
|
+
if (v > max) max = v
|
|
529
|
+
}
|
|
530
|
+
if (max <= 0) return values.map(() => 0)
|
|
531
|
+
return values.map((value) => {
|
|
532
|
+
const v = finite(value)
|
|
533
|
+
if (v <= 0) return 0
|
|
534
|
+
if (v >= max) return 1
|
|
535
|
+
return v / max
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Column totals for a stacked chart — `shell-charts.tsx:174`, minus its
|
|
541
|
+
* `|| 1` divide guard (which quietly turned an empty column into a full-height
|
|
542
|
+
* one).
|
|
543
|
+
*
|
|
544
|
+
* `series` is row-major: one row per series, one entry per column. Rows may be
|
|
545
|
+
* ragged; the result is as long as the longest row, and a short row
|
|
546
|
+
* contributes nothing to the columns it does not reach.
|
|
547
|
+
*
|
|
548
|
+
* Negative and non-finite entries contribute `0`, for the same reason as
|
|
549
|
+
* `normalizeToMax`. An all-zero column totals `0` — see `stackFractions` for
|
|
550
|
+
* how that is then divided safely.
|
|
551
|
+
*/
|
|
552
|
+
export function stackTotals(series: readonly (readonly NumericInput[])[]): number[] {
|
|
553
|
+
let columns = 0
|
|
554
|
+
for (const row of series) {
|
|
555
|
+
if (row.length > columns) columns = row.length
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const totals: number[] = new Array<number>(columns).fill(0)
|
|
559
|
+
for (const row of series) {
|
|
560
|
+
for (let i = 0; i < row.length; i++) {
|
|
561
|
+
const v = finite(row[i])
|
|
562
|
+
if (v > 0) totals[i] = (totals[i] ?? 0) + v
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return totals
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Each entry as its share `0..1` of its own column total — the 100%-stacked
|
|
570
|
+
* bar normalisation, with the divide-by-zero handled once instead of per call
|
|
571
|
+
* site.
|
|
572
|
+
*
|
|
573
|
+
* Shape is preserved exactly: same number of rows, each row the same length it
|
|
574
|
+
* came in as (ragged input stays ragged).
|
|
575
|
+
*
|
|
576
|
+
* Edge cases:
|
|
577
|
+
* - **An all-zero column:** every entry in it is `0`. The column renders
|
|
578
|
+
* empty rather than being invented into a full bar by a `|| 1`.
|
|
579
|
+
* - **Negative / non-finite entries:** `0`, and excluded from the total.
|
|
580
|
+
* - Every column that has any positive value sums to `1` (within float
|
|
581
|
+
* epsilon).
|
|
582
|
+
*/
|
|
583
|
+
export function stackFractions(
|
|
584
|
+
series: readonly (readonly NumericInput[])[],
|
|
585
|
+
): number[][] {
|
|
586
|
+
const totals = stackTotals(series)
|
|
587
|
+
return series.map((row) =>
|
|
588
|
+
row.map((value, i) => {
|
|
589
|
+
const v = finite(value)
|
|
590
|
+
const total = totals[i] ?? 0
|
|
591
|
+
// `total <= 0` is unreachable while `stackTotals` sums this same array
|
|
592
|
+
// (a positive `v` is inside its own column total). It stays as a divide
|
|
593
|
+
// guard for the day the totals come from somewhere else — mutation
|
|
594
|
+
// testing confirms only the `v <= 0` arm is load-bearing today.
|
|
595
|
+
if (v <= 0 || total <= 0) return 0
|
|
596
|
+
return v >= total ? 1 : v / total
|
|
597
|
+
}),
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* ── temporal domain ─────────────────────────────────────────────────── */
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The inclusive month band domain between two `YYYY-MM-DD` dates, as
|
|
605
|
+
* `YYYY-MM-01` strings — the generator inlined at `TimelineView.tsx:24-45`.
|
|
606
|
+
*
|
|
607
|
+
* Parsed as integers off the string rather than through `new Date(...)`: the
|
|
608
|
+
* original relied on a `T00:00:00` suffix to dodge UTC parsing, which makes
|
|
609
|
+
* the result depend on the machine's timezone. This is timezone-free and
|
|
610
|
+
* therefore deterministic in tests and in every deployment region.
|
|
611
|
+
*
|
|
612
|
+
* Edge cases:
|
|
613
|
+
* - **Either bound unparseable** (wrong shape, month outside `01..12`):
|
|
614
|
+
* returns `[]`. A chart with no domain draws its empty state.
|
|
615
|
+
* - **`start` after `end`:** `[]` — an inverted range is a caller bug, not a
|
|
616
|
+
* reversed axis.
|
|
617
|
+
* - **`start` and `end` in the same month:** a single-entry domain.
|
|
618
|
+
* - Spans are capped at 1200 months (100 years) so a typo'd year cannot
|
|
619
|
+
* allocate an unbounded array.
|
|
620
|
+
*/
|
|
621
|
+
export function monthDomain(startISO: string, endISO: string): string[] {
|
|
622
|
+
const start = monthIndex(startISO)
|
|
623
|
+
const end = monthIndex(endISO)
|
|
624
|
+
if (start === null || end === null || end < start) return []
|
|
625
|
+
|
|
626
|
+
const count = Math.min(end - start + 1, 1200)
|
|
627
|
+
const months: string[] = []
|
|
628
|
+
for (let i = 0; i < count; i++) {
|
|
629
|
+
const index = start + i
|
|
630
|
+
const year = Math.floor(index / 12)
|
|
631
|
+
const month = (index % 12) + 1
|
|
632
|
+
months.push(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01`)
|
|
633
|
+
}
|
|
634
|
+
return months
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** `YYYY-MM-…` → months since year 0, or `null` if it is not a date we can read. */
|
|
638
|
+
function monthIndex(iso: string): number | null {
|
|
639
|
+
const match = /^(\d{4})-(\d{2})/.exec(iso)
|
|
640
|
+
if (match === null) return null
|
|
641
|
+
const [, yearText, monthText] = match
|
|
642
|
+
if (yearText === undefined || monthText === undefined) return null
|
|
643
|
+
const year = Number(yearText)
|
|
644
|
+
const month = Number(monthText)
|
|
645
|
+
if (!Number.isFinite(year) || month < 1 || month > 12) return null
|
|
646
|
+
return year * 12 + (month - 1)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ── axis fitting ────────────────────────────────────────────────────── */
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* TEXT WIDTH ESTIMATION — why it is an estimate, and why the estimate is
|
|
653
|
+
* deliberately generous.
|
|
654
|
+
*
|
|
655
|
+
* An SVG `<text>` has no layout box until it is in the document, and a tick
|
|
656
|
+
* label's width is not known until after the tick count has been chosen — so
|
|
657
|
+
* a chart cannot measure its way out of "how many labels fit" without a
|
|
658
|
+
* render pass whose only job is to be thrown away. Every advance below was
|
|
659
|
+
* MEASURED in Chromium off the shipped gallery (`getBoundingClientRect` on
|
|
660
|
+
* real labels, 2026-09-06) and then rounded UP, because the failure modes are
|
|
661
|
+
* asymmetric: over-estimating drops one label that would have fitted, while
|
|
662
|
+
* under-estimating ships the overlap this module exists to prevent.
|
|
663
|
+
*
|
|
664
|
+
* 14px, weight 400 "Wk 1" 32.33px "Wk 10" 40.91px → digit 8.58
|
|
665
|
+
* 12px, weight 600 "8,240" 34.28px "13,390" 42.05px → digit 7.77
|
|
666
|
+
* 12px, weight 600 "96.2%" 38.58px "$17,460" 49.86px
|
|
667
|
+
*
|
|
668
|
+
* Ratios to font size, rounded up from those: a digit/letter runs ~0.61-0.65
|
|
669
|
+
* em, so 0.68; `,` and `.` ~0.27 em, so 0.32; `%` is the outlier at ~1.0 em,
|
|
670
|
+
* so 1.05. Checked back against every string above, the estimate is 0-6% HIGH
|
|
671
|
+
* in each case and never low.
|
|
672
|
+
*/
|
|
673
|
+
const ADVANCE_WIDE = 0.68
|
|
674
|
+
const ADVANCE_PUNCT = 0.32
|
|
675
|
+
const ADVANCE_PERCENT = 1.05
|
|
676
|
+
const ADVANCE_SPACE = 0.3
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Approximate rendered width of `text` at `fontSizePx`, in pixels.
|
|
680
|
+
*
|
|
681
|
+
* Deterministic, font-independent and total: an empty string is `0`, a
|
|
682
|
+
* non-finite font size is `0`. See the block above for where the per-glyph
|
|
683
|
+
* advances come from and why they round up.
|
|
684
|
+
*/
|
|
685
|
+
export function estimateTextWidth(text: string, fontSizePx: NumericInput): number {
|
|
686
|
+
const size = Math.max(0, finite(fontSizePx))
|
|
687
|
+
if (size === 0 || text.length === 0) return 0
|
|
688
|
+
|
|
689
|
+
let em = 0
|
|
690
|
+
for (const char of text) {
|
|
691
|
+
if (char === '%') em += ADVANCE_PERCENT
|
|
692
|
+
else if (char === ',' || char === '.' || char === ':') em += ADVANCE_PUNCT
|
|
693
|
+
else if (char === ' ') em += ADVANCE_SPACE
|
|
694
|
+
else em += ADVANCE_WIDE
|
|
695
|
+
}
|
|
696
|
+
return em * size
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Never fewer than two ticks: below that an axis has no step at all. */
|
|
700
|
+
const MIN_AXIS_TICKS = 2
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Most ticks any axis will draw, however much room it has.
|
|
704
|
+
*
|
|
705
|
+
* Past about six the ladder stops being read as a ladder and starts being
|
|
706
|
+
* read as texture, and every extra gridline is one more rule competing with
|
|
707
|
+
* the marks. ADR-146 D3 makes the gridline a structure token precisely so it
|
|
708
|
+
* stays quiet; drawing twelve of them undoes that.
|
|
709
|
+
*/
|
|
710
|
+
const MAX_AXIS_TICKS = 6
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* How many ticks fit along an axis of `availablePx` when adjacent ticks must
|
|
714
|
+
* sit at least `minSpacingPx` apart — the case where labels sit AT the tick,
|
|
715
|
+
* so the constraint is the SPACING between them, not their own width.
|
|
716
|
+
*
|
|
717
|
+
* `n` ticks spread over `available` are `available / (n - 1)` apart, so
|
|
718
|
+
* `n <= available / minSpacing + 1`.
|
|
719
|
+
*
|
|
720
|
+
* The caller owns what "far enough apart" means, because it differs by axis:
|
|
721
|
+
* a vertical ladder needs about two label line-heights
|
|
722
|
+
* (`Y_TICK_MIN_SPACING_PX`), while a horizontal strip needs one label WIDTH
|
|
723
|
+
* plus a gap. Baking one of those in would have made the other wrong.
|
|
724
|
+
*
|
|
725
|
+
* Edge cases:
|
|
726
|
+
* - **Unmeasured** (`availablePx <= 0`) or an unknown spacing
|
|
727
|
+
* (`minSpacingPx <= 0`): returns `requested` unchanged. A first paint
|
|
728
|
+
* before the ResizeObserver lands must not collapse the axis to two ticks
|
|
729
|
+
* and then visibly re-expand.
|
|
730
|
+
* - The result is always within `[2, min(requested, 6)]`, so a caller asking
|
|
731
|
+
* for fewer than two gets two, and one asking for twelve gets six.
|
|
732
|
+
*/
|
|
733
|
+
export function fitTickCount(
|
|
734
|
+
availablePx: NumericInput,
|
|
735
|
+
minSpacingPx: NumericInput,
|
|
736
|
+
requested: NumericInput = 5,
|
|
737
|
+
): number {
|
|
738
|
+
const want = Math.max(
|
|
739
|
+
MIN_AXIS_TICKS,
|
|
740
|
+
Math.min(MAX_AXIS_TICKS, Math.floor(finite(requested, 5))),
|
|
741
|
+
)
|
|
742
|
+
const available = finite(availablePx)
|
|
743
|
+
const spacing = finite(minSpacingPx)
|
|
744
|
+
if (!(available > 0) || !(spacing > 0)) return want
|
|
745
|
+
|
|
746
|
+
const capacity = Math.floor(available / spacing) + 1
|
|
747
|
+
return Math.max(MIN_AXIS_TICKS, Math.min(want, capacity))
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* How many labels fit side by side along `availablePx` when each occupies
|
|
752
|
+
* `labelExtentPx` — the horizontal case, where labels sit BESIDE each other
|
|
753
|
+
* and the constraint is their own width plus a gap.
|
|
754
|
+
*
|
|
755
|
+
* Edge cases mirror `fitTickCount`: an unmeasured axis or an unknown label
|
|
756
|
+
* width returns `requested`, and the result never drops below `minCount`.
|
|
757
|
+
*
|
|
758
|
+
* `minCount` may be `0`, which is how a caller asks the honest question "do
|
|
759
|
+
* even two of these fit?" — a 26px-wide plot holds no 32px label, and two of
|
|
760
|
+
* them overlapped by 10px until the caller was able to find that out.
|
|
761
|
+
*/
|
|
762
|
+
export function fitLabelCount(
|
|
763
|
+
availablePx: NumericInput,
|
|
764
|
+
labelExtentPx: NumericInput,
|
|
765
|
+
requested: NumericInput,
|
|
766
|
+
minCount: NumericInput = 2,
|
|
767
|
+
): number {
|
|
768
|
+
const floor = Math.max(0, Math.floor(finite(minCount, 2)))
|
|
769
|
+
const want = Math.max(floor, Math.floor(finite(requested, floor)))
|
|
770
|
+
const available = finite(availablePx)
|
|
771
|
+
const extent = finite(labelExtentPx)
|
|
772
|
+
if (!(available > 0) || !(extent > 0)) return want
|
|
773
|
+
|
|
774
|
+
return Math.max(floor, Math.min(want, Math.floor(available / extent)))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Nice-number ticks that ALSO fit the space they are drawn in.
|
|
779
|
+
*
|
|
780
|
+
* `niceTicks` aims for a target count but is allowed to overshoot it — it
|
|
781
|
+
* rounds the domain outward, so a target of 5 routinely returns 6. That
|
|
782
|
+
* overshoot is what put six 21px labels into a 190px gutter and made every
|
|
783
|
+
* adjacent pair touch. So the target is searched downward until the RETURNED
|
|
784
|
+
* ladder fits, rather than assumed to be honoured.
|
|
785
|
+
*
|
|
786
|
+
* `availablePx` is the axis's drawn length and `minSpacingPx` the room two
|
|
787
|
+
* adjacent labels need between them. Pass `0` for either — an unmeasured
|
|
788
|
+
* plot — and this is exactly `niceTicks(min, max, targetCount)`.
|
|
789
|
+
*/
|
|
790
|
+
export function fitTicks(
|
|
791
|
+
min: NumericInput,
|
|
792
|
+
max: NumericInput,
|
|
793
|
+
availablePx: NumericInput,
|
|
794
|
+
minSpacingPx: NumericInput,
|
|
795
|
+
targetCount: NumericInput = 5,
|
|
796
|
+
): number[] {
|
|
797
|
+
// The TARGET and the LIMIT are two different numbers, and conflating them
|
|
798
|
+
// is a bug: a target of 5 that legitimately returns 6 ticks is fine when
|
|
799
|
+
// six fit, and only a problem when they do not. The limit is therefore
|
|
800
|
+
// purely physical — what the space holds — while the target is what the
|
|
801
|
+
// caller asked for, capped by that limit.
|
|
802
|
+
const limit = fitTickCount(availablePx, minSpacingPx, MAX_AXIS_TICKS)
|
|
803
|
+
let target = Math.max(
|
|
804
|
+
MIN_AXIS_TICKS,
|
|
805
|
+
Math.min(limit, Math.floor(finite(targetCount, 5))),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
let ticks = niceTicks(min, max, target)
|
|
809
|
+
while (ticks.length > limit && target > MIN_AXIS_TICKS) {
|
|
810
|
+
target--
|
|
811
|
+
ticks = niceTicks(min, max, target)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Even the two-tick target can overshoot: `niceTicks(-50000, 50000, 2)`
|
|
815
|
+
// rounds out to a 100,000 step and returns THREE rungs, which is how a
|
|
816
|
+
// 33px plot ended up with "$50,000 / $0 / -$50,000" overlapping by 4.4px
|
|
817
|
+
// after the search had already bottomed out. Subsampling by the same
|
|
818
|
+
// uniform stride the x axis uses keeps the FIRST and LAST rung — so the
|
|
819
|
+
// drawn domain is unchanged, and only gridlines are dropped.
|
|
820
|
+
if (ticks.length > limit) {
|
|
821
|
+
return strideIndices(ticks.length, limit).map((index) => ticks[index] ?? 0)
|
|
822
|
+
}
|
|
823
|
+
return ticks
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Thin `count` positions down to at most `max` labels by a UNIFORM STRIDE,
|
|
828
|
+
* always keeping the first and the last.
|
|
829
|
+
*
|
|
830
|
+
* The predecessor rounded a fraction — `round(i / (max - 1) * last)` — which
|
|
831
|
+
* over 12 positions with `max` 8 produced 0, 2, 3, 5, 6, 8, 9, 11: an axis
|
|
832
|
+
* reading "Wk 1, Wk 3, Wk 4, Wk 6, Wk 7, Wk 9, Wk 10, Wk 12", with 2, 5, 8
|
|
833
|
+
* and 11 skipped while 3&4, 6&7 and 9&10 stayed ADJACENT. A reader sees an
|
|
834
|
+
* irregular axis and concludes, reasonably, that the DATA is irregular. A
|
|
835
|
+
* stride cannot do that: every gap is identical except, at most, the last.
|
|
836
|
+
*
|
|
837
|
+
* When the last position does not land on the stride, it replaces the
|
|
838
|
+
* previous kept index rather than being appended beside it — appending would
|
|
839
|
+
* leave two labels one step apart at the right-hand end, which is the same
|
|
840
|
+
* false signal in miniature. That is the "drop the second-to-last, never an
|
|
841
|
+
* interior one" rule.
|
|
842
|
+
*
|
|
843
|
+
* Returns the kept INDICES, ascending. Edge cases: a non-positive `count` is
|
|
844
|
+
* `[]`; `max` below 2 is read as 2; `count <= max` keeps everything.
|
|
845
|
+
*/
|
|
846
|
+
export function strideIndices(count: NumericInput, max: NumericInput): number[] {
|
|
847
|
+
const n = Math.max(0, Math.floor(finite(count)))
|
|
848
|
+
if (n === 0) return []
|
|
849
|
+
const cap = Math.max(MIN_AXIS_TICKS, Math.floor(finite(max, MIN_AXIS_TICKS)))
|
|
850
|
+
if (n <= cap) return Array.from({ length: n }, (_, i) => i)
|
|
851
|
+
|
|
852
|
+
const last = n - 1
|
|
853
|
+
const stride = Math.max(1, Math.ceil(last / (cap - 1)))
|
|
854
|
+
|
|
855
|
+
const kept: number[] = []
|
|
856
|
+
for (let i = 0; i <= last; i += stride) kept.push(i)
|
|
857
|
+
|
|
858
|
+
const tail = kept[kept.length - 1]
|
|
859
|
+
if (tail !== undefined && tail !== last) {
|
|
860
|
+
// Adjacent to the end: replace, so the final gap widens instead of
|
|
861
|
+
// collapsing to one step. Otherwise there is room for both.
|
|
862
|
+
if (kept.length > 1 && last - tail < stride) kept[kept.length - 1] = last
|
|
863
|
+
else kept.push(last)
|
|
864
|
+
}
|
|
865
|
+
return kept
|
|
866
|
+
}
|