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,457 @@
|
|
|
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
|
+
/**
|
|
29
|
+
* The derived tooltip row's value for a band in this state.
|
|
30
|
+
*
|
|
31
|
+
* Return `null` for a datum in this state whose reading is ABSENT — the row renders
|
|
32
|
+
* {@link NO_READING} instead, which is the escape `ChartSeries.getValue` has and this shape did
|
|
33
|
+
* not: with no value accessor, an absent reading had to come back as `''` and render as a label
|
|
34
|
+
* with an empty value, indistinguishable from a state whose name is the whole reading.
|
|
35
|
+
*
|
|
36
|
+
* Omit the function entirely for a state whose NAME *is* the whole reading ("Not measured").
|
|
37
|
+
*/
|
|
38
|
+
formatValue?: (d: T) => string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One drawn band — which state it is in, and the three ways a state can be qualified. */
|
|
42
|
+
export type BandSpan = {
|
|
43
|
+
/**
|
|
44
|
+
* Series key naming this band's state: the tie to its legend entry, its fill, and its derived
|
|
45
|
+
* tooltip row.
|
|
46
|
+
*
|
|
47
|
+
* A key not present in `series` is a bug TypeScript cannot catch (this is a `string`), and it
|
|
48
|
+
* used to draw NOTHING — which on a strip whose vocabulary is measured/not-measured is
|
|
49
|
+
* indistinguishable from a real coverage gap, so a typo silently asserted "not measured". It now
|
|
50
|
+
* throws in development and draws an explicit unknown band in production; see
|
|
51
|
+
* {@link unknownStateError} for why the two differ.
|
|
52
|
+
*/
|
|
53
|
+
state: string
|
|
54
|
+
/**
|
|
55
|
+
* Overrides the fill derived from the state's series entry — the seam for an intensity ramp
|
|
56
|
+
* WITHIN one state (5% loss vs 0.5% loss are the same state at different strengths). Build it
|
|
57
|
+
* with `alpha(VX.*, …)`, never a raw hex.
|
|
58
|
+
*/
|
|
59
|
+
fill?: string
|
|
60
|
+
/**
|
|
61
|
+
* 0..1 of this band's width that no source sample covers, drawn hatched from the band's right.
|
|
62
|
+
*
|
|
63
|
+
* This is what a fold owes the reader. A 1-of-3-measured slot whose merge reports the one
|
|
64
|
+
* measured member's reading paints, without this, as a fully-measured band — a reading asserted
|
|
65
|
+
* over two slots that reported nothing either way.
|
|
66
|
+
*/
|
|
67
|
+
absentFraction?: number
|
|
68
|
+
/**
|
|
69
|
+
* A shorter, inset bar drawn over the band — for a fact that must NOT be readable off the fill
|
|
70
|
+
* ramp (a renegotiation, a restart, a config change). Its colour comes from a `series` entry, so
|
|
71
|
+
* it cannot be drawn in a hue the legend does not name.
|
|
72
|
+
*/
|
|
73
|
+
marker?: {
|
|
74
|
+
/** Series key naming the marker's colour. Defaults to the band's own `state`. */
|
|
75
|
+
state?: string
|
|
76
|
+
/** Vertical inset from the band's top and bottom, in px. Default 6. */
|
|
77
|
+
inset?: number
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type BandStripProps<T> = {
|
|
82
|
+
data: readonly T[]
|
|
83
|
+
/** Stable id — identifies this chart as the cursor's source. */
|
|
84
|
+
chartId: string
|
|
85
|
+
/** Extracts the x-domain key. On a band strip this is a SLOT START, so `cursorResolution`
|
|
86
|
+
* defaults to `'leading'` (see below) rather than `CartesianChart`'s `'nearest'`. */
|
|
87
|
+
getX: (d: T) => string
|
|
88
|
+
/** The states this strip can draw — the single source of truth for legend entries, band fills,
|
|
89
|
+
* and the derived tooltip row. */
|
|
90
|
+
series: readonly BandStripSeries<T>[]
|
|
91
|
+
/** Which state each datum is in. */
|
|
92
|
+
getBand: (d: T) => BandSpan
|
|
93
|
+
/** Fixed height in px, forwarded to `ChartFrame`. The band row gets whatever the measured
|
|
94
|
+
* margins and the measured legend band leave. Default 240. */
|
|
95
|
+
height?: number
|
|
96
|
+
/** Fill the parent flex/grid cell's measured height instead of a fixed one. */
|
|
97
|
+
fill?: boolean
|
|
98
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
99
|
+
formatX?: (key: string) => string
|
|
100
|
+
/** Which keys get a tick. Default `smartTicks`. */
|
|
101
|
+
xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
|
|
102
|
+
/** Collapse adjacent data at widths where one band per datum sub-pixels. */
|
|
103
|
+
fold?: BandFold<T>
|
|
104
|
+
/**
|
|
105
|
+
* How a sibling's broadcast cursor key resolves against this strip's slots. Default
|
|
106
|
+
* `'leading'` — strict containment, "the slot that swallowed this key" — because a band IS a
|
|
107
|
+
* span keyed by its leading edge, and it stays correct at every fold width. `'nearest'` puts
|
|
108
|
+
* the crosshair one slot right for every source key in the back half of a slot.
|
|
109
|
+
*/
|
|
110
|
+
cursorResolution?: CursorResolution
|
|
111
|
+
tooltip?: BandTooltipConfig<T> | false
|
|
112
|
+
legend?: ChartLegendConfig | false
|
|
113
|
+
/** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
|
|
114
|
+
* state — a hatched band is the one mark a reader cannot decode from an axis. Default
|
|
115
|
+
* `VX.neutral`, unnamed. A key naming no `series` entry throws (a PROP, so a typo can only be a
|
|
116
|
+
* wiring error) rather than falling back to the unnamed default. */
|
|
117
|
+
absentState?: string
|
|
118
|
+
/** Per-side overrides of the measured margins — applied last. */
|
|
119
|
+
margin?: Partial<ChartMargin>
|
|
120
|
+
/** Accessible text alternative, forwarded to `ChartFrame` (+ `role="group"`) and to the
|
|
121
|
+
* focusable hover overlay's slider label. */
|
|
122
|
+
ariaLabel?: string
|
|
123
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc. */
|
|
124
|
+
isPending?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DEFAULT_MARKER_INSET = 6
|
|
128
|
+
|
|
129
|
+
/** Rendered by a `formatValue` that returned `null` — an absent reading, told apart from a state
|
|
130
|
+
* whose name is the whole reading (which renders no value at all). */
|
|
131
|
+
const NO_READING = '\u2014'
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The one band treatment that belongs to no legend entry. A dashed neutral outline appears nowhere
|
|
135
|
+
* else on a strip — not as a state fill, not as the absence hatch — so an unresolvable key reads as
|
|
136
|
+
* "unnamed", never as data.
|
|
137
|
+
*/
|
|
138
|
+
const UNKNOWN_MARK = {
|
|
139
|
+
fill: alpha(VX.neutral, 0.2),
|
|
140
|
+
stroke: VX.neutral,
|
|
141
|
+
strokeWidth: 1,
|
|
142
|
+
strokeDasharray: '3 2',
|
|
143
|
+
} as const
|
|
144
|
+
|
|
145
|
+
/** The house dev gate (`src/provider`, `src/agent-chat` use the same expression): `basaltViteConfig`
|
|
146
|
+
* defines `process.env.NODE_ENV`, so a production bundle constant-folds this to `false` and drops
|
|
147
|
+
* the throw. Read per call, never hoisted to a module const, so a test can flip it. */
|
|
148
|
+
function isDev(): boolean {
|
|
149
|
+
return process.env['NODE_ENV'] !== 'production'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A `state` (or `marker.state`) naming no `series` entry.
|
|
154
|
+
*
|
|
155
|
+
* Dev throws, production draws {@link UNKNOWN_MARK}: `state` comes off the DATUM, so a feed that
|
|
156
|
+
* grows a state basalt has never seen must degrade to an honest unknown rather than take the page
|
|
157
|
+
* down — while a typo, which is the same input, still fails loudly everywhere it is being written.
|
|
158
|
+
* The one outcome ruled out in both is the old one: dropping the band, which renders as absence.
|
|
159
|
+
*/
|
|
160
|
+
function unknownStateError(what: string, key: string, known: Iterable<string>): Error {
|
|
161
|
+
return new Error(
|
|
162
|
+
`BandStrip: ${what} "${key}" names no \`series\` entry (known: ${[...known].join(', ')}) — so ` +
|
|
163
|
+
'it has no legend entry, no colour and no tooltip row. Add the state to `series`, or map the ' +
|
|
164
|
+
'datum onto one that is there.',
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A band's tooltip value: `null` from `formatValue` is an absent reading, `undefined` (no
|
|
169
|
+
* `formatValue` at all) is a state whose label is the whole row. */
|
|
170
|
+
function formatBandValue<T>(style: BandStripSeries<T>, datum: T): string {
|
|
171
|
+
if (style.formatValue === undefined) return ''
|
|
172
|
+
return style.formatValue(datum) ?? NO_READING
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
|
|
177
|
+
*
|
|
178
|
+
* `CartesianChart` cannot host this. It renders `AxisLeftNumeric` unconditionally, so composing it
|
|
179
|
+
* would draw a numeric axis over a chart that measures nothing vertically — and its `scalePoint` x
|
|
180
|
+
* scale has positions but no widths, which is the whole content of a strip. This is the
|
|
181
|
+
* non-single-plot escape `docs/CHARTS-SPEC.md` § "The contract" describes, assembled from the same
|
|
182
|
+
* parts every other chart gets (`ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat`)
|
|
183
|
+
* via `useBandPlot`, never re-implemented.
|
|
184
|
+
*
|
|
185
|
+
* The three qualifications on {@link BandSpan} — `fill`, `absentFraction`, `marker` — are the ones
|
|
186
|
+
* a state strip cannot do without: an intensity ramp inside a state, the share of a folded slot
|
|
187
|
+
* nothing measured, and a fact that must not be read off the ramp.
|
|
188
|
+
*/
|
|
189
|
+
function BandStripInner<T>(props: BandStripProps<T>) {
|
|
190
|
+
const { series, chartId, height, fill, legend, ariaLabel, isPending } = props
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ChartFrame
|
|
194
|
+
series={series}
|
|
195
|
+
chartId={chartId}
|
|
196
|
+
{...(height !== undefined && { height })}
|
|
197
|
+
{...(fill !== undefined && { fill })}
|
|
198
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
199
|
+
{...(isPending !== undefined && { isPending })}
|
|
200
|
+
legend={resolveLegend(legend)}
|
|
201
|
+
>
|
|
202
|
+
{(plot) => <BandStripPlot {...props} plot={plot} />}
|
|
203
|
+
</ChartFrame>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type BandStripPlotProps<T> = BandStripProps<T> & {
|
|
208
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The measured plot — split from {@link BandStripInner} so its scale/cursor hooks only run once
|
|
212
|
+
* `ChartFrame` has resolved a non-empty plot rect (parity with `DualPanel`). */
|
|
213
|
+
function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
214
|
+
const {
|
|
215
|
+
data,
|
|
216
|
+
chartId,
|
|
217
|
+
getX,
|
|
218
|
+
series,
|
|
219
|
+
getBand,
|
|
220
|
+
formatX = fmtAxisDate,
|
|
221
|
+
xTickValues,
|
|
222
|
+
fold,
|
|
223
|
+
cursorResolution = 'leading',
|
|
224
|
+
tooltip,
|
|
225
|
+
absentState,
|
|
226
|
+
margin: marginOverride,
|
|
227
|
+
ariaLabel,
|
|
228
|
+
plot,
|
|
229
|
+
} = props
|
|
230
|
+
|
|
231
|
+
const { hidden } = plot
|
|
232
|
+
const styleByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
|
|
233
|
+
|
|
234
|
+
const band = useBandPlot<T>({
|
|
235
|
+
data,
|
|
236
|
+
chartId,
|
|
237
|
+
getX,
|
|
238
|
+
formatX,
|
|
239
|
+
width: plot.width,
|
|
240
|
+
cursorResolution,
|
|
241
|
+
...(fold !== undefined && { fold }),
|
|
242
|
+
...(xTickValues !== undefined && { xTickValues }),
|
|
243
|
+
...(marginOverride !== undefined && { margin: marginOverride }),
|
|
244
|
+
...(tooltip !== undefined && { tooltip }),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
|
|
248
|
+
const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
|
|
249
|
+
const hatchId = `${chartId}-band-absent`
|
|
250
|
+
if (absentState !== undefined && !styleByKey.has(absentState)) {
|
|
251
|
+
throw unknownStateError('absentState', absentState, styleByKey.keys())
|
|
252
|
+
}
|
|
253
|
+
const absentColor =
|
|
254
|
+
(absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
|
|
255
|
+
|
|
256
|
+
const rects = useMemo<ReactNode[]>(() => {
|
|
257
|
+
const out: ReactNode[] = []
|
|
258
|
+
bands.forEach((d, i) => {
|
|
259
|
+
const span = getBand(d)
|
|
260
|
+
const style = styleByKey.get(span.state)
|
|
261
|
+
const x = i * step
|
|
262
|
+
if (style === undefined) {
|
|
263
|
+
if (isDev()) throw unknownStateError('BandSpan.state', span.state, styleByKey.keys())
|
|
264
|
+
out.push(
|
|
265
|
+
<rect
|
|
266
|
+
key={getX(d)}
|
|
267
|
+
{...UNKNOWN_MARK}
|
|
268
|
+
x={x}
|
|
269
|
+
y={0}
|
|
270
|
+
width={bandWidth}
|
|
271
|
+
height={stripHeight}
|
|
272
|
+
rx={1}
|
|
273
|
+
pointerEvents="none"
|
|
274
|
+
/>,
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (hidden.has(span.state)) return
|
|
279
|
+
// Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
|
|
280
|
+
// 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
|
|
281
|
+
// straight into `width="NaN"`, a band that silently fails to paint.
|
|
282
|
+
const absent = clampFraction(span.absentFraction)
|
|
283
|
+
const measuredWidth = bandWidth * (1 - absent)
|
|
284
|
+
const hatchWidth = bandWidth - measuredWidth
|
|
285
|
+
const markerKey = span.marker?.state ?? span.state
|
|
286
|
+
const markerColor = styleByKey.get(markerKey)?.color
|
|
287
|
+
if (span.marker !== undefined && markerColor === undefined && isDev()) {
|
|
288
|
+
throw unknownStateError('BandSpan.marker.state', markerKey, styleByKey.keys())
|
|
289
|
+
}
|
|
290
|
+
const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
|
|
291
|
+
out.push(
|
|
292
|
+
<g key={getX(d)}>
|
|
293
|
+
{measuredWidth > 0 && (
|
|
294
|
+
<rect
|
|
295
|
+
x={x}
|
|
296
|
+
y={0}
|
|
297
|
+
width={measuredWidth}
|
|
298
|
+
height={stripHeight}
|
|
299
|
+
rx={1}
|
|
300
|
+
fill={span.fill ?? alpha(style.color, style.fillOpacity ?? 1)}
|
|
301
|
+
pointerEvents="none"
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
{hatchWidth > 0 && (
|
|
305
|
+
<rect
|
|
306
|
+
x={x + measuredWidth}
|
|
307
|
+
y={0}
|
|
308
|
+
width={hatchWidth}
|
|
309
|
+
height={stripHeight}
|
|
310
|
+
rx={1}
|
|
311
|
+
fill={hatchFill(hatchId)}
|
|
312
|
+
pointerEvents="none"
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
{span.marker !== undefined && measuredWidth > 0 && (
|
|
316
|
+
<rect
|
|
317
|
+
{...(markerColor === undefined ? UNKNOWN_MARK : { fill: markerColor })}
|
|
318
|
+
x={x}
|
|
319
|
+
y={inset}
|
|
320
|
+
width={measuredWidth}
|
|
321
|
+
height={Math.max(stripHeight - 2 * inset, 2)}
|
|
322
|
+
rx={1}
|
|
323
|
+
pointerEvents="none"
|
|
324
|
+
/>
|
|
325
|
+
)}
|
|
326
|
+
</g>,
|
|
327
|
+
)
|
|
328
|
+
})
|
|
329
|
+
return out
|
|
330
|
+
}, [bands, getBand, getX, styleByKey, hidden, bandWidth, step, stripHeight, hatchId])
|
|
331
|
+
|
|
332
|
+
if (plotWidth <= 0 || bands.length === 0) return null
|
|
333
|
+
|
|
334
|
+
const cfg = tooltip === false ? undefined : tooltip
|
|
335
|
+
const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
|
|
336
|
+
const row = point === null ? null : deriveBandRow(getBand(point), styleByKey, hidden, point)
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<>
|
|
340
|
+
<svg ref={band.svgRef} width={plot.width} height={plot.height}>
|
|
341
|
+
<defs>
|
|
342
|
+
<HatchPattern
|
|
343
|
+
id={hatchId}
|
|
344
|
+
color={absentColor}
|
|
345
|
+
opacity={0.7}
|
|
346
|
+
size={hatchSizeFor(bandWidth)}
|
|
347
|
+
/>
|
|
348
|
+
</defs>
|
|
349
|
+
{/* theme-allow-file basalt/hand-rolled-plot — a strip has ONE dimension: slots over a
|
|
350
|
+
category axis, no numeric y at all. `CartesianChart`'s contract is a single plot rect
|
|
351
|
+
with one or two numeric y axes and it renders `AxisLeftNumeric` unconditionally, so it
|
|
352
|
+
cannot express this shape. The plot is assembled here from the SAME parts every other
|
|
353
|
+
chart gets — `ChartFrame`, `autoMargin`, `useChartCursor`, `ChartTooltipFloat` — through
|
|
354
|
+
`useBandPlot`, which `MirroredBars` composes too rather than copying this file. */}
|
|
355
|
+
<Group left={margin.left} top={margin.top}>
|
|
356
|
+
{rects}
|
|
357
|
+
|
|
358
|
+
{crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={stripHeight} />}
|
|
359
|
+
|
|
360
|
+
<AxisBottomDate
|
|
361
|
+
top={stripHeight}
|
|
362
|
+
scale={scale}
|
|
363
|
+
tickValues={band.tickValues}
|
|
364
|
+
tickFormat={(v) => formatX(String(v))}
|
|
365
|
+
/>
|
|
366
|
+
|
|
367
|
+
<HoverOverlay
|
|
368
|
+
width={plotWidth}
|
|
369
|
+
height={stripHeight}
|
|
370
|
+
onMove={cursor.onPointerMove}
|
|
371
|
+
onLeave={cursor.onPointerLeave}
|
|
372
|
+
onKeyDown={cursor.onKeyDown}
|
|
373
|
+
onBlur={cursor.onBlur}
|
|
374
|
+
valueMax={Math.max(bands.length - 1, 0)}
|
|
375
|
+
{...(point !== null && {
|
|
376
|
+
valueNow: bands.indexOf(point),
|
|
377
|
+
valueText: formatX(getX(point)),
|
|
378
|
+
})}
|
|
379
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
380
|
+
/>
|
|
381
|
+
</Group>
|
|
382
|
+
</svg>
|
|
383
|
+
|
|
384
|
+
{band.showTooltip && point !== null && (
|
|
385
|
+
<ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
|
|
386
|
+
<TooltipHeader
|
|
387
|
+
date={getX(point)}
|
|
388
|
+
{...(cfg?.formatHeader !== undefined && {
|
|
389
|
+
format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
|
|
390
|
+
})}
|
|
391
|
+
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
392
|
+
/>
|
|
393
|
+
<TooltipBody>
|
|
394
|
+
{cfg?.prependRows?.(point, { hidden })}
|
|
395
|
+
{row !== null && (
|
|
396
|
+
<TooltipRow
|
|
397
|
+
color={row.color}
|
|
398
|
+
label={row.label}
|
|
399
|
+
value={row.value}
|
|
400
|
+
shape={row.shape}
|
|
401
|
+
dashed={row.dashed}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
{cfg?.extraRows?.(point, { hidden })}
|
|
405
|
+
</TooltipBody>
|
|
406
|
+
</ChartTooltipFloat>
|
|
407
|
+
)}
|
|
408
|
+
</>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
|
|
413
|
+
* end, since the alternative is a band that does not render at all. */
|
|
414
|
+
function clampFraction(value: number | undefined): number {
|
|
415
|
+
if (value === undefined || !Number.isFinite(value)) return 0
|
|
416
|
+
return Math.min(Math.max(value, 0), 1)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The strip's one derived row: the hovered band's STATE, named and formatted by its own `series`
|
|
421
|
+
* entry. A strip cannot show a row for a state it does not draw, and cannot name a state the
|
|
422
|
+
* legend does not carry — the same guarantee `deriveTooltipRows` gives a cartesian chart, over a
|
|
423
|
+
* shape that has no numeric value to derive from.
|
|
424
|
+
*/
|
|
425
|
+
function deriveBandRow<T>(
|
|
426
|
+
span: BandSpan,
|
|
427
|
+
styleByKey: ReadonlyMap<string, BandStripSeries<T>>,
|
|
428
|
+
hidden: ReadonlySet<string>,
|
|
429
|
+
datum: T,
|
|
430
|
+
): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
|
|
431
|
+
const style = styleByKey.get(span.state)
|
|
432
|
+
// Production-only: dev threw while drawing. The row names the KEY rather than a state, so the
|
|
433
|
+
// tooltip cannot read back a state the legend does not carry.
|
|
434
|
+
if (style === undefined) {
|
|
435
|
+
return {
|
|
436
|
+
color: VX.neutral,
|
|
437
|
+
label: 'Unknown state',
|
|
438
|
+
value: span.state,
|
|
439
|
+
shape: 'bar',
|
|
440
|
+
dashed: true,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (style.tooltip === false || hidden.has(span.state)) return null
|
|
444
|
+
return {
|
|
445
|
+
color: style.color,
|
|
446
|
+
label: style.label,
|
|
447
|
+
value: formatBandValue(style, datum),
|
|
448
|
+
shape: style.mark === 'line' ? 'line' : 'bar',
|
|
449
|
+
dashed: style.dash === 'dashed',
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Hand-memoized: React Compiler does not process the shipped dist, so the hot kind is wrapped in
|
|
455
|
+
* `React.memo` to retain the auto-memoization it had as source (parity with `DualPanel`).
|
|
456
|
+
*/
|
|
457
|
+
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')
|