@lovett/ui 0.0.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,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
+ }