basalt-ui 1.15.0 → 1.17.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/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +74 -13
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/dist/charts/cursor/resolve.d.ts +29 -5
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +25 -4
- package/dist/charts/hooks/useChartCursor.d.ts +8 -1
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +6 -2
- package/dist/charts/index.d.ts +2 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +30 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +10 -3
- package/dist/charts/kinds/Bars.test.d.ts +2 -0
- package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
- package/dist/charts/kinds/DualPanel.d.ts +30 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +82 -12
- package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +9 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +45 -33
- package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
- package/dist/charts/kinds/StackedArea.d.ts +9 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +16 -1
- package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
- package/dist/charts/kinds/ZonedLine.d.ts +9 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +5 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +45 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +29 -12
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +1 -1
- package/dist/charts/primitives/ChartLegend.d.ts +3 -0
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +4 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +3 -2
- package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
- package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
- package/dist/charts/primitives/XZoneRects.d.ts +22 -8
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -1
- package/dist/charts/primitives/XZoneRects.js +39 -2
- package/dist/charts/series.d.ts +16 -3
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -1
- package/dist/charts/utils/domain.d.ts +15 -0
- package/dist/charts/utils/domain.d.ts.map +1 -1
- package/dist/charts/utils/domain.js +6 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +62 -0
- package/src/charts/cursor/resolve.ts +71 -7
- package/src/charts/hooks/useChartCursor.ts +12 -1
- package/src/charts/index.ts +2 -0
- package/src/charts/kinds/Bars.test.tsx +183 -0
- package/src/charts/kinds/Bars.tsx +41 -12
- package/src/charts/kinds/DualPanel.test.tsx +441 -0
- package/src/charts/kinds/DualPanel.tsx +127 -11
- package/src/charts/kinds/MultiLine.test.tsx +218 -0
- package/src/charts/kinds/MultiLine.tsx +64 -36
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +147 -0
- package/src/charts/kinds/ZonedLine.tsx +14 -0
- package/src/charts/primitives/CartesianChart.test.tsx +269 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -19
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/primitives/XZoneRects.test.tsx +60 -0
- package/src/charts/primitives/XZoneRects.tsx +75 -10
- package/src/charts/series.test.ts +51 -0
- package/src/charts/series.ts +18 -6
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
- package/src/charts/primitives/CartesianChart.test.ts +0 -99
|
@@ -5,6 +5,7 @@ import { scaleLinear, scalePoint } from '@visx/scale'
|
|
|
5
5
|
import { Bar, LinePath } from '@visx/shape'
|
|
6
6
|
import { Threshold } from '@visx/threshold'
|
|
7
7
|
import { memo, useMemo } from 'react'
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
8
9
|
import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
|
|
9
10
|
import {
|
|
10
11
|
ChartTooltipFloat,
|
|
@@ -17,6 +18,7 @@ import { Crosshair, SeriesDot } from '../primitives/Crosshair'
|
|
|
17
18
|
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
18
19
|
import { ZoneRects } from '../primitives/ZoneRects'
|
|
19
20
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
21
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
20
22
|
import { useChartCursor } from '../hooks/useChartCursor'
|
|
21
23
|
import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
|
|
22
24
|
import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
@@ -31,6 +33,15 @@ export type DualPanelProps<T> = {
|
|
|
31
33
|
height?: number
|
|
32
34
|
chartId: string
|
|
33
35
|
getX: (d: T) => string
|
|
36
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM) — same default `CartesianChart` uses,
|
|
37
|
+
* so omitting it is byte-identical to today's rendering. */
|
|
38
|
+
formatX?: (key: string) => string
|
|
39
|
+
/**
|
|
40
|
+
* How a sibling chart's broadcast cursor key resolves against this chart's points. Default
|
|
41
|
+
* `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
|
|
42
|
+
* keyed by its Monday) — see `CursorResolution`.
|
|
43
|
+
*/
|
|
44
|
+
cursorResolution?: CursorResolution
|
|
34
45
|
/** 1+ line series in the top pane — the single source of truth for color, dash, legend, and
|
|
35
46
|
* tooltip rows. E.g. acute (solid) / chronic (dashed). */
|
|
36
47
|
series: ChartSeries<T>[]
|
|
@@ -50,9 +61,29 @@ export type DualPanelProps<T> = {
|
|
|
50
61
|
/** Top pane share of the inner plot height. Default 0.62. */
|
|
51
62
|
topFraction?: number
|
|
52
63
|
formatTop: (v: number) => string
|
|
64
|
+
/** Drives the bottom-pane TICKS. Also the tooltip-row fallback when `formatBar` is omitted. */
|
|
53
65
|
formatBottom: (v: number) => string
|
|
66
|
+
/** Per-row tooltip formatter for the bottom-pane bar — overrides `formatBottom` for the tooltip
|
|
67
|
+
* row only (ticks keep reading `formatBottom`). Receives the hovered datum alongside the value,
|
|
68
|
+
* so a row can add a unit/decimal `formatBottom` can't (e.g. a velocity reading). */
|
|
69
|
+
formatBar?: (v: number, d: T) => string
|
|
70
|
+
/**
|
|
71
|
+
* Bottom-pane y-domain. Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` computed from the
|
|
72
|
+
* data, `nice`d. A fixed tuple pins the domain instead (and is used verbatim, un-`nice`d).
|
|
73
|
+
*/
|
|
74
|
+
bottomYDomain?: [number, number] | 'auto'
|
|
75
|
+
/** When `bottomYDomain` is `'auto'`: floors the symmetric max-abs so a plateau doesn't amplify
|
|
76
|
+
* to fill the whole pane (mirrors `AxisConfig.autoMaxFloor`). Ignored with a fixed tuple. */
|
|
77
|
+
bottomMaxAbsFloor?: number
|
|
54
78
|
/** Tooltip badge — appears at the right of the tooltip header. */
|
|
55
79
|
tooltipLabel?: (d: T) => { text: string; color: string } | null
|
|
80
|
+
/**
|
|
81
|
+
* Overrides the tooltip header's date text — the same seam `CartesianChart`'s
|
|
82
|
+
* `CartesianTooltipConfig.formatHeader` exposes, so the two don't diverge. Default:
|
|
83
|
+
* `TooltipHeader`'s own `fmtTooltipDate` behavior, unchanged. Receives the raw `getX` key
|
|
84
|
+
* alongside the hovered datum.
|
|
85
|
+
*/
|
|
86
|
+
formatHeader?: (key: string, d: T) => string
|
|
56
87
|
/** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
|
|
57
88
|
* Default `{ placement: 'bottom' }`. */
|
|
58
89
|
legend?: ChartLegendConfig | false
|
|
@@ -124,6 +155,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
124
155
|
data,
|
|
125
156
|
chartId,
|
|
126
157
|
getX,
|
|
158
|
+
formatX = fmtAxisDate,
|
|
159
|
+
cursorResolution,
|
|
160
|
+
ariaLabel,
|
|
127
161
|
series,
|
|
128
162
|
topYDomain = 'auto',
|
|
129
163
|
fillBetween,
|
|
@@ -136,7 +170,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
136
170
|
topFraction = 0.62,
|
|
137
171
|
formatTop,
|
|
138
172
|
formatBottom,
|
|
173
|
+
formatBar,
|
|
174
|
+
bottomYDomain = 'auto',
|
|
175
|
+
bottomMaxAbsFloor,
|
|
139
176
|
tooltipLabel,
|
|
177
|
+
formatHeader,
|
|
140
178
|
plot,
|
|
141
179
|
} = props
|
|
142
180
|
|
|
@@ -184,8 +222,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
184
222
|
[topDomain, useNiceTop, formatTop],
|
|
185
223
|
)
|
|
186
224
|
|
|
187
|
-
// Bottom pane: symmetric signed domain around zero
|
|
225
|
+
// Bottom pane: symmetric signed domain around zero — unless `bottomYDomain` pins a fixed tuple,
|
|
226
|
+
// in which case that domain ships verbatim (un-`nice`d, matching `topYDomain`'s fixed-tuple path).
|
|
227
|
+
const useNiceBottom = bottomYDomain === 'auto'
|
|
188
228
|
const maxAbs = useMemo(() => {
|
|
229
|
+
if (bottomYDomain !== 'auto') return 0
|
|
189
230
|
let m = 0
|
|
190
231
|
for (const d of data) {
|
|
191
232
|
const v = getBar(d)
|
|
@@ -193,23 +234,33 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
193
234
|
const a = Math.abs(v)
|
|
194
235
|
if (a > m) m = a
|
|
195
236
|
}
|
|
196
|
-
|
|
197
|
-
|
|
237
|
+
const floored = bottomMaxAbsFloor !== undefined ? Math.max(m, bottomMaxAbsFloor) : m
|
|
238
|
+
return floored === 0 ? 1 : floored
|
|
239
|
+
}, [data, getBar, bottomYDomain, bottomMaxAbsFloor])
|
|
198
240
|
|
|
241
|
+
const bottomDomain = useMemo<[number, number]>(
|
|
242
|
+
() => (bottomYDomain === 'auto' ? [-maxAbs, maxAbs] : bottomYDomain),
|
|
243
|
+
[bottomYDomain, maxAbs],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// `bottomLabels`/`probeAxisLabels` measure whatever domain actually ships (fixed tuple or
|
|
247
|
+
// computed maxAbs), so the margin is never computed from ticks the axis never paints.
|
|
199
248
|
const bottomLabels = useMemo(
|
|
200
249
|
() =>
|
|
201
250
|
probeAxisLabels({
|
|
202
|
-
domain:
|
|
251
|
+
domain: bottomDomain,
|
|
203
252
|
ticks: 3,
|
|
204
253
|
format: formatBottom,
|
|
205
|
-
nice:
|
|
254
|
+
nice: useNiceBottom,
|
|
206
255
|
}).labels,
|
|
207
|
-
[
|
|
256
|
+
[bottomDomain, formatBottom, useNiceBottom],
|
|
208
257
|
)
|
|
209
258
|
|
|
210
259
|
// Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
|
|
211
260
|
// labels, never widens the max, so measuring against the full set is a safe over-estimate.
|
|
212
|
-
|
|
261
|
+
// Uses the SAME `formatX` the real axis paints with (`AxisBottomDate` below), or the measured
|
|
262
|
+
// gutter would be computed from labels the axis never paints.
|
|
263
|
+
const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
|
|
213
264
|
|
|
214
265
|
const margin = useMemo(
|
|
215
266
|
() => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
|
|
@@ -242,8 +293,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
242
293
|
)
|
|
243
294
|
|
|
244
295
|
const bottomYScale = useMemo(
|
|
245
|
-
() =>
|
|
246
|
-
|
|
296
|
+
() =>
|
|
297
|
+
scaleLinear<number>({
|
|
298
|
+
domain: bottomDomain,
|
|
299
|
+
range: [bottomH, 0],
|
|
300
|
+
...(useNiceBottom && { nice: true }),
|
|
301
|
+
}),
|
|
302
|
+
[bottomDomain, bottomH, useNiceBottom],
|
|
247
303
|
)
|
|
248
304
|
|
|
249
305
|
const cursor = useChartCursor<T>({
|
|
@@ -252,6 +308,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
252
308
|
getKey: getX,
|
|
253
309
|
xScale,
|
|
254
310
|
marginLeft: margin.left,
|
|
311
|
+
...(cursorResolution !== undefined && { resolution: cursorResolution }),
|
|
255
312
|
})
|
|
256
313
|
|
|
257
314
|
const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
|
|
@@ -334,6 +391,39 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
334
391
|
return out
|
|
335
392
|
}, [data, visibleSeries])
|
|
336
393
|
|
|
394
|
+
// Per-point markers (PR star / status dots), memoized for the same reason as `histogramBars` /
|
|
395
|
+
// `lineValid` — the shared cursor re-renders every mounted chart on every pointer frame, and this
|
|
396
|
+
// walks every visible series' valid points calling the consumer's `getMarker` each time otherwise.
|
|
397
|
+
const markerNodes = useMemo<ReactNode[]>(
|
|
398
|
+
() =>
|
|
399
|
+
visibleSeries.flatMap((s) => {
|
|
400
|
+
const getMarker = s.getMarker
|
|
401
|
+
if (!getMarker) return [] as ReactNode[]
|
|
402
|
+
const pts = lineValid.get(s.key) ?? []
|
|
403
|
+
const markers: ReactNode[] = []
|
|
404
|
+
for (const p of pts) {
|
|
405
|
+
const m = getMarker(p.__d)
|
|
406
|
+
if (m === null) continue
|
|
407
|
+
const cx = xScale(getX(p.__d)) ?? 0
|
|
408
|
+
const cy = topYScale(p.__y)
|
|
409
|
+
const ring = m.ring ?? true
|
|
410
|
+
markers.push(
|
|
411
|
+
<circle
|
|
412
|
+
key={`mk-${s.key}-${getX(p.__d)}`}
|
|
413
|
+
cx={cx}
|
|
414
|
+
cy={cy}
|
|
415
|
+
r={m.r ?? VX.dotR}
|
|
416
|
+
fill={m.color ?? s.color}
|
|
417
|
+
fillOpacity={m.fillOpacity ?? 1}
|
|
418
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 2 })}
|
|
419
|
+
/>,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
return markers
|
|
423
|
+
}),
|
|
424
|
+
[visibleSeries, lineValid, xScale, topYScale, getX],
|
|
425
|
+
)
|
|
426
|
+
|
|
337
427
|
const point = cursor.point
|
|
338
428
|
const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
|
|
339
429
|
const syncedBar = point !== null ? getBar(point) : null
|
|
@@ -386,11 +476,16 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
386
476
|
stroke={s.color}
|
|
387
477
|
strokeWidth={s.strokeWidth ?? VX.lineWidth}
|
|
388
478
|
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
479
|
+
strokeOpacity={s.strokeOpacity ?? 1}
|
|
389
480
|
curve={curveMonotoneX}
|
|
390
481
|
/>
|
|
391
482
|
)
|
|
392
483
|
})}
|
|
393
484
|
|
|
485
|
+
{/* Per-point markers (PR star / status dots) — plain-circle parity with MultiLine's
|
|
486
|
+
`getMarker` handling; DualPanel doesn't need the star-shape variant. */}
|
|
487
|
+
{markerNodes}
|
|
488
|
+
|
|
394
489
|
{/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
|
|
395
490
|
{point !== null && (
|
|
396
491
|
<>
|
|
@@ -414,6 +509,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
414
509
|
{/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
|
|
415
510
|
x-only, so either overlay yields the same point. The top overlay extends over the gutter
|
|
416
511
|
(height = topH + PANE_GAP) so there's no dead zone between the panes. */}
|
|
512
|
+
{/* The focusable overlay is the ONE keyboard control for both panes, so it carries the
|
|
513
|
+
slider's label and value — parity with `CartesianChart`. Without them the frame's
|
|
514
|
+
`aria-label` announces the chart and the slider underneath it is an unnamed control
|
|
515
|
+
reporting no position: technically focusable, useless to read. The bottom overlay
|
|
516
|
+
stays pointer-only and therefore deliberately carries neither. */}
|
|
417
517
|
<HoverOverlay
|
|
418
518
|
width={xMax}
|
|
419
519
|
height={topH + PANE_GAP}
|
|
@@ -421,6 +521,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
421
521
|
onLeave={cursor.onPointerLeave}
|
|
422
522
|
onKeyDown={cursor.onKeyDown}
|
|
423
523
|
onBlur={cursor.onBlur}
|
|
524
|
+
valueMax={Math.max(data.length - 1, 0)}
|
|
525
|
+
{...(point !== null && {
|
|
526
|
+
valueNow: data.indexOf(point),
|
|
527
|
+
valueText: formatX(getX(point)),
|
|
528
|
+
})}
|
|
529
|
+
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
424
530
|
/>
|
|
425
531
|
</Group>
|
|
426
532
|
|
|
@@ -461,7 +567,12 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
461
567
|
)}
|
|
462
568
|
|
|
463
569
|
<AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
|
|
464
|
-
<AxisBottomDate
|
|
570
|
+
<AxisBottomDate
|
|
571
|
+
top={bottomH}
|
|
572
|
+
scale={xScale}
|
|
573
|
+
tickValues={tickValues}
|
|
574
|
+
tickFormat={formatX}
|
|
575
|
+
/>
|
|
465
576
|
{/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
|
|
466
577
|
Both panes scrub the same single x cursor, so a second tab stop would be a second
|
|
467
578
|
control for one logical axis: a keyboard user would tab twice through one chart and
|
|
@@ -486,6 +597,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
486
597
|
<ChartTooltipFloat anchor={cursor.anchor}>
|
|
487
598
|
<TooltipHeader
|
|
488
599
|
date={getX(point)}
|
|
600
|
+
{...(formatHeader !== undefined && {
|
|
601
|
+
format: (key: string) => formatHeader(key, point),
|
|
602
|
+
})}
|
|
489
603
|
{...(() => {
|
|
490
604
|
const lbl = tooltipLabel?.(point) ?? null
|
|
491
605
|
return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
|
|
@@ -507,11 +621,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
507
621
|
(() => {
|
|
508
622
|
const v = getBar(point)
|
|
509
623
|
if (v === null || v === undefined || Number.isNaN(v)) return null
|
|
624
|
+
// `formatBar` overrides the tooltip row only — `formatBottom` keeps owning ticks.
|
|
625
|
+
const value = formatBar !== undefined ? formatBar(v, point) : formatBottom(v)
|
|
510
626
|
return (
|
|
511
627
|
<TooltipRow
|
|
512
628
|
color={v >= 0 ? barColorPositive : barColorNegative}
|
|
513
629
|
label={barLabel}
|
|
514
|
-
value={
|
|
630
|
+
value={value}
|
|
515
631
|
shape="bar"
|
|
516
632
|
/>
|
|
517
633
|
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MultiLine` — the `strokeOpacity` wiring: `dimOpacity(s) * (s.strokeOpacity ?? 1)`. SSR harness
|
|
3
|
+
* (no hover needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
|
|
4
|
+
*/
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
8
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
9
|
+
import { CartesianChart } from '../primitives/CartesianChart'
|
|
10
|
+
import { MultiLine } from './MultiLine'
|
|
11
|
+
import type { ChartSeries } from '../series'
|
|
12
|
+
|
|
13
|
+
type Row = { date: string; v: number }
|
|
14
|
+
|
|
15
|
+
const rows: Row[] = [
|
|
16
|
+
{ date: '2026-08-01', v: 10 },
|
|
17
|
+
{ date: '2026-08-02', v: 12 },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
describe('MultiLine — series.strokeOpacity dims the plotted stroke', () => {
|
|
21
|
+
test('the line path carries the configured stroke-opacity attribute', () => {
|
|
22
|
+
const series: ChartSeries<Row>[] = [
|
|
23
|
+
{
|
|
24
|
+
key: 'v',
|
|
25
|
+
label: 'V',
|
|
26
|
+
color: '#123456',
|
|
27
|
+
mark: 'line',
|
|
28
|
+
getValue: (d) => d.v,
|
|
29
|
+
strokeOpacity: 0.35,
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const html = renderToStaticMarkup(
|
|
34
|
+
<MultiLine<Row> data={rows} chartId="ml-opacity" getX={(d) => d.date} series={series} />,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(html).toContain('stroke="#123456"')
|
|
38
|
+
expect(html).toContain('stroke-opacity="0.35"')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('MultiLine — formatX', () => {
|
|
43
|
+
test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
|
|
44
|
+
const series: ChartSeries<Row>[] = [
|
|
45
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
46
|
+
]
|
|
47
|
+
const html = renderToStaticMarkup(
|
|
48
|
+
<MultiLine<Row>
|
|
49
|
+
data={rows}
|
|
50
|
+
chartId="ml-formatx"
|
|
51
|
+
getX={(d) => d.date}
|
|
52
|
+
series={series}
|
|
53
|
+
formatX={(key) => `X:${key}`}
|
|
54
|
+
/>,
|
|
55
|
+
)
|
|
56
|
+
expect(html).toContain('X:2026-08-01')
|
|
57
|
+
expect(html).not.toContain('>01.08<')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('MultiLine — getMarker fillOpacity/ring', () => {
|
|
62
|
+
test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
|
|
63
|
+
const series: ChartSeries<Row>[] = [
|
|
64
|
+
{
|
|
65
|
+
key: 'v',
|
|
66
|
+
label: 'V',
|
|
67
|
+
color: '#111',
|
|
68
|
+
mark: 'line',
|
|
69
|
+
getValue: (d) => d.v,
|
|
70
|
+
getMarker: (d) => (d.date === '2026-08-01' ? { ring: false, fillOpacity: 0.7 } : null),
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
const html = renderToStaticMarkup(
|
|
74
|
+
<MultiLine<Row> data={rows} chartId="ml-marker-ring" getX={(d) => d.date} series={series} />,
|
|
75
|
+
)
|
|
76
|
+
expect(html).toContain('fill-opacity="0.7"')
|
|
77
|
+
expect(html).not.toContain('stroke-width="2"')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('default marker keeps the punched-out ring and full opacity (unchanged rendering)', () => {
|
|
81
|
+
const series: ChartSeries<Row>[] = [
|
|
82
|
+
{
|
|
83
|
+
key: 'v',
|
|
84
|
+
label: 'V',
|
|
85
|
+
color: '#111',
|
|
86
|
+
mark: 'line',
|
|
87
|
+
getValue: (d) => d.v,
|
|
88
|
+
getMarker: (d) => (d.date === '2026-08-01' ? {} : null),
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
const html = renderToStaticMarkup(
|
|
92
|
+
<MultiLine<Row>
|
|
93
|
+
data={rows}
|
|
94
|
+
chartId="ml-marker-default"
|
|
95
|
+
getX={(d) => d.date}
|
|
96
|
+
series={series}
|
|
97
|
+
/>,
|
|
98
|
+
)
|
|
99
|
+
expect(html).toContain('stroke-width="2"')
|
|
100
|
+
expect(html).toContain('fill-opacity="1"')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('MultiLine — markerShape="star" honours getMarker ring/fillOpacity', () => {
|
|
105
|
+
test('ring: false omits the stroke; fillOpacity defaults 1 and honors an override', () => {
|
|
106
|
+
const series: ChartSeries<Row>[] = [
|
|
107
|
+
{
|
|
108
|
+
key: 'v',
|
|
109
|
+
label: 'V',
|
|
110
|
+
color: '#111',
|
|
111
|
+
mark: 'line',
|
|
112
|
+
getValue: (d) => d.v,
|
|
113
|
+
getMarker: (d) => (d.date === '2026-08-01' ? { ring: false, fillOpacity: 0.7 } : null),
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
const html = renderToStaticMarkup(
|
|
117
|
+
<MultiLine<Row>
|
|
118
|
+
data={rows}
|
|
119
|
+
chartId="ml-star-ring"
|
|
120
|
+
getX={(d) => d.date}
|
|
121
|
+
series={series}
|
|
122
|
+
markerShape="star"
|
|
123
|
+
/>,
|
|
124
|
+
)
|
|
125
|
+
expect(html).toContain('fill-opacity="0.7"')
|
|
126
|
+
expect(html).not.toContain('stroke-width="1.5"')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('default marker keeps the punched-out ring and full opacity (unchanged rendering)', () => {
|
|
130
|
+
const series: ChartSeries<Row>[] = [
|
|
131
|
+
{
|
|
132
|
+
key: 'v',
|
|
133
|
+
label: 'V',
|
|
134
|
+
color: '#111',
|
|
135
|
+
mark: 'line',
|
|
136
|
+
getValue: (d) => d.v,
|
|
137
|
+
getMarker: (d) => (d.date === '2026-08-01' ? {} : null),
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
const html = renderToStaticMarkup(
|
|
141
|
+
<MultiLine<Row>
|
|
142
|
+
data={rows}
|
|
143
|
+
chartId="ml-star-default"
|
|
144
|
+
getX={(d) => d.date}
|
|
145
|
+
series={series}
|
|
146
|
+
markerShape="star"
|
|
147
|
+
/>,
|
|
148
|
+
)
|
|
149
|
+
expect(html).toContain('stroke-width="1.5"')
|
|
150
|
+
expect(html).toContain('fill-opacity="1"')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('MultiLine — cursorResolution threads through to sibling resolution', () => {
|
|
155
|
+
// Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a MultiLine sibling folded
|
|
156
|
+
// into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
|
|
157
|
+
// `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
|
|
158
|
+
// `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
|
|
159
|
+
const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
|
|
160
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
161
|
+
v: i,
|
|
162
|
+
}))
|
|
163
|
+
const foldedRows: Row[] = [
|
|
164
|
+
{ date: '2026-08-01', v: 0 },
|
|
165
|
+
{ date: '2026-08-08', v: 1 },
|
|
166
|
+
]
|
|
167
|
+
const dailySeries: ChartSeries<Row>[] = [
|
|
168
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
169
|
+
]
|
|
170
|
+
const foldedSeries: ChartSeries<Row>[] = [
|
|
171
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
175
|
+
render(
|
|
176
|
+
<ChartCursorScope>
|
|
177
|
+
<CartesianChart<Row>
|
|
178
|
+
data={dailyRows}
|
|
179
|
+
chartId="ml-daily"
|
|
180
|
+
getX={(d) => d.date}
|
|
181
|
+
series={dailySeries}
|
|
182
|
+
ariaLabel="Daily"
|
|
183
|
+
>
|
|
184
|
+
{() => null}
|
|
185
|
+
</CartesianChart>
|
|
186
|
+
<MultiLine<Row>
|
|
187
|
+
data={foldedRows}
|
|
188
|
+
chartId="ml-folded"
|
|
189
|
+
getX={(d) => d.date}
|
|
190
|
+
series={foldedSeries}
|
|
191
|
+
ariaLabel="Folded"
|
|
192
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
193
|
+
/>
|
|
194
|
+
</ChartCursorScope>,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
|
|
199
|
+
// via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
200
|
+
function driveDailyToAug05() {
|
|
201
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
202
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
206
|
+
renderPair('leading')
|
|
207
|
+
driveDailyToAug05()
|
|
208
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
209
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
|
|
213
|
+
renderPair()
|
|
214
|
+
driveDailyToAug05()
|
|
215
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
216
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { curveMonotoneX } from '@visx/curve'
|
|
2
2
|
import { LinePath } from '@visx/shape'
|
|
3
|
-
import { memo, useMemo } from 'react'
|
|
3
|
+
import { memo, useCallback, useMemo } from 'react'
|
|
4
4
|
import type { ReactNode } from 'react'
|
|
5
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
5
6
|
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
6
7
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
7
8
|
import type { XZoneSpec } from '../primitives/XZoneRects'
|
|
@@ -35,6 +36,14 @@ export type MultiLineProps<T> = {
|
|
|
35
36
|
refLines?: { value: number; color: string; dashed?: boolean }[]
|
|
36
37
|
/** Exact number of x ticks. Default: as many as fit. */
|
|
37
38
|
xTicks?: number
|
|
39
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
40
|
+
formatX?: (key: string) => string
|
|
41
|
+
/**
|
|
42
|
+
* How a sibling chart's broadcast cursor key resolves against this chart's points. Default
|
|
43
|
+
* `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
|
|
44
|
+
* keyed by its Monday) — see `CursorResolution`.
|
|
45
|
+
*/
|
|
46
|
+
cursorResolution?: CursorResolution
|
|
38
47
|
/** Tooltip config — `label` for a right-aligned header badge, `extraRows` for rows appended
|
|
39
48
|
* after the derived per-series rows. `false` disables the tooltip entirely. */
|
|
40
49
|
tooltip?: CartesianTooltipConfig<T> | false
|
|
@@ -100,6 +109,8 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
|
100
109
|
xZones,
|
|
101
110
|
refLines,
|
|
102
111
|
xTicks,
|
|
112
|
+
formatX,
|
|
113
|
+
cursorResolution,
|
|
103
114
|
tooltip,
|
|
104
115
|
markerShape = 'circle',
|
|
105
116
|
height,
|
|
@@ -127,6 +138,8 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
|
127
138
|
{...(xZones !== undefined && { xZones })}
|
|
128
139
|
{...(refLines !== undefined && { refLines })}
|
|
129
140
|
{...(xTicks !== undefined && { xTicks })}
|
|
141
|
+
{...(formatX !== undefined && { formatX })}
|
|
142
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
130
143
|
{...(tooltip !== undefined && { tooltip })}
|
|
131
144
|
{...(height !== undefined && { height })}
|
|
132
145
|
{...(legend !== undefined && { legend })}
|
|
@@ -163,35 +176,27 @@ function MultiLineMarks<T>({
|
|
|
163
176
|
}, [visible, data])
|
|
164
177
|
|
|
165
178
|
// A series stays at full opacity when nothing is highlighted, when it IS the highlighted series,
|
|
166
|
-
// or when it is a companion of the highlighted series (its `parent` matches).
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
s
|
|
179
|
+
// or when it is a companion of the highlighted series (its `parent` matches). `useCallback`
|
|
180
|
+
// (rather than a plain function) gives both a stable identity across renders where `highlighted`/
|
|
181
|
+
// `y2Scale` haven't changed, so `markerNodes` below can depend on THEM rather than the raw
|
|
182
|
+
// scale/highlighted values a closure would otherwise hide from the deps array.
|
|
183
|
+
const dimOpacity = useCallback(
|
|
184
|
+
(s: ChartSeries<T>): number =>
|
|
185
|
+
highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25,
|
|
186
|
+
[highlighted],
|
|
187
|
+
)
|
|
172
188
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (valid.length === 0) return null
|
|
178
|
-
const scale = scaleFor(s)
|
|
179
|
-
return (
|
|
180
|
-
<LinePath<LinePt<T>>
|
|
181
|
-
key={`line-${s.key}`}
|
|
182
|
-
data={valid}
|
|
183
|
-
x={(p) => xScale(getX(p.__d)) ?? 0}
|
|
184
|
-
y={(p) => scale(p.__y)}
|
|
185
|
-
stroke={s.color}
|
|
186
|
-
strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
187
|
-
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
188
|
-
strokeOpacity={dimOpacity(s)}
|
|
189
|
-
curve={curveMonotoneX}
|
|
190
|
-
/>
|
|
191
|
-
)
|
|
192
|
-
})}
|
|
189
|
+
const scaleFor = useCallback(
|
|
190
|
+
(s: ChartSeries<T>) => (s.axis === 'right' && y2Scale !== null ? y2Scale : yScale),
|
|
191
|
+
[y2Scale, yScale],
|
|
192
|
+
)
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
// Memoized for the same reason as `pointsBySeries` above — the shared cursor re-renders every
|
|
195
|
+
// mounted chart on every pointer frame, and this walks every visible series' points calling the
|
|
196
|
+
// consumer's `getMarker` each time otherwise.
|
|
197
|
+
const markerNodes = useMemo<ReactNode[]>(
|
|
198
|
+
() =>
|
|
199
|
+
visible.flatMap((s) => {
|
|
195
200
|
const getMarker = s.getMarker
|
|
196
201
|
if (!getMarker) return [] as ReactNode[]
|
|
197
202
|
const scale = scaleFor(s)
|
|
@@ -204,16 +209,16 @@ function MultiLineMarks<T>({
|
|
|
204
209
|
const cy = scale(p.__y)
|
|
205
210
|
const color = m.color ?? s.color
|
|
206
211
|
const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
|
|
212
|
+
const ring = m.ring ?? true
|
|
213
|
+
const fillOpacity = (m.fillOpacity ?? 1) * op
|
|
207
214
|
markers.push(
|
|
208
215
|
markerShape === 'star' ? (
|
|
209
216
|
<path
|
|
210
217
|
key={`mk-${s.key}-${getX(p.__d)}`}
|
|
211
218
|
d={starPath(cx, cy, r)}
|
|
212
219
|
fill={color}
|
|
213
|
-
|
|
214
|
-
strokeWidth
|
|
215
|
-
fillOpacity={op}
|
|
216
|
-
strokeOpacity={op}
|
|
220
|
+
fillOpacity={fillOpacity}
|
|
221
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 1.5, strokeOpacity: op })}
|
|
217
222
|
/>
|
|
218
223
|
) : (
|
|
219
224
|
<circle
|
|
@@ -222,16 +227,39 @@ function MultiLineMarks<T>({
|
|
|
222
227
|
cy={cy}
|
|
223
228
|
r={r}
|
|
224
229
|
fill={color}
|
|
225
|
-
|
|
226
|
-
strokeWidth
|
|
227
|
-
fillOpacity={op}
|
|
228
|
-
strokeOpacity={op}
|
|
230
|
+
fillOpacity={fillOpacity}
|
|
231
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 2, strokeOpacity: op })}
|
|
229
232
|
/>
|
|
230
233
|
),
|
|
231
234
|
)
|
|
232
235
|
}
|
|
233
236
|
return markers
|
|
237
|
+
}),
|
|
238
|
+
[visible, pointsBySeries, xScale, getX, scaleFor, dimOpacity, markerShape],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
{visible.map((s) => {
|
|
244
|
+
const valid = pointsBySeries.get(s.key) ?? []
|
|
245
|
+
if (valid.length === 0) return null
|
|
246
|
+
const scale = scaleFor(s)
|
|
247
|
+
return (
|
|
248
|
+
<LinePath<LinePt<T>>
|
|
249
|
+
key={`line-${s.key}`}
|
|
250
|
+
data={valid}
|
|
251
|
+
x={(p) => xScale(getX(p.__d)) ?? 0}
|
|
252
|
+
y={(p) => scale(p.__y)}
|
|
253
|
+
stroke={s.color}
|
|
254
|
+
strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
255
|
+
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
256
|
+
strokeOpacity={dimOpacity(s) * (s.strokeOpacity ?? 1)}
|
|
257
|
+
curve={curveMonotoneX}
|
|
258
|
+
/>
|
|
259
|
+
)
|
|
234
260
|
})}
|
|
261
|
+
|
|
262
|
+
{markerNodes}
|
|
235
263
|
</>
|
|
236
264
|
)
|
|
237
265
|
}
|