basalt-ui 1.22.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.
- package/AGENTS.md +9 -1
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +125 -12
- package/README.md +45 -11
- package/agent/rules/basalt-app.md +26 -2
- package/agent/rules/basalt-charts.md +40 -5
- package/agent/rules/basalt-tokens.md +17 -1
- package/agent/skills/basalt-charts/SKILL.md +23 -8
- 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 +113 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +218 -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 +93 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +315 -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 +47 -8
- 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 +32 -11
- 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 +200 -0
- package/src/charts/kinds/BandStrip.tsx +364 -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 +193 -0
- package/src/charts/kinds/MirroredBars.tsx +455 -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/index.ts +125 -18
- package/src/cli/shipped-versions.test.ts +83 -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 +138 -42
- package/src/guard/index.ts +83 -17
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { Group } from '@visx/group'
|
|
2
|
+
import { memo, useMemo } from 'react'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { VX, alpha } from '../../tokens'
|
|
5
|
+
import type { ChartMargin } from '../../tokens'
|
|
6
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
7
|
+
import { useBandPlot } from '../hooks/useBandPlot'
|
|
8
|
+
import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
|
|
9
|
+
import { AxisBottomDate } from '../primitives/Axes'
|
|
10
|
+
import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
|
|
11
|
+
import {
|
|
12
|
+
ChartTooltipFloat,
|
|
13
|
+
TooltipBody,
|
|
14
|
+
TooltipHeader,
|
|
15
|
+
TooltipRow,
|
|
16
|
+
} from '../primitives/ChartTooltip'
|
|
17
|
+
import { Crosshair } from '../primitives/Crosshair'
|
|
18
|
+
import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
|
|
19
|
+
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
20
|
+
import type { ChartLegendConfig, SeriesStyle } from '../series'
|
|
21
|
+
import { fmtAxisDate } from '../utils/format'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A state a strip can draw. Extends `SeriesStyle` rather than `ChartSeries` because a band has no
|
|
25
|
+
* numeric value to plot — `formatValue` reads the datum directly.
|
|
26
|
+
*/
|
|
27
|
+
export type BandStripSeries<T> = SeriesStyle & {
|
|
28
|
+
/** The derived tooltip row's value for a band in this state. Omit for a state whose NAME is the
|
|
29
|
+
* whole reading ("Not measured"), which then renders as a label with an empty value. */
|
|
30
|
+
formatValue?: (d: T) => string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One drawn band — which state it is in, and the three ways a state can be qualified. */
|
|
34
|
+
export type BandSpan = {
|
|
35
|
+
/**
|
|
36
|
+
* Series key naming this band's state: the tie to its legend entry, its fill, and its derived
|
|
37
|
+
* tooltip row. A key not present in `series` draws nothing — the loud failure is deliberate,
|
|
38
|
+
* since the alternative is a mark no legend entry accounts for.
|
|
39
|
+
*/
|
|
40
|
+
state: string
|
|
41
|
+
/**
|
|
42
|
+
* Overrides the fill derived from the state's series entry — the seam for an intensity ramp
|
|
43
|
+
* WITHIN one state (5% loss vs 0.5% loss are the same state at different strengths). Build it
|
|
44
|
+
* with `alpha(VX.*, …)`, never a raw hex.
|
|
45
|
+
*/
|
|
46
|
+
fill?: string
|
|
47
|
+
/**
|
|
48
|
+
* 0..1 of this band's width that no source sample covers, drawn hatched from the band's right.
|
|
49
|
+
*
|
|
50
|
+
* This is what a fold owes the reader. A 1-of-3-measured slot whose merge reports the one
|
|
51
|
+
* measured member's reading paints, without this, as a fully-measured band — a reading asserted
|
|
52
|
+
* over two slots that reported nothing either way.
|
|
53
|
+
*/
|
|
54
|
+
absentFraction?: number
|
|
55
|
+
/**
|
|
56
|
+
* A shorter, inset bar drawn over the band — for a fact that must NOT be readable off the fill
|
|
57
|
+
* ramp (a renegotiation, a restart, a config change). Its colour comes from a `series` entry, so
|
|
58
|
+
* it cannot be drawn in a hue the legend does not name.
|
|
59
|
+
*/
|
|
60
|
+
marker?: {
|
|
61
|
+
/** Series key naming the marker's colour. Defaults to the band's own `state`. */
|
|
62
|
+
state?: string
|
|
63
|
+
/** Vertical inset from the band's top and bottom, in px. Default 6. */
|
|
64
|
+
inset?: number
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type BandStripProps<T> = {
|
|
69
|
+
data: readonly T[]
|
|
70
|
+
/** Stable id — identifies this chart as the cursor's source. */
|
|
71
|
+
chartId: string
|
|
72
|
+
/** Extracts the x-domain key. On a band strip this is a SLOT START, so `cursorResolution`
|
|
73
|
+
* defaults to `'leading'` (see below) rather than `CartesianChart`'s `'nearest'`. */
|
|
74
|
+
getX: (d: T) => string
|
|
75
|
+
/** The states this strip can draw — the single source of truth for legend entries, band fills,
|
|
76
|
+
* and the derived tooltip row. */
|
|
77
|
+
series: readonly BandStripSeries<T>[]
|
|
78
|
+
/** Which state each datum is in. */
|
|
79
|
+
getBand: (d: T) => BandSpan
|
|
80
|
+
/** Fixed height in px, forwarded to `ChartFrame`. The band row gets whatever the measured
|
|
81
|
+
* margins and the measured legend band leave. Default 240. */
|
|
82
|
+
height?: number
|
|
83
|
+
/** Fill the parent flex/grid cell's measured height instead of a fixed one. */
|
|
84
|
+
fill?: boolean
|
|
85
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
86
|
+
formatX?: (key: string) => string
|
|
87
|
+
/** Which keys get a tick. Default `smartTicks`. */
|
|
88
|
+
xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
|
|
89
|
+
/** Collapse adjacent data at widths where one band per datum sub-pixels. */
|
|
90
|
+
fold?: BandFold<T>
|
|
91
|
+
/**
|
|
92
|
+
* How a sibling's broadcast cursor key resolves against this strip's slots. Default
|
|
93
|
+
* `'leading'` — strict containment, "the slot that swallowed this key" — because a band IS a
|
|
94
|
+
* span keyed by its leading edge, and it stays correct at every fold width. `'nearest'` puts
|
|
95
|
+
* the crosshair one slot right for every source key in the back half of a slot.
|
|
96
|
+
*/
|
|
97
|
+
cursorResolution?: CursorResolution
|
|
98
|
+
tooltip?: BandTooltipConfig<T> | false
|
|
99
|
+
legend?: ChartLegendConfig | false
|
|
100
|
+
/** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
|
|
101
|
+
* state — a hatched band is the one mark a reader cannot decode from an axis. Default
|
|
102
|
+
* `VX.neutral`, unnamed. */
|
|
103
|
+
absentState?: string
|
|
104
|
+
/** Per-side overrides of the measured margins — applied last. */
|
|
105
|
+
margin?: Partial<ChartMargin>
|
|
106
|
+
/** Accessible text alternative, forwarded to `ChartFrame` (+ `role="group"`) and to the
|
|
107
|
+
* focusable hover overlay's slider label. */
|
|
108
|
+
ariaLabel?: string
|
|
109
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc. */
|
|
110
|
+
isPending?: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_MARKER_INSET = 6
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
|
|
117
|
+
*
|
|
118
|
+
* `CartesianChart` cannot host this. It renders `AxisLeftNumeric` unconditionally, so composing it
|
|
119
|
+
* would draw a numeric axis over a chart that measures nothing vertically — and its `scalePoint` x
|
|
120
|
+
* scale has positions but no widths, which is the whole content of a strip. This is the
|
|
121
|
+
* non-single-plot escape `docs/CHARTS-SPEC.md` § "The contract" describes, assembled from the same
|
|
122
|
+
* parts every other chart gets (`ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat`)
|
|
123
|
+
* via `useBandPlot`, never re-implemented.
|
|
124
|
+
*
|
|
125
|
+
* The three qualifications on {@link BandSpan} — `fill`, `absentFraction`, `marker` — are the ones
|
|
126
|
+
* a state strip cannot do without: an intensity ramp inside a state, the share of a folded slot
|
|
127
|
+
* nothing measured, and a fact that must not be read off the ramp.
|
|
128
|
+
*/
|
|
129
|
+
function BandStripInner<T>(props: BandStripProps<T>) {
|
|
130
|
+
const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<ChartFrame
|
|
134
|
+
series={series}
|
|
135
|
+
chartId={chartId}
|
|
136
|
+
{...(height !== undefined && { height })}
|
|
137
|
+
{...(fill !== undefined && { fill })}
|
|
138
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
139
|
+
{...(isPending !== undefined && { isPending })}
|
|
140
|
+
legend={resolveLegend(legend)}
|
|
141
|
+
>
|
|
142
|
+
{(plot) => <BandStripPlot {...props} plot={plot} />}
|
|
143
|
+
</ChartFrame>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type BandStripPlotProps<T> = BandStripProps<T> & {
|
|
148
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The measured plot — split from {@link BandStripInner} so its scale/cursor hooks only run once
|
|
152
|
+
* `ChartFrame` has resolved a non-empty plot rect (parity with `DualPanel`). */
|
|
153
|
+
function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
154
|
+
const {
|
|
155
|
+
data,
|
|
156
|
+
chartId,
|
|
157
|
+
getX,
|
|
158
|
+
series,
|
|
159
|
+
getBand,
|
|
160
|
+
formatX = fmtAxisDate,
|
|
161
|
+
xTickValues,
|
|
162
|
+
fold,
|
|
163
|
+
cursorResolution = 'leading',
|
|
164
|
+
tooltip,
|
|
165
|
+
absentState,
|
|
166
|
+
margin: marginOverride,
|
|
167
|
+
ariaLabel,
|
|
168
|
+
plot,
|
|
169
|
+
} = props
|
|
170
|
+
|
|
171
|
+
const { hidden } = plot
|
|
172
|
+
const styleByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
|
|
173
|
+
|
|
174
|
+
const band = useBandPlot<T>({
|
|
175
|
+
data,
|
|
176
|
+
chartId,
|
|
177
|
+
getX,
|
|
178
|
+
formatX,
|
|
179
|
+
width: plot.width,
|
|
180
|
+
cursorResolution,
|
|
181
|
+
...(fold !== undefined && { fold }),
|
|
182
|
+
...(xTickValues !== undefined && { xTickValues }),
|
|
183
|
+
...(marginOverride !== undefined && { margin: marginOverride }),
|
|
184
|
+
...(tooltip !== undefined && { tooltip }),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
|
|
188
|
+
const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
|
|
189
|
+
const hatchId = `${chartId}-band-absent`
|
|
190
|
+
const absentColor =
|
|
191
|
+
(absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
|
|
192
|
+
|
|
193
|
+
const rects = useMemo<ReactNode[]>(() => {
|
|
194
|
+
const out: ReactNode[] = []
|
|
195
|
+
bands.forEach((d, i) => {
|
|
196
|
+
const span = getBand(d)
|
|
197
|
+
const style = styleByKey.get(span.state)
|
|
198
|
+
if (style === undefined || hidden.has(span.state)) return
|
|
199
|
+
// Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
|
|
200
|
+
// 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
|
|
201
|
+
// straight into `width="NaN"`, a band that silently fails to paint.
|
|
202
|
+
const absent = clampFraction(span.absentFraction)
|
|
203
|
+
const measuredWidth = bandWidth * (1 - absent)
|
|
204
|
+
const hatchWidth = bandWidth - measuredWidth
|
|
205
|
+
const x = i * step
|
|
206
|
+
const markerKey = span.marker?.state ?? span.state
|
|
207
|
+
const markerColor = styleByKey.get(markerKey)?.color
|
|
208
|
+
const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
|
|
209
|
+
out.push(
|
|
210
|
+
<g key={getX(d)}>
|
|
211
|
+
{measuredWidth > 0 && (
|
|
212
|
+
<rect
|
|
213
|
+
x={x}
|
|
214
|
+
y={0}
|
|
215
|
+
width={measuredWidth}
|
|
216
|
+
height={stripHeight}
|
|
217
|
+
rx={1}
|
|
218
|
+
fill={span.fill ?? alpha(style.color, style.fillOpacity ?? 1)}
|
|
219
|
+
pointerEvents="none"
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
222
|
+
{hatchWidth > 0 && (
|
|
223
|
+
<rect
|
|
224
|
+
x={x + measuredWidth}
|
|
225
|
+
y={0}
|
|
226
|
+
width={hatchWidth}
|
|
227
|
+
height={stripHeight}
|
|
228
|
+
rx={1}
|
|
229
|
+
fill={hatchFill(hatchId)}
|
|
230
|
+
pointerEvents="none"
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
233
|
+
{span.marker !== undefined && markerColor !== undefined && measuredWidth > 0 && (
|
|
234
|
+
<rect
|
|
235
|
+
x={x}
|
|
236
|
+
y={inset}
|
|
237
|
+
width={measuredWidth}
|
|
238
|
+
height={Math.max(stripHeight - 2 * inset, 2)}
|
|
239
|
+
rx={1}
|
|
240
|
+
fill={markerColor}
|
|
241
|
+
pointerEvents="none"
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
</g>,
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
return out
|
|
248
|
+
}, [bands, getBand, getX, styleByKey, hidden, bandWidth, step, stripHeight, hatchId])
|
|
249
|
+
|
|
250
|
+
if (plotWidth <= 0 || bands.length === 0) return null
|
|
251
|
+
|
|
252
|
+
const cfg = tooltip === false ? undefined : tooltip
|
|
253
|
+
const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
|
|
254
|
+
const row = point === null ? null : deriveBandRow(getBand(point), styleByKey, hidden, point)
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
<svg ref={band.svgRef} width={plot.width} height={plot.height}>
|
|
259
|
+
<defs>
|
|
260
|
+
<HatchPattern
|
|
261
|
+
id={hatchId}
|
|
262
|
+
color={absentColor}
|
|
263
|
+
opacity={0.7}
|
|
264
|
+
size={hatchSizeFor(bandWidth)}
|
|
265
|
+
/>
|
|
266
|
+
</defs>
|
|
267
|
+
{/* theme-allow-file basalt/hand-rolled-plot — a strip has ONE dimension: slots over a
|
|
268
|
+
category axis, no numeric y at all. `CartesianChart`'s contract is a single plot rect
|
|
269
|
+
with one or two numeric y axes and it renders `AxisLeftNumeric` unconditionally, so it
|
|
270
|
+
cannot express this shape. The plot is assembled here from the SAME parts every other
|
|
271
|
+
chart gets — `ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through
|
|
272
|
+
`useBandPlot`, which `MirroredBars` composes too rather than copying this file. */}
|
|
273
|
+
<Group left={margin.left} top={margin.top}>
|
|
274
|
+
{rects}
|
|
275
|
+
|
|
276
|
+
{crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={stripHeight} />}
|
|
277
|
+
|
|
278
|
+
<AxisBottomDate
|
|
279
|
+
top={stripHeight}
|
|
280
|
+
scale={scale}
|
|
281
|
+
tickValues={band.tickValues}
|
|
282
|
+
tickFormat={(v) => formatX(String(v))}
|
|
283
|
+
/>
|
|
284
|
+
|
|
285
|
+
<HoverOverlay
|
|
286
|
+
width={plotWidth}
|
|
287
|
+
height={stripHeight}
|
|
288
|
+
onMove={cursor.onPointerMove}
|
|
289
|
+
onLeave={cursor.onPointerLeave}
|
|
290
|
+
onKeyDown={cursor.onKeyDown}
|
|
291
|
+
onBlur={cursor.onBlur}
|
|
292
|
+
valueMax={Math.max(bands.length - 1, 0)}
|
|
293
|
+
{...(point !== null && {
|
|
294
|
+
valueNow: bands.indexOf(point),
|
|
295
|
+
valueText: formatX(getX(point)),
|
|
296
|
+
})}
|
|
297
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
298
|
+
/>
|
|
299
|
+
</Group>
|
|
300
|
+
</svg>
|
|
301
|
+
|
|
302
|
+
{band.showTooltip && point !== null && (
|
|
303
|
+
<ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
|
|
304
|
+
<TooltipHeader
|
|
305
|
+
date={getX(point)}
|
|
306
|
+
{...(cfg?.formatHeader !== undefined && {
|
|
307
|
+
format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
|
|
308
|
+
})}
|
|
309
|
+
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
310
|
+
/>
|
|
311
|
+
<TooltipBody>
|
|
312
|
+
{cfg?.prependRows?.(point, { hidden })}
|
|
313
|
+
{row !== null && (
|
|
314
|
+
<TooltipRow
|
|
315
|
+
color={row.color}
|
|
316
|
+
label={row.label}
|
|
317
|
+
value={row.value}
|
|
318
|
+
shape={row.shape}
|
|
319
|
+
dashed={row.dashed}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
{cfg?.extraRows?.(point, { hidden })}
|
|
323
|
+
</TooltipBody>
|
|
324
|
+
</ChartTooltipFloat>
|
|
325
|
+
)}
|
|
326
|
+
</>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
|
|
331
|
+
* end, since the alternative is a band that does not render at all. */
|
|
332
|
+
function clampFraction(value: number | undefined): number {
|
|
333
|
+
if (value === undefined || !Number.isFinite(value)) return 0
|
|
334
|
+
return Math.min(Math.max(value, 0), 1)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The strip's one derived row: the hovered band's STATE, named and formatted by its own `series`
|
|
339
|
+
* entry. A strip cannot show a row for a state it does not draw, and cannot name a state the
|
|
340
|
+
* legend does not carry — the same guarantee `deriveTooltipRows` gives a cartesian chart, over a
|
|
341
|
+
* shape that has no numeric value to derive from.
|
|
342
|
+
*/
|
|
343
|
+
function deriveBandRow<T>(
|
|
344
|
+
span: BandSpan,
|
|
345
|
+
styleByKey: ReadonlyMap<string, BandStripSeries<T>>,
|
|
346
|
+
hidden: ReadonlySet<string>,
|
|
347
|
+
datum: T,
|
|
348
|
+
): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
|
|
349
|
+
const style = styleByKey.get(span.state)
|
|
350
|
+
if (style === undefined || style.tooltip === false || hidden.has(span.state)) return null
|
|
351
|
+
return {
|
|
352
|
+
color: style.color,
|
|
353
|
+
label: style.label,
|
|
354
|
+
value: style.formatValue?.(datum) ?? '',
|
|
355
|
+
shape: style.mark === 'line' ? 'line' : 'bar',
|
|
356
|
+
dashed: style.dash === 'dashed',
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
|
|
362
|
+
* `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
|
|
363
|
+
*/
|
|
364
|
+
export const BandStrip = memo(BandStripInner) as typeof BandStripInner
|
|
@@ -107,8 +107,12 @@ export type BarsProps<T> = {
|
|
|
107
107
|
/** Per-bar opacity override. Receives the data point + bar key. Default 0.85. */
|
|
108
108
|
barOpacity?: (d: T, key: string) => number
|
|
109
109
|
|
|
110
|
-
/**
|
|
110
|
+
/** Exact number of x ticks. Default: as many as fit. Ignored when `xTickValues` is set. */
|
|
111
111
|
xTicks?: number
|
|
112
|
+
/** Which domain keys get a tick, from the full key list and the resolved plot width. Takes
|
|
113
|
+
* precedence over `xTicks` — see `CartesianChartProps.xTickValues` for why a count is not
|
|
114
|
+
* always enough. */
|
|
115
|
+
xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
|
|
112
116
|
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
113
117
|
formatX?: (key: string) => string
|
|
114
118
|
/**
|
|
@@ -161,6 +165,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
161
165
|
barLayout = 'stacked',
|
|
162
166
|
barOpacity,
|
|
163
167
|
xTicks,
|
|
168
|
+
xTickValues,
|
|
164
169
|
formatX,
|
|
165
170
|
cursorResolution,
|
|
166
171
|
tooltip,
|
|
@@ -280,6 +285,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
280
285
|
y={yConfig}
|
|
281
286
|
{...(y2 !== undefined && { y2 })}
|
|
282
287
|
{...(xTicks !== undefined && { xTicks })}
|
|
288
|
+
{...(xTickValues !== undefined && { xTickValues })}
|
|
283
289
|
{...(formatX !== undefined && { formatX })}
|
|
284
290
|
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
285
291
|
{...(zones !== undefined && { zones })}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `Bars.test.tsx`) — DualPanel has two `HoverOverlay`s sharing one cursor, but only the TOP one is
|
|
7
7
|
* keyboard-focusable, so `getByRole('slider')` is unambiguous.
|
|
8
8
|
*/
|
|
9
|
-
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
9
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
10
10
|
import { describe, expect, test } from 'bun:test'
|
|
11
11
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
12
12
|
import { ChartCursorScope } from '../cursor/scope'
|
|
@@ -499,10 +499,16 @@ describe('DualPanel — onFollow (follower behaviour parity with CartesianChart)
|
|
|
499
499
|
test('the follower tooltip is not aria-live; the source is', async () => {
|
|
500
500
|
renderPair(true)
|
|
501
501
|
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
502
|
-
await screen.findByText('V')
|
|
503
502
|
|
|
504
|
-
|
|
505
|
-
|
|
503
|
+
// The FOLLOWER anchors synchronously off its own svg rect; the SOURCE positions against
|
|
504
|
+
// `cursor.anchor`, which `useChartCursor` coalesces through `requestAnimationFrame` — one frame
|
|
505
|
+
// later. So waiting on the follower's 'V' is no barrier for the source tooltip. Wait for the
|
|
506
|
+
// PAIR, which is what's being asserted.
|
|
507
|
+
const tooltips = await waitFor(() => {
|
|
508
|
+
const found = screen.getAllByRole('tooltip')
|
|
509
|
+
expect(found).toHaveLength(2)
|
|
510
|
+
return found
|
|
511
|
+
})
|
|
506
512
|
const sourceTooltip = tooltips.find((t) => within(t).queryByText('Shown') !== null)
|
|
507
513
|
const followerTooltip = tooltips.find((t) => within(t).queryByText('V') !== null)
|
|
508
514
|
expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MirroredBars` — the properties `DualPanel` provably cannot supply: two BAR panes (no line pane),
|
|
3
|
+
* two INDEPENDENT domains resolved from two accessors (not one signed one), and absence hatched
|
|
4
|
+
* across the full band rather than sitting on one side of a baseline.
|
|
5
|
+
*
|
|
6
|
+
* Under this DOM harness there is no ResizeObserver, so `ChartFrame` falls back to its `minWidth`
|
|
7
|
+
* floor — `plot.width` is a fixed 200.
|
|
8
|
+
*/
|
|
9
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
12
|
+
import type { ChartSeries } from '../series'
|
|
13
|
+
import { MirroredBars } from './MirroredBars'
|
|
14
|
+
|
|
15
|
+
type Point = { key: string; down: number | null; up: number | null; absent: number }
|
|
16
|
+
|
|
17
|
+
const SERIES: ChartSeries<Point>[] = [
|
|
18
|
+
{ key: 'down', label: 'Download', color: '#06c', mark: 'bar', getValue: (d) => d.down },
|
|
19
|
+
{ key: 'up', label: 'Upload', color: '#999', mark: 'bar', getValue: (d) => d.up },
|
|
20
|
+
{ key: 'absent', label: 'Not measured', color: '#555', mark: 'bar', getValue: () => null },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const DATA: Point[] = [
|
|
24
|
+
{ key: '2026-08-01', down: 800, up: 40, absent: 0 },
|
|
25
|
+
{ key: '2026-08-02', down: 400, up: 20, absent: 0 },
|
|
26
|
+
{ key: '2026-08-03', down: null, up: null, absent: 1 },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
function renderChart(props: Partial<Parameters<typeof MirroredBars<Point>>[0]> = {}) {
|
|
30
|
+
return render(
|
|
31
|
+
<ChartCursorScope>
|
|
32
|
+
<MirroredBars<Point>
|
|
33
|
+
data={DATA}
|
|
34
|
+
chartId="mb"
|
|
35
|
+
getX={(d) => d.key}
|
|
36
|
+
series={SERIES}
|
|
37
|
+
up={{ key: 'up', format: (v) => `${v}u`, ticks: 2 }}
|
|
38
|
+
down={{ key: 'down', format: (v) => `${v}d`, ticks: 2 }}
|
|
39
|
+
getAbsentFraction={(d) => d.absent}
|
|
40
|
+
height={200}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
</ChartCursorScope>,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Tick label text of one left axis, in paint order (up pane first, down pane second). */
|
|
48
|
+
function axisText(container: HTMLElement, index: number): string {
|
|
49
|
+
return container.querySelectorAll('.visx-axis-left')[index]?.textContent ?? ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('MirroredBars — two panes, two independent domains', () => {
|
|
53
|
+
test('each pane paints its OWN axis in its OWN units — a shared scale could print only one', () => {
|
|
54
|
+
const { container } = renderChart({
|
|
55
|
+
up: { key: 'up', format: (v) => `${v}u`, max: 100, ticks: 2 },
|
|
56
|
+
down: { key: 'down', format: (v) => `${v}d`, max: 1000, ticks: 2 },
|
|
57
|
+
})
|
|
58
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
|
|
59
|
+
// Two axes, two domains, two unit suffixes — neither axis carries the other's numbers.
|
|
60
|
+
expect(axisText(container, 0)).toContain('100u')
|
|
61
|
+
expect(axisText(container, 0)).not.toContain('d')
|
|
62
|
+
expect(axisText(container, 1)).toContain('1000d')
|
|
63
|
+
expect(axisText(container, 1)).not.toContain('u')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('an auto pane resolves from its OWN accessor: 40 up beside 800 down', () => {
|
|
67
|
+
const { container } = renderChart()
|
|
68
|
+
// `40` can only come from the up accessor's own maximum; on a shared [0, 800] scale d3 would
|
|
69
|
+
// never place a tick there.
|
|
70
|
+
expect(axisText(container, 0)).toContain('40u')
|
|
71
|
+
expect(axisText(container, 1)).not.toContain('40d')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a pinned `max` on one pane leaves the other pane’s auto domain alone', () => {
|
|
75
|
+
const { container } = renderChart({ up: { key: 'up', format: (v) => `${v}u`, max: 200 } })
|
|
76
|
+
expect(axisText(container, 0)).toContain('200u')
|
|
77
|
+
// Down stayed auto — its ticks still span its own 800 maximum, not the pinned 200.
|
|
78
|
+
expect(axisText(container, 1)).toContain('500d')
|
|
79
|
+
expect(axisText(container, 1)).not.toContain('200d')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('`autoMaxFloor` widens a quiet pane without touching its sibling', () => {
|
|
83
|
+
const withoutFloor = renderChart()
|
|
84
|
+
expect(axisText(withoutFloor.container, 0)).toContain('40u')
|
|
85
|
+
withoutFloor.unmount()
|
|
86
|
+
|
|
87
|
+
const { container } = renderChart({
|
|
88
|
+
up: { key: 'up', format: (v) => `${v}u`, autoMaxFloor: 500, ticks: 2 },
|
|
89
|
+
})
|
|
90
|
+
// The floor lifted the up domain well past its 40 maximum...
|
|
91
|
+
expect(axisText(container, 0)).toContain('400u')
|
|
92
|
+
expect(axisText(container, 0)).not.toContain('40u')
|
|
93
|
+
// ...and the down pane is unmoved.
|
|
94
|
+
expect(axisText(container, 1)).toContain('500d')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('MirroredBars — geometry around the shared baseline', () => {
|
|
99
|
+
test('up bars grow upward from the baseline, down bars downward, and both are proportional', () => {
|
|
100
|
+
const { container } = renderChart()
|
|
101
|
+
const rects = [...container.querySelectorAll('rect')].filter(
|
|
102
|
+
(r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
|
|
103
|
+
)
|
|
104
|
+
// Two measured points × two panes.
|
|
105
|
+
expect(rects).toHaveLength(4)
|
|
106
|
+
const [up1, down1, up2, down2] = rects.map((r) => ({
|
|
107
|
+
y: Number(r.getAttribute('y')),
|
|
108
|
+
h: Number(r.getAttribute('height')),
|
|
109
|
+
}))
|
|
110
|
+
// Both panes share ONE baseline: the up bar's bottom edge equals the down bar's top edge.
|
|
111
|
+
expect(up1!.y + up1!.h).toBeCloseTo(down1!.y, 5)
|
|
112
|
+
expect(up2!.y + up2!.h).toBeCloseTo(down2!.y, 5)
|
|
113
|
+
// Half the value, half the height — within each pane's own scale.
|
|
114
|
+
expect(down2!.h / down1!.h).toBeCloseTo(0.5, 5)
|
|
115
|
+
expect(up2!.h / up1!.h).toBeCloseTo(0.5, 5)
|
|
116
|
+
// ...and the two panes are NOT on one scale: 40 up and 800 down draw comparable heights.
|
|
117
|
+
expect(up1!.h).toBeGreaterThan(down1!.h * 0.2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('an unmeasured point hatches the FULL band, not one side of the baseline', () => {
|
|
121
|
+
const { container } = renderChart()
|
|
122
|
+
const hatched = [...container.querySelectorAll('rect')].filter(
|
|
123
|
+
(r) => r.getAttribute('fill') === 'url(#mb-mirrored-absent)',
|
|
124
|
+
)
|
|
125
|
+
expect(hatched).toHaveLength(1)
|
|
126
|
+
expect(Number(hatched[0]?.getAttribute('y'))).toBe(0)
|
|
127
|
+
const baselineLine = container.querySelector('line[stroke-width="1"]')
|
|
128
|
+
const baselineY = Number(baselineLine?.getAttribute('y1'))
|
|
129
|
+
expect(Number(hatched[0]?.getAttribute('height'))).toBeGreaterThan(baselineY)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a NaN value draws no bar at all rather than NaN geometry', () => {
|
|
133
|
+
// `deriveTooltipRows` skips only null, so a NaN reaching `scaleLinear` used to emit
|
|
134
|
+
// `y="NaN" height="NaN"` — a bar that silently fails to paint behind a React warning.
|
|
135
|
+
const { container } = renderChart({
|
|
136
|
+
data: [{ key: '2026-08-01', down: Number.NaN, up: Number.NaN, absent: 0 }],
|
|
137
|
+
})
|
|
138
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
139
|
+
const painted = [...container.querySelectorAll('rect')].filter(
|
|
140
|
+
(r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
|
|
141
|
+
)
|
|
142
|
+
expect(painted).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('`getBarOpacity` dims a qualified measurement without changing its geometry', () => {
|
|
146
|
+
const { container } = renderChart({ getBarOpacity: (d) => (d.key === '2026-08-02' ? 0.45 : 1) })
|
|
147
|
+
const fills = [...container.querySelectorAll('rect')]
|
|
148
|
+
.map((r) => r.getAttribute('fill'))
|
|
149
|
+
.filter((f): f is string => f?.startsWith('color-mix') === true)
|
|
150
|
+
expect(fills).toContain('color-mix(in srgb, #06c 45%, transparent)')
|
|
151
|
+
expect(fills).toContain('color-mix(in srgb, #06c 100%, transparent)')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('MirroredBars — legend toggle removes a pane whole', () => {
|
|
156
|
+
test('hiding a pane drops its bars AND its axis together', () => {
|
|
157
|
+
const { container } = renderChart()
|
|
158
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
|
|
159
|
+
|
|
160
|
+
const toggle = container.querySelector('[data-legend-key="up"]')
|
|
161
|
+
fireEvent.click(toggle as Element)
|
|
162
|
+
|
|
163
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(1)
|
|
164
|
+
expect(container.textContent).not.toContain('40u')
|
|
165
|
+
expect(axisText(container, 0)).toContain('500d')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('MirroredBars — tooltip rows are derived, each in its own pane’s units', () => {
|
|
170
|
+
test('a pane row falls back to that pane’s formatter, never the other pane’s', async () => {
|
|
171
|
+
renderChart({ ariaLabel: 'Carried' })
|
|
172
|
+
const slider = screen.getByRole('slider')
|
|
173
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
174
|
+
|
|
175
|
+
const tip = await screen.findByRole('tooltip')
|
|
176
|
+
expect(tip.textContent).toContain('800d')
|
|
177
|
+
expect(tip.textContent).toContain('40u')
|
|
178
|
+
// The legend-only absence entry has no value, so it never becomes a row.
|
|
179
|
+
expect(tip.textContent).not.toContain('Not measured')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an unmeasured point yields no pane rows at all rather than a fabricated zero', async () => {
|
|
183
|
+
renderChart({ ariaLabel: 'Carried' })
|
|
184
|
+
const slider = screen.getByRole('slider')
|
|
185
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
186
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
187
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
188
|
+
|
|
189
|
+
const tip = await screen.findByRole('tooltip')
|
|
190
|
+
expect(tip.textContent).not.toContain('Download')
|
|
191
|
+
expect(tip.textContent).not.toContain('Upload')
|
|
192
|
+
})
|
|
193
|
+
})
|