basalt-ui 1.22.0 → 1.23.1
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/AGENTS.md +29 -12
- package/CHANGELOG.md +28 -0
- package/MIGRATING.md +207 -12
- package/README.md +74 -21
- package/agent/rules/basalt-app.md +33 -3
- package/agent/rules/basalt-charts.md +78 -7
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +35 -6
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +27 -10
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +49 -8
- package/configs/oxlint-plugin.test.ts +88 -31
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +127 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +271 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +95 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +328 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +228 -31
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +60 -13
- package/dist/vite.d.ts +42 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +67 -30
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +282 -0
- package/src/charts/kinds/BandStrip.tsx +457 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +232 -0
- package/src/charts/kinds/MirroredBars.tsx +491 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +436 -52
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/tokens-css.test.ts +62 -0
- package/src/cli/toolchain-wiring.test.ts +171 -4
- package/src/cli/waiver-audit.test.ts +7 -3
- package/src/guard/check-source.test.ts +211 -42
- package/src/guard/index.ts +149 -23
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -0,0 +1,491 @@
|
|
|
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
|
+
* A key naming no entry throws — see {@link assertPaneKey}. */
|
|
30
|
+
key: string
|
|
31
|
+
/**
|
|
32
|
+
* The pane's upper bound. `'auto'` (default) is its own visible maximum; a number pins it.
|
|
33
|
+
*
|
|
34
|
+
* A pane has no LOWER bound to configure — a mirrored bar's length is its magnitude measured
|
|
35
|
+
* from the shared baseline, so the pane's zero IS the baseline. That is also why the two panes
|
|
36
|
+
* never share a domain: an upload an order of magnitude below its download flattens to a line
|
|
37
|
+
* along the baseline on a shared scale, and it is the half that explains a stalled call.
|
|
38
|
+
*/
|
|
39
|
+
max?: number | 'auto'
|
|
40
|
+
/** When `max` is `'auto'`: floors the upper bound, so a quiet window doesn't amplify noise to
|
|
41
|
+
* full height. */
|
|
42
|
+
autoMaxFloor?: number
|
|
43
|
+
/** Tick count for this pane's own axis. Default 3. */
|
|
44
|
+
ticks?: number
|
|
45
|
+
/** Tick + tooltip formatter for this pane. A pane's axis is in its OWN units — one shared
|
|
46
|
+
* formatter would be wrong for at least one of them whenever the two differ. */
|
|
47
|
+
format: (v: number) => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type MirroredBarsProps<T> = {
|
|
51
|
+
data: readonly T[]
|
|
52
|
+
chartId: string
|
|
53
|
+
/** Extracts the x-domain key — a slot start, hence the `'leading'` cursor default. */
|
|
54
|
+
getX: (d: T) => string
|
|
55
|
+
/**
|
|
56
|
+
* The single source of truth for bar colours, legend entries and tooltip rows. `up.key` and
|
|
57
|
+
* `down.key` name two of these; any further entry is legend-only (give it `getValue: () => null`)
|
|
58
|
+
* — which is how ABSENCE gets named, and it has to be: a hatched bar is the one mark on this
|
|
59
|
+
* chart a reader cannot decode from either axis.
|
|
60
|
+
*/
|
|
61
|
+
series: readonly ChartSeries<T>[]
|
|
62
|
+
/** The pane drawn ABOVE the baseline. */
|
|
63
|
+
up: MirroredBarPane
|
|
64
|
+
/** The pane drawn BELOW the baseline. */
|
|
65
|
+
down: MirroredBarPane
|
|
66
|
+
/** The up pane's share of the bar band. Default 0.35 — the mirrored pair is usually asymmetric,
|
|
67
|
+
* and splitting evenly spends most of the chart on empty space above the smaller half. */
|
|
68
|
+
upFraction?: number
|
|
69
|
+
/**
|
|
70
|
+
* 0..1 of this bar's width no source sample covers, hatched across the FULL band height.
|
|
71
|
+
*
|
|
72
|
+
* Full height, both panes, deliberately: a hatch drawn on one pane only reads as "that half
|
|
73
|
+
* measured zero", which is the measured-and-idle state absence must stay distinguishable from.
|
|
74
|
+
*/
|
|
75
|
+
getAbsentFraction?: (d: T) => number
|
|
76
|
+
/** Per-datum bar opacity — a real but qualified measurement (a short bucket, a partial interval)
|
|
77
|
+
* drawn dimmer. Default 1. */
|
|
78
|
+
getBarOpacity?: (d: T) => number
|
|
79
|
+
/** Fixed height in px, forwarded to `ChartFrame`. Default 240. */
|
|
80
|
+
height?: number
|
|
81
|
+
fill?: boolean
|
|
82
|
+
formatX?: (key: string) => string
|
|
83
|
+
xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
|
|
84
|
+
fold?: BandFold<T>
|
|
85
|
+
/** Default `'leading'` — see `BandStripProps.cursorResolution`. */
|
|
86
|
+
cursorResolution?: CursorResolution
|
|
87
|
+
tooltip?: BandTooltipConfig<T> | false
|
|
88
|
+
legend?: ChartLegendConfig | false
|
|
89
|
+
/** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`. A key naming no
|
|
90
|
+
* `series` entry throws rather than falling back to the unnamed default. */
|
|
91
|
+
absentState?: string
|
|
92
|
+
margin?: Partial<ChartMargin>
|
|
93
|
+
ariaLabel?: string
|
|
94
|
+
isPending?: boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_UP_FRACTION = 0.35
|
|
98
|
+
const DEFAULT_PANE_TICKS = 3
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A pane key naming no `series` entry.
|
|
102
|
+
*
|
|
103
|
+
* This is `BandStrip`'s silent-drop bug in the shape this kind has: an unresolvable `up.key` left
|
|
104
|
+
* `upSeries` undefined, which made `upVisible` false, which hid the pane AND its axis — rendering
|
|
105
|
+
* as "that half measured nothing", the measured-and-idle state the whole kind exists to keep
|
|
106
|
+
* distinguishable from absence. Unlike `BandSpan.state`, a pane key is a PROP and never comes off a
|
|
107
|
+
* datum, so a typo can only ever be a wiring error: this throws in every environment, with no
|
|
108
|
+
* production fallback, because there is no honest way to draw a pane whose colour and legend entry
|
|
109
|
+
* do not exist.
|
|
110
|
+
*/
|
|
111
|
+
function assertPaneKey<T>(
|
|
112
|
+
side: 'up' | 'down',
|
|
113
|
+
key: string,
|
|
114
|
+
resolved: ChartSeries<T> | undefined,
|
|
115
|
+
series: readonly ChartSeries<T>[],
|
|
116
|
+
): void {
|
|
117
|
+
if (resolved !== undefined) return
|
|
118
|
+
throw new Error(
|
|
119
|
+
`MirroredBars: ${side}.key "${key}" names no \`series\` entry ` +
|
|
120
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the pane would silently vanish, which ` +
|
|
121
|
+
'reads as a measured zero. Add the series, or point the pane at one that exists.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Two bar panes over ONE x scale and one cursor, mirrored around a shared baseline, each in its
|
|
127
|
+
* own independent domain.
|
|
128
|
+
*
|
|
129
|
+
* **Why not `DualPanel`.** `DualPanel`'s two panes are a LINE pane and a signed-histogram pane: it
|
|
130
|
+
* requires `series` to be line series it draws with `LinePath`, and its bottom pane takes a single
|
|
131
|
+
* signed `getBar` over one symmetric `[-maxAbs, maxAbs]` scale. Neither half fits. Two independent
|
|
132
|
+
* magnitudes mirrored around a baseline are not one signed quantity — a 220 kB/s down and a 20 kB/s
|
|
133
|
+
* up are two readings, not `+220` and `−20` of one — and forcing them onto one scale is exactly the
|
|
134
|
+
* flattening this shape exists to avoid. Bending `DualPanel` to cover it would mean a
|
|
135
|
+
* `topMark: 'line' | 'bars'` switch, a second value accessor, and a per-pane domain law that
|
|
136
|
+
* contradicts its symmetric one: a fork of its internals wearing one name. It composes the same
|
|
137
|
+
* `useBandPlot` choreography instead, which is where the sharing actually belongs.
|
|
138
|
+
*/
|
|
139
|
+
function MirroredBarsInner<T>(props: MirroredBarsProps<T>) {
|
|
140
|
+
const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<ChartFrame
|
|
144
|
+
series={series}
|
|
145
|
+
chartId={chartId}
|
|
146
|
+
{...(height !== undefined && { height })}
|
|
147
|
+
{...(fill !== undefined && { fill })}
|
|
148
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
149
|
+
{...(isPending !== undefined && { isPending })}
|
|
150
|
+
legend={resolveLegend(legend)}
|
|
151
|
+
>
|
|
152
|
+
{(plot) => <MirroredBarsPlot {...props} plot={plot} />}
|
|
153
|
+
</ChartFrame>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type MirroredBarsPlotProps<T> = MirroredBarsProps<T> & {
|
|
158
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The measured plot — split so the scale/cursor hooks only run once `ChartFrame` has resolved a
|
|
162
|
+
* non-empty plot rect (parity with `DualPanel`). */
|
|
163
|
+
function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
164
|
+
const {
|
|
165
|
+
data,
|
|
166
|
+
chartId,
|
|
167
|
+
getX,
|
|
168
|
+
series,
|
|
169
|
+
up,
|
|
170
|
+
down,
|
|
171
|
+
upFraction = DEFAULT_UP_FRACTION,
|
|
172
|
+
getAbsentFraction,
|
|
173
|
+
getBarOpacity,
|
|
174
|
+
formatX = fmtAxisDate,
|
|
175
|
+
xTickValues,
|
|
176
|
+
fold,
|
|
177
|
+
cursorResolution = 'leading',
|
|
178
|
+
tooltip,
|
|
179
|
+
absentState,
|
|
180
|
+
margin: marginOverride,
|
|
181
|
+
ariaLabel,
|
|
182
|
+
plot,
|
|
183
|
+
} = props
|
|
184
|
+
|
|
185
|
+
const { hidden } = plot
|
|
186
|
+
const seriesByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
|
|
187
|
+
const upSeries = seriesByKey.get(up.key)
|
|
188
|
+
const downSeries = seriesByKey.get(down.key)
|
|
189
|
+
assertPaneKey('up', up.key, upSeries, series)
|
|
190
|
+
assertPaneKey('down', down.key, downSeries, series)
|
|
191
|
+
const upVisible = upSeries !== undefined && !hidden.has(up.key)
|
|
192
|
+
const downVisible = downSeries !== undefined && !hidden.has(down.key)
|
|
193
|
+
|
|
194
|
+
// ── Pass 1: the two domains and their tick labels, both independent of the pixel rect ────────
|
|
195
|
+
const upMax = usePaneMax(data, upSeries, up, upVisible)
|
|
196
|
+
const downMax = usePaneMax(data, downSeries, down, downVisible)
|
|
197
|
+
const upTicks = up.ticks ?? DEFAULT_PANE_TICKS
|
|
198
|
+
const downTicks = down.ticks ?? DEFAULT_PANE_TICKS
|
|
199
|
+
|
|
200
|
+
const leftLabels = useMemo(() => {
|
|
201
|
+
const labels: string[] = []
|
|
202
|
+
if (upVisible) {
|
|
203
|
+
labels.push(
|
|
204
|
+
...probeAxisLabels({ domain: [0, upMax], ticks: upTicks, format: up.format }).labels,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (downVisible) {
|
|
208
|
+
labels.push(
|
|
209
|
+
...probeAxisLabels({ domain: [0, downMax], ticks: downTicks, format: down.format }).labels,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
return labels
|
|
213
|
+
}, [upVisible, downVisible, upMax, downMax, upTicks, downTicks, up.format, down.format])
|
|
214
|
+
|
|
215
|
+
const band = useBandPlot<T>({
|
|
216
|
+
data,
|
|
217
|
+
chartId,
|
|
218
|
+
getX,
|
|
219
|
+
formatX,
|
|
220
|
+
width: plot.width,
|
|
221
|
+
cursorResolution,
|
|
222
|
+
leftLabels,
|
|
223
|
+
...(fold !== undefined && { fold }),
|
|
224
|
+
...(xTickValues !== undefined && { xTickValues }),
|
|
225
|
+
...(marginOverride !== undefined && { margin: marginOverride }),
|
|
226
|
+
...(tooltip !== undefined && { tooltip }),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
|
|
230
|
+
|
|
231
|
+
// ── Pass 2: the pane rects, now that the plot height is known ───────────────────────────────
|
|
232
|
+
const barBand = Math.max(plot.height - margin.top - margin.bottom, 2)
|
|
233
|
+
const upHeight = Math.max(Math.round(barBand * upFraction), 1)
|
|
234
|
+
const downHeight = Math.max(barBand - upHeight, 1)
|
|
235
|
+
const baseline = upHeight
|
|
236
|
+
|
|
237
|
+
const upScale = useMemo(
|
|
238
|
+
() => scaleLinear<number>({ domain: [0, upMax], range: [0, upHeight] }),
|
|
239
|
+
[upMax, upHeight],
|
|
240
|
+
)
|
|
241
|
+
const downScale = useMemo(
|
|
242
|
+
() => scaleLinear<number>({ domain: [0, downMax], range: [0, downHeight] }),
|
|
243
|
+
[downMax, downHeight],
|
|
244
|
+
)
|
|
245
|
+
// The up AXIS reads top-to-bottom while the up BARS grow bottom-to-top, so its scale is the
|
|
246
|
+
// reverse of `upScale`. Same domain, same range — only the direction differs.
|
|
247
|
+
const upAxisScale = useMemo(
|
|
248
|
+
() => scaleLinear<number>({ domain: [upMax, 0], range: [0, upHeight] }),
|
|
249
|
+
[upMax, upHeight],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const hatchId = `${chartId}-mirrored-absent`
|
|
253
|
+
if (absentState !== undefined && !seriesByKey.has(absentState)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`MirroredBars: absentState "${absentState}" names no \`series\` entry ` +
|
|
256
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the hatch would fall back to an unnamed ` +
|
|
257
|
+
'neutral, and a hatched bar is the one mark a reader cannot decode from either axis.',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
const absentColor =
|
|
261
|
+
(absentState === undefined ? undefined : seriesByKey.get(absentState)?.color) ?? VX.neutral
|
|
262
|
+
|
|
263
|
+
const drawnUp = upVisible ? upSeries : undefined
|
|
264
|
+
const drawnDown = downVisible ? downSeries : undefined
|
|
265
|
+
|
|
266
|
+
const bars = useMemo<ReactNode[]>(() => {
|
|
267
|
+
const out: ReactNode[] = []
|
|
268
|
+
bands.forEach((d, i) => {
|
|
269
|
+
const x = i * step
|
|
270
|
+
const absent = clampFraction(getAbsentFraction?.(d))
|
|
271
|
+
const measuredWidth = bandWidth * (1 - absent)
|
|
272
|
+
const hatchWidth = bandWidth - measuredWidth
|
|
273
|
+
const opacity = getBarOpacity?.(d) ?? 1
|
|
274
|
+
// `Number.isNaN` as well as null: `deriveTooltipRows` skips only null, and a NaN reaching
|
|
275
|
+
// `scaleLinear` produces NaN `y`/`height` attributes — a bar that silently fails to paint
|
|
276
|
+
// while React logs a warning nobody reads. Same guard `DualPanel` applies to its line paths.
|
|
277
|
+
const upValue = finiteOrNull(drawnUp?.getValue(d))
|
|
278
|
+
const downValue = finiteOrNull(drawnDown?.getValue(d))
|
|
279
|
+
out.push(
|
|
280
|
+
<g key={getX(d)}>
|
|
281
|
+
{measuredWidth > 0 && drawnUp !== undefined && upValue !== null && (
|
|
282
|
+
<rect
|
|
283
|
+
x={x}
|
|
284
|
+
y={baseline - upScale(upValue)}
|
|
285
|
+
width={measuredWidth}
|
|
286
|
+
height={Math.max(upScale(upValue), upValue > 0 ? 1 : 0)}
|
|
287
|
+
fill={alpha(drawnUp.color, opacity)}
|
|
288
|
+
pointerEvents="none"
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
{measuredWidth > 0 && drawnDown !== undefined && downValue !== null && (
|
|
292
|
+
<rect
|
|
293
|
+
x={x}
|
|
294
|
+
y={baseline}
|
|
295
|
+
width={measuredWidth}
|
|
296
|
+
height={Math.max(downScale(downValue), downValue > 0 ? 1 : 0)}
|
|
297
|
+
fill={alpha(drawnDown.color, opacity)}
|
|
298
|
+
pointerEvents="none"
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
{hatchWidth > 0 && (
|
|
302
|
+
<rect
|
|
303
|
+
x={x + measuredWidth}
|
|
304
|
+
y={0}
|
|
305
|
+
width={hatchWidth}
|
|
306
|
+
height={barBand}
|
|
307
|
+
fill={hatchFill(hatchId)}
|
|
308
|
+
pointerEvents="none"
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
</g>,
|
|
312
|
+
)
|
|
313
|
+
})
|
|
314
|
+
return out
|
|
315
|
+
}, [
|
|
316
|
+
bands,
|
|
317
|
+
step,
|
|
318
|
+
bandWidth,
|
|
319
|
+
barBand,
|
|
320
|
+
baseline,
|
|
321
|
+
getX,
|
|
322
|
+
getAbsentFraction,
|
|
323
|
+
getBarOpacity,
|
|
324
|
+
drawnUp,
|
|
325
|
+
drawnDown,
|
|
326
|
+
upScale,
|
|
327
|
+
downScale,
|
|
328
|
+
hatchId,
|
|
329
|
+
])
|
|
330
|
+
|
|
331
|
+
if (plotWidth <= 0 || bands.length === 0) return null
|
|
332
|
+
|
|
333
|
+
const cfg = tooltip === false ? undefined : tooltip
|
|
334
|
+
const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
|
|
335
|
+
// Each pane's row falls back to ITS OWN axis formatter, never the other pane's — the two panes
|
|
336
|
+
// are in different units by construction (an explicit per-series `formatValue` still wins).
|
|
337
|
+
const visibleSeries = series
|
|
338
|
+
.filter((s) => !hidden.has(s.key))
|
|
339
|
+
.map((s) => {
|
|
340
|
+
if (s.formatValue !== undefined) return s
|
|
341
|
+
if (s.key === up.key) return { ...s, formatValue: (v: number) => up.format(v) }
|
|
342
|
+
if (s.key === down.key) return { ...s, formatValue: (v: number) => down.format(v) }
|
|
343
|
+
return s
|
|
344
|
+
})
|
|
345
|
+
const rows = point === null ? [] : deriveTooltipRows(visibleSeries, point, down.format)
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<>
|
|
349
|
+
<svg ref={band.svgRef} width={plot.width} height={plot.height}>
|
|
350
|
+
<defs>
|
|
351
|
+
<HatchPattern
|
|
352
|
+
id={hatchId}
|
|
353
|
+
color={absentColor}
|
|
354
|
+
opacity={0.7}
|
|
355
|
+
size={hatchSizeFor(bandWidth)}
|
|
356
|
+
/>
|
|
357
|
+
</defs>
|
|
358
|
+
{/* theme-allow-file basalt/hand-rolled-plot — TWO bar panes over one x scale, in two
|
|
359
|
+
INDEPENDENT domains mirrored around a shared baseline. `CartesianChart`'s contract is a
|
|
360
|
+
single plot rect with one or two numeric y axes measured against the same marks, so it
|
|
361
|
+
cannot express two panes; `DualPanel` is the wrong shape for the reason this kind's own
|
|
362
|
+
docblock argues. Assembled from the SAME parts every other chart gets — `ChartFrame`,
|
|
363
|
+
`autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through `useBandPlot`, shared with
|
|
364
|
+
`BandStrip` rather than copied from it. */}
|
|
365
|
+
<Group left={margin.left} top={margin.top}>
|
|
366
|
+
{bars}
|
|
367
|
+
|
|
368
|
+
{/* One axis per pane, each in its own scale's units — a single shared axis would be
|
|
369
|
+
wrong for at least one of them whenever the two domains differ, which is always. */}
|
|
370
|
+
{upVisible && (
|
|
371
|
+
<AxisLeftNumeric
|
|
372
|
+
scale={upAxisScale}
|
|
373
|
+
numTicks={upTicks}
|
|
374
|
+
tickFormat={(v) => up.format(Number(v))}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
{downVisible && (
|
|
378
|
+
<Group top={baseline}>
|
|
379
|
+
<AxisLeftNumeric
|
|
380
|
+
scale={downScale}
|
|
381
|
+
numTicks={downTicks}
|
|
382
|
+
tickFormat={(v) => down.format(Number(v))}
|
|
383
|
+
/>
|
|
384
|
+
</Group>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
<line
|
|
388
|
+
x1={0}
|
|
389
|
+
x2={plotWidth}
|
|
390
|
+
y1={baseline}
|
|
391
|
+
y2={baseline}
|
|
392
|
+
stroke={VX.axisStroke}
|
|
393
|
+
strokeWidth={1}
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
{crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={barBand} />}
|
|
397
|
+
|
|
398
|
+
<AxisBottomDate
|
|
399
|
+
top={barBand}
|
|
400
|
+
scale={scale}
|
|
401
|
+
tickValues={band.tickValues}
|
|
402
|
+
tickFormat={(v) => formatX(String(v))}
|
|
403
|
+
/>
|
|
404
|
+
|
|
405
|
+
<HoverOverlay
|
|
406
|
+
width={plotWidth}
|
|
407
|
+
height={barBand}
|
|
408
|
+
onMove={cursor.onPointerMove}
|
|
409
|
+
onLeave={cursor.onPointerLeave}
|
|
410
|
+
onKeyDown={cursor.onKeyDown}
|
|
411
|
+
onBlur={cursor.onBlur}
|
|
412
|
+
valueMax={Math.max(bands.length - 1, 0)}
|
|
413
|
+
{...(point !== null && {
|
|
414
|
+
valueNow: bands.indexOf(point),
|
|
415
|
+
valueText: formatX(getX(point)),
|
|
416
|
+
})}
|
|
417
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
418
|
+
/>
|
|
419
|
+
</Group>
|
|
420
|
+
</svg>
|
|
421
|
+
|
|
422
|
+
{band.showTooltip && point !== null && (
|
|
423
|
+
<ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
|
|
424
|
+
<TooltipHeader
|
|
425
|
+
date={getX(point)}
|
|
426
|
+
{...(cfg?.formatHeader !== undefined && {
|
|
427
|
+
format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
|
|
428
|
+
})}
|
|
429
|
+
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
430
|
+
/>
|
|
431
|
+
<TooltipBody>
|
|
432
|
+
{cfg?.prependRows?.(point, { hidden })}
|
|
433
|
+
{rows.map((row) => (
|
|
434
|
+
<TooltipRow
|
|
435
|
+
key={row.key}
|
|
436
|
+
color={row.color}
|
|
437
|
+
label={row.label}
|
|
438
|
+
value={row.value}
|
|
439
|
+
shape={row.shape}
|
|
440
|
+
dashed={row.dashed}
|
|
441
|
+
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
442
|
+
/>
|
|
443
|
+
))}
|
|
444
|
+
{cfg?.extraRows?.(point, { hidden })}
|
|
445
|
+
</TooltipBody>
|
|
446
|
+
</ChartTooltipFloat>
|
|
447
|
+
)}
|
|
448
|
+
</>
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A 0..1 share, non-finite included — see `BandStrip`'s identical guard. */
|
|
453
|
+
function clampFraction(value: number | undefined): number {
|
|
454
|
+
if (value === undefined || !Number.isFinite(value)) return 0
|
|
455
|
+
return Math.min(Math.max(value, 0), 1)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A drawable value, or null. See the call site for why NaN is not drawable. */
|
|
459
|
+
function finiteOrNull(v: number | null | undefined): number | null {
|
|
460
|
+
return v === null || v === undefined || Number.isNaN(v) ? null : v
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* One pane's upper bound. `|| 1` guards a window with no traffic at all — a zero-width domain
|
|
465
|
+
* renders as NaN geometry rather than as an empty chart.
|
|
466
|
+
*/
|
|
467
|
+
function usePaneMax<T>(
|
|
468
|
+
data: readonly T[],
|
|
469
|
+
paneSeries: ChartSeries<T> | undefined,
|
|
470
|
+
pane: MirroredBarPane,
|
|
471
|
+
visible: boolean,
|
|
472
|
+
): number {
|
|
473
|
+
return useMemo(() => {
|
|
474
|
+
if (pane.max !== undefined && pane.max !== 'auto') return pane.max || 1
|
|
475
|
+
if (!visible || paneSeries === undefined) return 1
|
|
476
|
+
let max = 0
|
|
477
|
+
for (const d of data) {
|
|
478
|
+
const v = paneSeries.getValue(d)
|
|
479
|
+
if (v === null || Number.isNaN(v)) continue
|
|
480
|
+
if (v > max) max = v
|
|
481
|
+
}
|
|
482
|
+
const floored = pane.autoMaxFloor === undefined ? max : Math.max(max, pane.autoMaxFloor)
|
|
483
|
+
return floored || 1
|
|
484
|
+
}, [data, paneSeries, pane.max, pane.autoMaxFloor, visible])
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
|
|
489
|
+
* `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
|
|
490
|
+
*/
|
|
491
|
+
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
|
-
/**
|
|
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
|
-
|
|
395
|
-
|
|
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
|
+
})
|