basalt-ui 1.21.0 → 1.23.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 (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
@@ -0,0 +1,455 @@
1
+ import { Group } from '@visx/group'
2
+ import { scaleLinear } from '@visx/scale'
3
+ import { memo, useMemo } from 'react'
4
+ import type { ReactNode } from 'react'
5
+ import { VX, alpha } from '../../tokens'
6
+ import type { ChartMargin } from '../../tokens'
7
+ import type { CursorResolution } from '../cursor/resolve'
8
+ import { useBandPlot } from '../hooks/useBandPlot'
9
+ import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
10
+ import { probeAxisLabels } from '../layout/auto-margin'
11
+ import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
12
+ import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
13
+ import {
14
+ ChartTooltipFloat,
15
+ TooltipBody,
16
+ TooltipHeader,
17
+ TooltipRow,
18
+ } from '../primitives/ChartTooltip'
19
+ import { Crosshair } from '../primitives/Crosshair'
20
+ import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
21
+ import { HoverOverlay } from '../primitives/HoverOverlay'
22
+ import { deriveTooltipRows } from '../series'
23
+ import type { ChartLegendConfig, ChartSeries } from '../series'
24
+ import { fmtAxisDate } from '../utils/format'
25
+
26
+ /** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
27
+ export type MirroredBarPane = {
28
+ /** Key of the `series` entry this pane draws: its colour, its legend entry, its tooltip row. */
29
+ key: string
30
+ /**
31
+ * The pane's upper bound. `'auto'` (default) is its own visible maximum; a number pins it.
32
+ *
33
+ * A pane has no LOWER bound to configure — a mirrored bar's length is its magnitude measured
34
+ * from the shared baseline, so the pane's zero IS the baseline. That is also why the two panes
35
+ * never share a domain: an upload an order of magnitude below its download flattens to a line
36
+ * along the baseline on a shared scale, and it is the half that explains a stalled call.
37
+ */
38
+ max?: number | 'auto'
39
+ /** When `max` is `'auto'`: floors the upper bound, so a quiet window doesn't amplify noise to
40
+ * full height. */
41
+ autoMaxFloor?: number
42
+ /** Tick count for this pane's own axis. Default 3. */
43
+ ticks?: number
44
+ /** Tick + tooltip formatter for this pane. A pane's axis is in its OWN units — one shared
45
+ * formatter would be wrong for at least one of them whenever the two differ. */
46
+ format: (v: number) => string
47
+ }
48
+
49
+ export type MirroredBarsProps<T> = {
50
+ data: readonly T[]
51
+ chartId: string
52
+ /** Extracts the x-domain key — a slot start, hence the `'leading'` cursor default. */
53
+ getX: (d: T) => string
54
+ /**
55
+ * The single source of truth for bar colours, legend entries and tooltip rows. `up.key` and
56
+ * `down.key` name two of these; any further entry is legend-only (give it `getValue: () => null`)
57
+ * — which is how ABSENCE gets named, and it has to be: a hatched bar is the one mark on this
58
+ * chart a reader cannot decode from either axis.
59
+ */
60
+ series: readonly ChartSeries<T>[]
61
+ /** The pane drawn ABOVE the baseline. */
62
+ up: MirroredBarPane
63
+ /** The pane drawn BELOW the baseline. */
64
+ down: MirroredBarPane
65
+ /** The up pane's share of the bar band. Default 0.35 — the mirrored pair is usually asymmetric,
66
+ * and splitting evenly spends most of the chart on empty space above the smaller half. */
67
+ upFraction?: number
68
+ /**
69
+ * 0..1 of this bar's width no source sample covers, hatched across the FULL band height.
70
+ *
71
+ * Full height, both panes, deliberately: a hatch drawn on one pane only reads as "that half
72
+ * measured zero", which is the measured-and-idle state absence must stay distinguishable from.
73
+ */
74
+ getAbsentFraction?: (d: T) => number
75
+ /** Per-datum bar opacity — a real but qualified measurement (a short bucket, a partial interval)
76
+ * drawn dimmer. Default 1. */
77
+ getBarOpacity?: (d: T) => number
78
+ /** Fixed height in px, forwarded to `ChartFrame`. Default 240. */
79
+ height?: number
80
+ fill?: boolean
81
+ formatX?: (key: string) => string
82
+ xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
83
+ fold?: BandFold<T>
84
+ /** Default `'leading'` — see `BandStripProps.cursorResolution`. */
85
+ cursorResolution?: CursorResolution
86
+ tooltip?: BandTooltipConfig<T> | false
87
+ legend?: ChartLegendConfig | false
88
+ /** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`. */
89
+ absentState?: string
90
+ margin?: Partial<ChartMargin>
91
+ ariaLabel?: string
92
+ isPending?: boolean
93
+ }
94
+
95
+ const DEFAULT_UP_FRACTION = 0.35
96
+ const DEFAULT_PANE_TICKS = 3
97
+
98
+ /**
99
+ * Two bar panes over ONE x scale and one cursor, mirrored around a shared baseline, each in its
100
+ * own independent domain.
101
+ *
102
+ * **Why not `DualPanel`.** `DualPanel`'s two panes are a LINE pane and a signed-histogram pane: it
103
+ * requires `series` to be line series it draws with `LinePath`, and its bottom pane takes a single
104
+ * signed `getBar` over one symmetric `[-maxAbs, maxAbs]` scale. Neither half fits. Two independent
105
+ * magnitudes mirrored around a baseline are not one signed quantity — a 220 kB/s down and a 20 kB/s
106
+ * up are two readings, not `+220` and `−20` of one — and forcing them onto one scale is exactly the
107
+ * flattening this shape exists to avoid. Bending `DualPanel` to cover it would mean a
108
+ * `topMark: 'line' | 'bars'` switch, a second value accessor, and a per-pane domain law that
109
+ * contradicts its symmetric one: a fork of its internals wearing one name. It composes the same
110
+ * `useBandPlot` choreography instead, which is where the sharing actually belongs.
111
+ */
112
+ function MirroredBarsInner<T>(props: MirroredBarsProps<T>) {
113
+ const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
114
+
115
+ return (
116
+ <ChartFrame
117
+ series={series}
118
+ chartId={chartId}
119
+ {...(height !== undefined && { height })}
120
+ {...(fill !== undefined && { fill })}
121
+ {...(ariaLabel !== undefined && { ariaLabel })}
122
+ {...(isPending !== undefined && { isPending })}
123
+ legend={resolveLegend(legend)}
124
+ >
125
+ {(plot) => <MirroredBarsPlot {...props} plot={plot} />}
126
+ </ChartFrame>
127
+ )
128
+ }
129
+
130
+ type MirroredBarsPlotProps<T> = MirroredBarsProps<T> & {
131
+ plot: { width: number; height: number; hidden: ReadonlySet<string> }
132
+ }
133
+
134
+ /** The measured plot — split so the scale/cursor hooks only run once `ChartFrame` has resolved a
135
+ * non-empty plot rect (parity with `DualPanel`). */
136
+ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
137
+ const {
138
+ data,
139
+ chartId,
140
+ getX,
141
+ series,
142
+ up,
143
+ down,
144
+ upFraction = DEFAULT_UP_FRACTION,
145
+ getAbsentFraction,
146
+ getBarOpacity,
147
+ formatX = fmtAxisDate,
148
+ xTickValues,
149
+ fold,
150
+ cursorResolution = 'leading',
151
+ tooltip,
152
+ absentState,
153
+ margin: marginOverride,
154
+ ariaLabel,
155
+ plot,
156
+ } = props
157
+
158
+ const { hidden } = plot
159
+ const seriesByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
160
+ const upSeries = seriesByKey.get(up.key)
161
+ const downSeries = seriesByKey.get(down.key)
162
+ const upVisible = upSeries !== undefined && !hidden.has(up.key)
163
+ const downVisible = downSeries !== undefined && !hidden.has(down.key)
164
+
165
+ // ── Pass 1: the two domains and their tick labels, both independent of the pixel rect ────────
166
+ const upMax = usePaneMax(data, upSeries, up, upVisible)
167
+ const downMax = usePaneMax(data, downSeries, down, downVisible)
168
+ const upTicks = up.ticks ?? DEFAULT_PANE_TICKS
169
+ const downTicks = down.ticks ?? DEFAULT_PANE_TICKS
170
+
171
+ const leftLabels = useMemo(() => {
172
+ const labels: string[] = []
173
+ if (upVisible) {
174
+ labels.push(
175
+ ...probeAxisLabels({ domain: [0, upMax], ticks: upTicks, format: up.format }).labels,
176
+ )
177
+ }
178
+ if (downVisible) {
179
+ labels.push(
180
+ ...probeAxisLabels({ domain: [0, downMax], ticks: downTicks, format: down.format }).labels,
181
+ )
182
+ }
183
+ return labels
184
+ }, [upVisible, downVisible, upMax, downMax, upTicks, downTicks, up.format, down.format])
185
+
186
+ const band = useBandPlot<T>({
187
+ data,
188
+ chartId,
189
+ getX,
190
+ formatX,
191
+ width: plot.width,
192
+ cursorResolution,
193
+ leftLabels,
194
+ ...(fold !== undefined && { fold }),
195
+ ...(xTickValues !== undefined && { xTickValues }),
196
+ ...(marginOverride !== undefined && { margin: marginOverride }),
197
+ ...(tooltip !== undefined && { tooltip }),
198
+ })
199
+
200
+ const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
201
+
202
+ // ── Pass 2: the pane rects, now that the plot height is known ───────────────────────────────
203
+ const barBand = Math.max(plot.height - margin.top - margin.bottom, 2)
204
+ const upHeight = Math.max(Math.round(barBand * upFraction), 1)
205
+ const downHeight = Math.max(barBand - upHeight, 1)
206
+ const baseline = upHeight
207
+
208
+ const upScale = useMemo(
209
+ () => scaleLinear<number>({ domain: [0, upMax], range: [0, upHeight] }),
210
+ [upMax, upHeight],
211
+ )
212
+ const downScale = useMemo(
213
+ () => scaleLinear<number>({ domain: [0, downMax], range: [0, downHeight] }),
214
+ [downMax, downHeight],
215
+ )
216
+ // The up AXIS reads top-to-bottom while the up BARS grow bottom-to-top, so its scale is the
217
+ // reverse of `upScale`. Same domain, same range — only the direction differs.
218
+ const upAxisScale = useMemo(
219
+ () => scaleLinear<number>({ domain: [upMax, 0], range: [0, upHeight] }),
220
+ [upMax, upHeight],
221
+ )
222
+
223
+ const hatchId = `${chartId}-mirrored-absent`
224
+ const absentColor =
225
+ (absentState === undefined ? undefined : seriesByKey.get(absentState)?.color) ?? VX.neutral
226
+
227
+ const drawnUp = upVisible ? upSeries : undefined
228
+ const drawnDown = downVisible ? downSeries : undefined
229
+
230
+ const bars = useMemo<ReactNode[]>(() => {
231
+ const out: ReactNode[] = []
232
+ bands.forEach((d, i) => {
233
+ const x = i * step
234
+ const absent = clampFraction(getAbsentFraction?.(d))
235
+ const measuredWidth = bandWidth * (1 - absent)
236
+ const hatchWidth = bandWidth - measuredWidth
237
+ const opacity = getBarOpacity?.(d) ?? 1
238
+ // `Number.isNaN` as well as null: `deriveTooltipRows` skips only null, and a NaN reaching
239
+ // `scaleLinear` produces NaN `y`/`height` attributes — a bar that silently fails to paint
240
+ // while React logs a warning nobody reads. Same guard `DualPanel` applies to its line paths.
241
+ const upValue = finiteOrNull(drawnUp?.getValue(d))
242
+ const downValue = finiteOrNull(drawnDown?.getValue(d))
243
+ out.push(
244
+ <g key={getX(d)}>
245
+ {measuredWidth > 0 && drawnUp !== undefined && upValue !== null && (
246
+ <rect
247
+ x={x}
248
+ y={baseline - upScale(upValue)}
249
+ width={measuredWidth}
250
+ height={Math.max(upScale(upValue), upValue > 0 ? 1 : 0)}
251
+ fill={alpha(drawnUp.color, opacity)}
252
+ pointerEvents="none"
253
+ />
254
+ )}
255
+ {measuredWidth > 0 && drawnDown !== undefined && downValue !== null && (
256
+ <rect
257
+ x={x}
258
+ y={baseline}
259
+ width={measuredWidth}
260
+ height={Math.max(downScale(downValue), downValue > 0 ? 1 : 0)}
261
+ fill={alpha(drawnDown.color, opacity)}
262
+ pointerEvents="none"
263
+ />
264
+ )}
265
+ {hatchWidth > 0 && (
266
+ <rect
267
+ x={x + measuredWidth}
268
+ y={0}
269
+ width={hatchWidth}
270
+ height={barBand}
271
+ fill={hatchFill(hatchId)}
272
+ pointerEvents="none"
273
+ />
274
+ )}
275
+ </g>,
276
+ )
277
+ })
278
+ return out
279
+ }, [
280
+ bands,
281
+ step,
282
+ bandWidth,
283
+ barBand,
284
+ baseline,
285
+ getX,
286
+ getAbsentFraction,
287
+ getBarOpacity,
288
+ drawnUp,
289
+ drawnDown,
290
+ upScale,
291
+ downScale,
292
+ hatchId,
293
+ ])
294
+
295
+ if (plotWidth <= 0 || bands.length === 0) return null
296
+
297
+ const cfg = tooltip === false ? undefined : tooltip
298
+ const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
299
+ // Each pane's row falls back to ITS OWN axis formatter, never the other pane's — the two panes
300
+ // are in different units by construction (an explicit per-series `formatValue` still wins).
301
+ const visibleSeries = series
302
+ .filter((s) => !hidden.has(s.key))
303
+ .map((s) => {
304
+ if (s.formatValue !== undefined) return s
305
+ if (s.key === up.key) return { ...s, formatValue: (v: number) => up.format(v) }
306
+ if (s.key === down.key) return { ...s, formatValue: (v: number) => down.format(v) }
307
+ return s
308
+ })
309
+ const rows = point === null ? [] : deriveTooltipRows(visibleSeries, point, down.format)
310
+
311
+ return (
312
+ <>
313
+ <svg ref={band.svgRef} width={plot.width} height={plot.height}>
314
+ <defs>
315
+ <HatchPattern
316
+ id={hatchId}
317
+ color={absentColor}
318
+ opacity={0.7}
319
+ size={hatchSizeFor(bandWidth)}
320
+ />
321
+ </defs>
322
+ {/* theme-allow-file basalt/hand-rolled-plot — TWO bar panes over one x scale, in two
323
+ INDEPENDENT domains mirrored around a shared baseline. `CartesianChart`'s contract is a
324
+ single plot rect with one or two numeric y axes measured against the same marks, so it
325
+ cannot express two panes; `DualPanel` is the wrong shape for the reason this kind's own
326
+ docblock argues. Assembled from the SAME parts every other chart gets — `ChartFrame`,
327
+ `autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through `useBandPlot`, shared with
328
+ `BandStrip` rather than copied from it. */}
329
+ <Group left={margin.left} top={margin.top}>
330
+ {bars}
331
+
332
+ {/* One axis per pane, each in its own scale's units — a single shared axis would be
333
+ wrong for at least one of them whenever the two domains differ, which is always. */}
334
+ {upVisible && (
335
+ <AxisLeftNumeric
336
+ scale={upAxisScale}
337
+ numTicks={upTicks}
338
+ tickFormat={(v) => up.format(Number(v))}
339
+ />
340
+ )}
341
+ {downVisible && (
342
+ <Group top={baseline}>
343
+ <AxisLeftNumeric
344
+ scale={downScale}
345
+ numTicks={downTicks}
346
+ tickFormat={(v) => down.format(Number(v))}
347
+ />
348
+ </Group>
349
+ )}
350
+
351
+ <line
352
+ x1={0}
353
+ x2={plotWidth}
354
+ y1={baseline}
355
+ y2={baseline}
356
+ stroke={VX.axisStroke}
357
+ strokeWidth={1}
358
+ />
359
+
360
+ {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={barBand} />}
361
+
362
+ <AxisBottomDate
363
+ top={barBand}
364
+ scale={scale}
365
+ tickValues={band.tickValues}
366
+ tickFormat={(v) => formatX(String(v))}
367
+ />
368
+
369
+ <HoverOverlay
370
+ width={plotWidth}
371
+ height={barBand}
372
+ onMove={cursor.onPointerMove}
373
+ onLeave={cursor.onPointerLeave}
374
+ onKeyDown={cursor.onKeyDown}
375
+ onBlur={cursor.onBlur}
376
+ valueMax={Math.max(bands.length - 1, 0)}
377
+ {...(point !== null && {
378
+ valueNow: bands.indexOf(point),
379
+ valueText: formatX(getX(point)),
380
+ })}
381
+ {...(ariaLabel !== undefined && { ariaLabel })}
382
+ />
383
+ </Group>
384
+ </svg>
385
+
386
+ {band.showTooltip && point !== null && (
387
+ <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
388
+ <TooltipHeader
389
+ date={getX(point)}
390
+ {...(cfg?.formatHeader !== undefined && {
391
+ format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
392
+ })}
393
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
394
+ />
395
+ <TooltipBody>
396
+ {cfg?.prependRows?.(point, { hidden })}
397
+ {rows.map((row) => (
398
+ <TooltipRow
399
+ key={row.key}
400
+ color={row.color}
401
+ label={row.label}
402
+ value={row.value}
403
+ shape={row.shape}
404
+ dashed={row.dashed}
405
+ {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
406
+ />
407
+ ))}
408
+ {cfg?.extraRows?.(point, { hidden })}
409
+ </TooltipBody>
410
+ </ChartTooltipFloat>
411
+ )}
412
+ </>
413
+ )
414
+ }
415
+
416
+ /** A 0..1 share, non-finite included — see `BandStrip`'s identical guard. */
417
+ function clampFraction(value: number | undefined): number {
418
+ if (value === undefined || !Number.isFinite(value)) return 0
419
+ return Math.min(Math.max(value, 0), 1)
420
+ }
421
+
422
+ /** A drawable value, or null. See the call site for why NaN is not drawable. */
423
+ function finiteOrNull(v: number | null | undefined): number | null {
424
+ return v === null || v === undefined || Number.isNaN(v) ? null : v
425
+ }
426
+
427
+ /**
428
+ * One pane's upper bound. `|| 1` guards a window with no traffic at all — a zero-width domain
429
+ * renders as NaN geometry rather than as an empty chart.
430
+ */
431
+ function usePaneMax<T>(
432
+ data: readonly T[],
433
+ paneSeries: ChartSeries<T> | undefined,
434
+ pane: MirroredBarPane,
435
+ visible: boolean,
436
+ ): number {
437
+ return useMemo(() => {
438
+ if (pane.max !== undefined && pane.max !== 'auto') return pane.max || 1
439
+ if (!visible || paneSeries === undefined) return 1
440
+ let max = 0
441
+ for (const d of data) {
442
+ const v = paneSeries.getValue(d)
443
+ if (v === null || Number.isNaN(v)) continue
444
+ if (v > max) max = v
445
+ }
446
+ const floored = pane.autoMaxFloor === undefined ? max : Math.max(max, pane.autoMaxFloor)
447
+ return floored || 1
448
+ }, [data, paneSeries, pane.max, pane.autoMaxFloor, visible])
449
+ }
450
+
451
+ /**
452
+ * Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
453
+ * `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
454
+ */
455
+ export const MirroredBars = memo(MirroredBarsInner) as typeof MirroredBarsInner
@@ -34,8 +34,12 @@ export type MultiLineProps<T> = {
34
34
  xZones?: XZoneSpec[]
35
35
  /** Horizontal reference lines. Solid by default; set dashed: true for a dashed line. */
36
36
  refLines?: { value: number; color: string; dashed?: boolean }[]
37
- /** Exact number of x ticks. Default: as many as fit. */
37
+ /** Exact number of x ticks. Default: as many as fit. Ignored when `xTickValues` is set. */
38
38
  xTicks?: number
39
+ /** Which domain keys get a tick, from the full key list and the resolved plot width. Takes
40
+ * precedence over `xTicks` — see `CartesianChartProps.xTickValues` for why a count is not
41
+ * always enough. */
42
+ xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
39
43
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
40
44
  formatX?: (key: string) => string
41
45
  /**
@@ -109,6 +113,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
109
113
  xZones,
110
114
  refLines,
111
115
  xTicks,
116
+ xTickValues,
112
117
  formatX,
113
118
  cursorResolution,
114
119
  tooltip,
@@ -138,6 +143,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
138
143
  {...(xZones !== undefined && { xZones })}
139
144
  {...(refLines !== undefined && { refLines })}
140
145
  {...(xTicks !== undefined && { xTicks })}
146
+ {...(xTickValues !== undefined && { xTickValues })}
141
147
  {...(formatX !== undefined && { formatX })}
142
148
  {...(cursorResolution !== undefined && { cursorResolution })}
143
149
  {...(tooltip !== undefined && { tooltip })}
@@ -20,8 +20,12 @@ export type StackedAreaProps<T> = {
20
20
  * chart needs, since the plotted quantity is the cumulative band top, not any one series' own
21
21
  * value. Pass a fixed tuple or your own function to override. */
22
22
  y?: AxisConfig<T>
23
- /** X tick count override. Default: as many as fit. */
23
+ /** Exact number of x ticks. Default: as many as fit. Ignored when `xTickValues` is set. */
24
24
  xTicks?: number
25
+ /** Which domain keys get a tick, from the full key list and the resolved plot width. Takes
26
+ * precedence over `xTicks` — see `CartesianChartProps.xTickValues` for why a count is not
27
+ * always enough. */
28
+ xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
25
29
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
26
30
  formatX?: (key: string) => string
27
31
  /**
@@ -60,6 +64,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
60
64
  series,
61
65
  y,
62
66
  xTicks,
67
+ xTickValues,
63
68
  formatX,
64
69
  cursorResolution,
65
70
  height,
@@ -121,6 +126,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
121
126
  y={yConfig}
122
127
  cursorValue={stackedCursorValue}
123
128
  {...(xTicks !== undefined && { xTicks })}
129
+ {...(xTickValues !== undefined && { xTickValues })}
124
130
  {...(formatX !== undefined && { formatX })}
125
131
  {...(cursorResolution !== undefined && { cursorResolution })}
126
132
  {...(height !== undefined && { height })}
@@ -50,8 +50,12 @@ export type ZonedLineProps<T> = {
50
50
  xZones?: XZoneSpec[]
51
51
  thresholds?: ZonedLineThreshold[]
52
52
  refLines?: ZonedLineRefLine[]
53
- /** Exact number of x ticks. Default: as many as fit. */
53
+ /** Exact number of x ticks. Default: as many as fit. Ignored when `xTickValues` is set. */
54
54
  xTicks?: number
55
+ /** Which domain keys get a tick, from the full key list and the resolved plot width. Takes
56
+ * precedence over `xTicks` — see `CartesianChartProps.xTickValues` for why a count is not
57
+ * always enough. */
58
+ xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
55
59
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
56
60
  formatX?: (key: string) => string
57
61
  /**
@@ -119,6 +123,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
119
123
  thresholds,
120
124
  refLines,
121
125
  xTicks,
126
+ xTickValues,
122
127
  formatX,
123
128
  cursorResolution,
124
129
  tooltip,
@@ -151,6 +156,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
151
156
  {...(xZones !== undefined && { xZones })}
152
157
  {...(refLines !== undefined && { refLines })}
153
158
  {...(xTicks !== undefined && { xTicks })}
159
+ {...(xTickValues !== undefined && { xTickValues })}
154
160
  {...(formatX !== undefined && { formatX })}
155
161
  {...(cursorResolution !== undefined && { cursorResolution })}
156
162
  {...(tooltip !== undefined && { tooltip })}
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen, within } from '@testing-library/react'
1
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
2
2
  import { describe, expect, test } from 'bun:test'
3
3
  import { renderToStaticMarkup } from 'react-dom/server'
4
4
  import { ChartCursorScope } from '../cursor/scope'
@@ -389,10 +389,18 @@ describe('tooltip.onFollow — aria-live stays SOURCE-only', () => {
389
389
  )
390
390
 
391
391
  fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
392
- await screen.findByText('b')
393
392
 
394
- const tooltips = screen.getAllByRole('tooltip')
395
- expect(tooltips).toHaveLength(2)
393
+ // The FOLLOWER anchors synchronously off its own svg rect, so its tooltip is in the DOM on the
394
+ // tick the broadcast lands. The SOURCE positions against `cursor.anchor`, which
395
+ // `useChartCursor` deliberately coalesces through `requestAnimationFrame` — one frame later.
396
+ // So waiting on the follower's 'b' is no barrier at all for the source tooltip: that only ever
397
+ // held because happy-dom mocks rAF with `setImmediate`, which usually — not reliably — lands
398
+ // before Testing Library's async drain. Wait for the PAIR, which is what's being asserted.
399
+ const tooltips = await waitFor(() => {
400
+ const found = screen.getAllByRole('tooltip')
401
+ expect(found).toHaveLength(2)
402
+ return found
403
+ })
396
404
  const sourceTooltip = tooltips.find((t) => within(t).queryByText('a') !== null)
397
405
  const followerTooltip = tooltips.find((t) => within(t).queryByText('b') !== null)
398
406
  expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
@@ -439,3 +447,77 @@ describe('tooltip.onFollow — a follower whose domain never resolves the broadc
439
447
  expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
440
448
  })
441
449
  })
450
+
451
+ /**
452
+ * `xTickValues` — the seam a tick COUNT cannot express.
453
+ *
454
+ * `smartTicks`/`smartTicksEvery` append the final key unconditionally, so when the step misses the
455
+ * last index that appended tick lands a partial step from its neighbour and two rich labels print
456
+ * on top of each other at the right edge. No count fixes that; only choosing the VALUES does. It is
457
+ * the same prop `BandStrip`/`MirroredBars` take, so the seam does not fork by kind.
458
+ */
459
+ describe('CartesianChart — xTickValues', () => {
460
+ const many: Row[] = Array.from({ length: 12 }, (_, i) => ({
461
+ date: `2026-08-${String(i + 1).padStart(2, '0')}`,
462
+ a: i,
463
+ b: i,
464
+ }))
465
+
466
+ const axisText = (markup: string): string =>
467
+ (/<g class="visx-axis-bottom[\s\S]*?<\/g><\/g>/.exec(markup)?.[0] ?? markup).replace(
468
+ /<[^>]+>/g,
469
+ ' ',
470
+ )
471
+
472
+ const renderWith = (props: Partial<Parameters<typeof CartesianChart<Row>>[0]>): string =>
473
+ renderToStaticMarkup(
474
+ <CartesianChart<Row>
475
+ data={many}
476
+ chartId="ctv"
477
+ getX={(d) => d.date}
478
+ series={[seriesFor('a')]}
479
+ legend={false}
480
+ formatX={(key) => key}
481
+ {...props}
482
+ >
483
+ {() => null}
484
+ </CartesianChart>,
485
+ )
486
+
487
+ test('picks exactly the keys the callback returns, and is handed the width to pick from', () => {
488
+ const seen: { count: number; width: number }[] = []
489
+ const markup = renderWith({
490
+ xTickValues: (keys, xMax) => {
491
+ seen.push({ count: keys.length, width: xMax })
492
+ return ['2026-08-02', '2026-08-07']
493
+ },
494
+ })
495
+ // Every key reaches the callback, alongside the plot width the chart already measured —
496
+ // which is the second half of the seam: no `useChartSize` box outside the chart.
497
+ expect(seen[0]?.count).toBe(12)
498
+ expect(seen[0]?.width).toBeGreaterThan(0)
499
+
500
+ const text = axisText(markup)
501
+ expect(text).toContain('2026-08-02')
502
+ expect(text).toContain('2026-08-07')
503
+ // ...and nothing else. A count could never have produced this pair.
504
+ expect(text).not.toContain('2026-08-01')
505
+ expect(text).not.toContain('2026-08-12')
506
+ })
507
+
508
+ test('takes precedence over xTicks, which keeps working on its own', () => {
509
+ const both = axisText(renderWith({ xTicks: 6, xTickValues: () => ['2026-08-05'] }))
510
+ expect(both).toContain('2026-08-05')
511
+ expect(both).not.toContain('2026-08-01')
512
+
513
+ // The count form is untouched: `smartTicksEvery` still appends the final key.
514
+ const countOnly = axisText(renderWith({ xTicks: 3 }))
515
+ expect(countOnly).toContain('2026-08-01')
516
+ expect(countOnly).toContain('2026-08-12')
517
+ })
518
+
519
+ test('returning no values draws a bare axis rather than falling back to smartTicks', () => {
520
+ const text = axisText(renderWith({ xTickValues: () => [] }))
521
+ expect(text).not.toContain('2026-08-')
522
+ })
523
+ })