basalt-ui 1.15.0 → 1.16.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 +7 -0
- package/agent/rules/basalt-charts.md +28 -9
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +21 -8
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +6 -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 +13 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +55 -8
- 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.map +1 -1
- package/dist/charts/kinds/MultiLine.js +35 -25
- 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/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +1 -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 +22 -4
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +18 -8
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +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/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 +12 -2
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +2 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/Bars.test.tsx +98 -0
- package/src/charts/kinds/Bars.tsx +28 -12
- package/src/charts/kinds/DualPanel.test.tsx +131 -0
- package/src/charts/kinds/DualPanel.tsx +81 -9
- package/src/charts/kinds/MultiLine.test.tsx +37 -0
- package/src/charts/kinds/MultiLine.tsx +45 -28
- package/src/charts/kinds/ZonedLine.test.tsx +37 -0
- package/src/charts/kinds/ZonedLine.tsx +1 -0
- package/src/charts/primitives/{CartesianChart.test.ts → CartesianChart.test.tsx} +62 -1
- package/src/charts/primitives/CartesianChart.tsx +44 -12
- package/src/charts/primitives/ChartLegend.test.tsx +30 -0
- package/src/charts/primitives/ChartLegend.tsx +7 -0
- 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 +15 -4
|
@@ -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,
|
|
@@ -50,7 +51,20 @@ export type DualPanelProps<T> = {
|
|
|
50
51
|
/** Top pane share of the inner plot height. Default 0.62. */
|
|
51
52
|
topFraction?: number
|
|
52
53
|
formatTop: (v: number) => string
|
|
54
|
+
/** Drives the bottom-pane TICKS. Also the tooltip-row fallback when `formatBar` is omitted. */
|
|
53
55
|
formatBottom: (v: number) => string
|
|
56
|
+
/** Per-row tooltip formatter for the bottom-pane bar — overrides `formatBottom` for the tooltip
|
|
57
|
+
* row only (ticks keep reading `formatBottom`). Receives the hovered datum alongside the value,
|
|
58
|
+
* so a row can add a unit/decimal `formatBottom` can't (e.g. a velocity reading). */
|
|
59
|
+
formatBar?: (v: number, d: T) => string
|
|
60
|
+
/**
|
|
61
|
+
* Bottom-pane y-domain. Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` computed from the
|
|
62
|
+
* data, `nice`d. A fixed tuple pins the domain instead (and is used verbatim, un-`nice`d).
|
|
63
|
+
*/
|
|
64
|
+
bottomYDomain?: [number, number] | 'auto'
|
|
65
|
+
/** When `bottomYDomain` is `'auto'`: floors the symmetric max-abs so a plateau doesn't amplify
|
|
66
|
+
* to fill the whole pane (mirrors `AxisConfig.autoMaxFloor`). Ignored with a fixed tuple. */
|
|
67
|
+
bottomMaxAbsFloor?: number
|
|
54
68
|
/** Tooltip badge — appears at the right of the tooltip header. */
|
|
55
69
|
tooltipLabel?: (d: T) => { text: string; color: string } | null
|
|
56
70
|
/** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
|
|
@@ -136,6 +150,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
136
150
|
topFraction = 0.62,
|
|
137
151
|
formatTop,
|
|
138
152
|
formatBottom,
|
|
153
|
+
formatBar,
|
|
154
|
+
bottomYDomain = 'auto',
|
|
155
|
+
bottomMaxAbsFloor,
|
|
139
156
|
tooltipLabel,
|
|
140
157
|
plot,
|
|
141
158
|
} = props
|
|
@@ -184,8 +201,11 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
184
201
|
[topDomain, useNiceTop, formatTop],
|
|
185
202
|
)
|
|
186
203
|
|
|
187
|
-
// Bottom pane: symmetric signed domain around zero
|
|
204
|
+
// Bottom pane: symmetric signed domain around zero — unless `bottomYDomain` pins a fixed tuple,
|
|
205
|
+
// in which case that domain ships verbatim (un-`nice`d, matching `topYDomain`'s fixed-tuple path).
|
|
206
|
+
const useNiceBottom = bottomYDomain === 'auto'
|
|
188
207
|
const maxAbs = useMemo(() => {
|
|
208
|
+
if (bottomYDomain !== 'auto') return 0
|
|
189
209
|
let m = 0
|
|
190
210
|
for (const d of data) {
|
|
191
211
|
const v = getBar(d)
|
|
@@ -193,18 +213,26 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
193
213
|
const a = Math.abs(v)
|
|
194
214
|
if (a > m) m = a
|
|
195
215
|
}
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
const floored = bottomMaxAbsFloor !== undefined ? Math.max(m, bottomMaxAbsFloor) : m
|
|
217
|
+
return floored === 0 ? 1 : floored
|
|
218
|
+
}, [data, getBar, bottomYDomain, bottomMaxAbsFloor])
|
|
198
219
|
|
|
220
|
+
const bottomDomain = useMemo<[number, number]>(
|
|
221
|
+
() => (bottomYDomain === 'auto' ? [-maxAbs, maxAbs] : bottomYDomain),
|
|
222
|
+
[bottomYDomain, maxAbs],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
// `bottomLabels`/`probeAxisLabels` measure whatever domain actually ships (fixed tuple or
|
|
226
|
+
// computed maxAbs), so the margin is never computed from ticks the axis never paints.
|
|
199
227
|
const bottomLabels = useMemo(
|
|
200
228
|
() =>
|
|
201
229
|
probeAxisLabels({
|
|
202
|
-
domain:
|
|
230
|
+
domain: bottomDomain,
|
|
203
231
|
ticks: 3,
|
|
204
232
|
format: formatBottom,
|
|
205
|
-
nice:
|
|
233
|
+
nice: useNiceBottom,
|
|
206
234
|
}).labels,
|
|
207
|
-
[
|
|
235
|
+
[bottomDomain, formatBottom, useNiceBottom],
|
|
208
236
|
)
|
|
209
237
|
|
|
210
238
|
// Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
|
|
@@ -242,8 +270,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
242
270
|
)
|
|
243
271
|
|
|
244
272
|
const bottomYScale = useMemo(
|
|
245
|
-
() =>
|
|
246
|
-
|
|
273
|
+
() =>
|
|
274
|
+
scaleLinear<number>({
|
|
275
|
+
domain: bottomDomain,
|
|
276
|
+
range: [bottomH, 0],
|
|
277
|
+
...(useNiceBottom && { nice: true }),
|
|
278
|
+
}),
|
|
279
|
+
[bottomDomain, bottomH, useNiceBottom],
|
|
247
280
|
)
|
|
248
281
|
|
|
249
282
|
const cursor = useChartCursor<T>({
|
|
@@ -334,6 +367,38 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
334
367
|
return out
|
|
335
368
|
}, [data, visibleSeries])
|
|
336
369
|
|
|
370
|
+
// Per-point markers (PR star / status dots), memoized for the same reason as `histogramBars` /
|
|
371
|
+
// `lineValid` — the shared cursor re-renders every mounted chart on every pointer frame, and this
|
|
372
|
+
// walks every visible series' valid points calling the consumer's `getMarker` each time otherwise.
|
|
373
|
+
const markerNodes = useMemo<ReactNode[]>(
|
|
374
|
+
() =>
|
|
375
|
+
visibleSeries.flatMap((s) => {
|
|
376
|
+
const getMarker = s.getMarker
|
|
377
|
+
if (!getMarker) return [] as ReactNode[]
|
|
378
|
+
const pts = lineValid.get(s.key) ?? []
|
|
379
|
+
const markers: ReactNode[] = []
|
|
380
|
+
for (const p of pts) {
|
|
381
|
+
const m = getMarker(p.__d)
|
|
382
|
+
if (m === null) continue
|
|
383
|
+
const cx = xScale(getX(p.__d)) ?? 0
|
|
384
|
+
const cy = topYScale(p.__y)
|
|
385
|
+
markers.push(
|
|
386
|
+
<circle
|
|
387
|
+
key={`mk-${s.key}-${getX(p.__d)}`}
|
|
388
|
+
cx={cx}
|
|
389
|
+
cy={cy}
|
|
390
|
+
r={m.r ?? VX.dotR}
|
|
391
|
+
fill={m.color ?? s.color}
|
|
392
|
+
stroke={VX.dotStroke}
|
|
393
|
+
strokeWidth={2}
|
|
394
|
+
/>,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
return markers
|
|
398
|
+
}),
|
|
399
|
+
[visibleSeries, lineValid, xScale, topYScale, getX],
|
|
400
|
+
)
|
|
401
|
+
|
|
337
402
|
const point = cursor.point
|
|
338
403
|
const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
|
|
339
404
|
const syncedBar = point !== null ? getBar(point) : null
|
|
@@ -386,11 +451,16 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
386
451
|
stroke={s.color}
|
|
387
452
|
strokeWidth={s.strokeWidth ?? VX.lineWidth}
|
|
388
453
|
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
454
|
+
strokeOpacity={s.strokeOpacity ?? 1}
|
|
389
455
|
curve={curveMonotoneX}
|
|
390
456
|
/>
|
|
391
457
|
)
|
|
392
458
|
})}
|
|
393
459
|
|
|
460
|
+
{/* Per-point markers (PR star / status dots) — plain-circle parity with MultiLine's
|
|
461
|
+
`getMarker` handling; DualPanel doesn't need the star-shape variant. */}
|
|
462
|
+
{markerNodes}
|
|
463
|
+
|
|
394
464
|
{/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
|
|
395
465
|
{point !== null && (
|
|
396
466
|
<>
|
|
@@ -507,11 +577,13 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
507
577
|
(() => {
|
|
508
578
|
const v = getBar(point)
|
|
509
579
|
if (v === null || v === undefined || Number.isNaN(v)) return null
|
|
580
|
+
// `formatBar` overrides the tooltip row only — `formatBottom` keeps owning ticks.
|
|
581
|
+
const value = formatBar !== undefined ? formatBar(v, point) : formatBottom(v)
|
|
510
582
|
return (
|
|
511
583
|
<TooltipRow
|
|
512
584
|
color={v >= 0 ? barColorPositive : barColorNegative}
|
|
513
585
|
label={barLabel}
|
|
514
|
-
value={
|
|
586
|
+
value={value}
|
|
515
587
|
shape="bar"
|
|
516
588
|
/>
|
|
517
589
|
)
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { describe, expect, test } from 'bun:test'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
import { MultiLine } from './MultiLine'
|
|
8
|
+
import type { ChartSeries } from '../series'
|
|
9
|
+
|
|
10
|
+
type Row = { date: string; v: number }
|
|
11
|
+
|
|
12
|
+
const rows: Row[] = [
|
|
13
|
+
{ date: '2026-08-01', v: 10 },
|
|
14
|
+
{ date: '2026-08-02', v: 12 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
describe('MultiLine — series.strokeOpacity dims the plotted stroke', () => {
|
|
18
|
+
test('the line path carries the configured stroke-opacity attribute', () => {
|
|
19
|
+
const series: ChartSeries<Row>[] = [
|
|
20
|
+
{
|
|
21
|
+
key: 'v',
|
|
22
|
+
label: 'V',
|
|
23
|
+
color: '#123456',
|
|
24
|
+
mark: 'line',
|
|
25
|
+
getValue: (d) => d.v,
|
|
26
|
+
strokeOpacity: 0.35,
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<MultiLine<Row> data={rows} chartId="ml-opacity" getX={(d) => d.date} series={series} />,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('stroke="#123456"')
|
|
35
|
+
expect(html).toContain('stroke-opacity="0.35"')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
6
6
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
@@ -163,35 +163,27 @@ function MultiLineMarks<T>({
|
|
|
163
163
|
}, [visible, data])
|
|
164
164
|
|
|
165
165
|
// 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
|
|
166
|
+
// or when it is a companion of the highlighted series (its `parent` matches). `useCallback`
|
|
167
|
+
// (rather than a plain function) gives both a stable identity across renders where `highlighted`/
|
|
168
|
+
// `y2Scale` haven't changed, so `markerNodes` below can depend on THEM rather than the raw
|
|
169
|
+
// scale/highlighted values a closure would otherwise hide from the deps array.
|
|
170
|
+
const dimOpacity = useCallback(
|
|
171
|
+
(s: ChartSeries<T>): number =>
|
|
172
|
+
highlighted === null || s.key === highlighted || s.parent === highlighted ? 1 : 0.25,
|
|
173
|
+
[highlighted],
|
|
174
|
+
)
|
|
172
175
|
|
|
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
|
-
})}
|
|
176
|
+
const scaleFor = useCallback(
|
|
177
|
+
(s: ChartSeries<T>) => (s.axis === 'right' && y2Scale !== null ? y2Scale : yScale),
|
|
178
|
+
[y2Scale, yScale],
|
|
179
|
+
)
|
|
193
180
|
|
|
194
|
-
|
|
181
|
+
// Memoized for the same reason as `pointsBySeries` above — the shared cursor re-renders every
|
|
182
|
+
// mounted chart on every pointer frame, and this walks every visible series' points calling the
|
|
183
|
+
// consumer's `getMarker` each time otherwise.
|
|
184
|
+
const markerNodes = useMemo<ReactNode[]>(
|
|
185
|
+
() =>
|
|
186
|
+
visible.flatMap((s) => {
|
|
195
187
|
const getMarker = s.getMarker
|
|
196
188
|
if (!getMarker) return [] as ReactNode[]
|
|
197
189
|
const scale = scaleFor(s)
|
|
@@ -231,7 +223,32 @@ function MultiLineMarks<T>({
|
|
|
231
223
|
)
|
|
232
224
|
}
|
|
233
225
|
return markers
|
|
226
|
+
}),
|
|
227
|
+
[visible, pointsBySeries, xScale, getX, scaleFor, dimOpacity, markerShape],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<>
|
|
232
|
+
{visible.map((s) => {
|
|
233
|
+
const valid = pointsBySeries.get(s.key) ?? []
|
|
234
|
+
if (valid.length === 0) return null
|
|
235
|
+
const scale = scaleFor(s)
|
|
236
|
+
return (
|
|
237
|
+
<LinePath<LinePt<T>>
|
|
238
|
+
key={`line-${s.key}`}
|
|
239
|
+
data={valid}
|
|
240
|
+
x={(p) => xScale(getX(p.__d)) ?? 0}
|
|
241
|
+
y={(p) => scale(p.__y)}
|
|
242
|
+
stroke={s.color}
|
|
243
|
+
strokeWidth={s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
244
|
+
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
245
|
+
strokeOpacity={dimOpacity(s) * (s.strokeOpacity ?? 1)}
|
|
246
|
+
curve={curveMonotoneX}
|
|
247
|
+
/>
|
|
248
|
+
)
|
|
234
249
|
})}
|
|
250
|
+
|
|
251
|
+
{markerNodes}
|
|
235
252
|
</>
|
|
236
253
|
)
|
|
237
254
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ZonedLine` — the `strokeOpacity` wiring: `primary.strokeOpacity ?? 1`. SSR harness (no hover
|
|
3
|
+
* needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
import { ZonedLine } from './ZonedLine'
|
|
8
|
+
import type { ChartSeries } from '../series'
|
|
9
|
+
|
|
10
|
+
type Row = { date: string; v: number }
|
|
11
|
+
|
|
12
|
+
const rows: Row[] = [
|
|
13
|
+
{ date: '2026-08-01', v: 10 },
|
|
14
|
+
{ date: '2026-08-02', v: 12 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
describe('ZonedLine — series.strokeOpacity dims the plotted stroke', () => {
|
|
18
|
+
test('the line path carries the configured stroke-opacity attribute', () => {
|
|
19
|
+
const series: ChartSeries<Row>[] = [
|
|
20
|
+
{
|
|
21
|
+
key: 'v',
|
|
22
|
+
label: 'V',
|
|
23
|
+
color: '#654321',
|
|
24
|
+
mark: 'line',
|
|
25
|
+
getValue: (d) => d.v,
|
|
26
|
+
strokeOpacity: 0.5,
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<ZonedLine<Row> data={rows} chartId="zl-opacity" getX={(d) => d.date} series={series} />,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('stroke="#654321"')
|
|
35
|
+
expect(html).toContain('stroke-opacity="0.5"')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -228,6 +228,7 @@ function ZonedLineMarks<T>({
|
|
|
228
228
|
stroke={primary.color}
|
|
229
229
|
strokeWidth={primary.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
230
230
|
strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
|
|
231
|
+
strokeOpacity={primary.strokeOpacity ?? 1}
|
|
231
232
|
curve={curveMonotoneX}
|
|
232
233
|
/>
|
|
233
234
|
)}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
1
2
|
import { describe, expect, test } from 'bun:test'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
4
|
import type { ChartSeries } from '../series'
|
|
3
|
-
import { resolveAxisDomain } from './CartesianChart'
|
|
5
|
+
import { CartesianChart, resolveAxisDomain } from './CartesianChart'
|
|
4
6
|
|
|
5
7
|
type Row = { date: string; a: number; b: number }
|
|
6
8
|
|
|
@@ -97,3 +99,62 @@ describe('resolveAxisDomain — degenerate input', () => {
|
|
|
97
99
|
expect(max).toBeGreaterThan(min)
|
|
98
100
|
})
|
|
99
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', () => {
|
|
104
|
+
// A fixed, deliberately non-round domain: d3's `.ticks()` never produces a tick past the raw
|
|
105
|
+
// domain max (95.7) without `nice`, but WITH `nice` the scale first rounds its domain outward
|
|
106
|
+
// (e.g. to 100), so a tick at "100" can only appear on the `nice: true` render.
|
|
107
|
+
function renderChart(nice: boolean): string {
|
|
108
|
+
return renderToStaticMarkup(
|
|
109
|
+
<CartesianChart<Row>
|
|
110
|
+
data={rows}
|
|
111
|
+
chartId="nice-test"
|
|
112
|
+
getX={(d) => d.date}
|
|
113
|
+
series={[seriesFor('a')]}
|
|
114
|
+
y={{ domain: [0, 95.7], ticks: 5, format: (v) => String(v), nice }}
|
|
115
|
+
>
|
|
116
|
+
{() => null}
|
|
117
|
+
</CartesianChart>,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('nice: false never paints a tick past the raw domain max', () => {
|
|
122
|
+
expect(renderChart(false)).not.toContain('>100<')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('nice: true rounds the scale outward — a tick beyond the raw domain max appears', () => {
|
|
126
|
+
expect(renderChart(true)).toContain('>100<')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
|
|
131
|
+
test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
|
|
132
|
+
render(
|
|
133
|
+
<CartesianChart<Row>
|
|
134
|
+
data={rows}
|
|
135
|
+
chartId="extra-rows-ctx"
|
|
136
|
+
getX={(d) => d.date}
|
|
137
|
+
series={both}
|
|
138
|
+
tooltip={{
|
|
139
|
+
extraRows: (_d, ctx) => (
|
|
140
|
+
<div data-testid="extra-ctx">
|
|
141
|
+
visible:{ctx.visible.map((s) => s.key).join(',')}|hidden:
|
|
142
|
+
{[...ctx.hidden].join(',')}
|
|
143
|
+
</div>
|
|
144
|
+
),
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{() => null}
|
|
148
|
+
</CartesianChart>,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const slider = screen.getByRole('slider')
|
|
152
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
153
|
+
|
|
154
|
+
expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a,b|hidden:')
|
|
155
|
+
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: 'b' }))
|
|
157
|
+
|
|
158
|
+
expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -53,6 +53,23 @@ export type AxisConfig<T> = {
|
|
|
53
53
|
format?: (v: number) => string
|
|
54
54
|
/** Horizontal grid rules. Default: on for the left axis, off for the right. */
|
|
55
55
|
grid?: boolean
|
|
56
|
+
/**
|
|
57
|
+
* Round the scale's domain outward to nice tick values (d3's `scale.nice()`). Threaded to BOTH
|
|
58
|
+
* `probeAxisLabels` (measurement) and the real `scaleLinear` (rendering) — they must agree, or
|
|
59
|
+
* the measured margin is computed from ticks the axis never paints.
|
|
60
|
+
*
|
|
61
|
+
* Default `false`, deliberately: flipping the default would move the domain of every
|
|
62
|
+
* already-migrated chart. Opt in per axis.
|
|
63
|
+
*/
|
|
64
|
+
nice?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Handed to `prependRows`/`extraRows` alongside the hovered datum — the same `visible`/`hidden`
|
|
68
|
+
* the plot itself draws from, so a hand-authored row can't structurally desync from legend
|
|
69
|
+
* toggling. */
|
|
70
|
+
export type CartesianTooltipRowContext<T> = {
|
|
71
|
+
visible: readonly ChartSeries<T>[]
|
|
72
|
+
hidden: ReadonlySet<string>
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export type CartesianTooltipConfig<T> = {
|
|
@@ -65,10 +82,12 @@ export type CartesianTooltipConfig<T> = {
|
|
|
65
82
|
follow?: boolean
|
|
66
83
|
/** Right-aligned badge in the tooltip header (e.g. a status label). */
|
|
67
84
|
label?: (d: T) => { text: string; color: string } | null
|
|
68
|
-
/** Rows rendered BEFORE the derived per-series rows (a total, a context line).
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
/** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
|
|
86
|
+
* argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
|
|
87
|
+
* stays in sync with legend toggling instead of naming a series the plot no longer draws. */
|
|
88
|
+
prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
89
|
+
/** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
|
|
90
|
+
extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
/** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
|
|
@@ -339,9 +358,10 @@ function CartesianPlot<T>({
|
|
|
339
358
|
probeAxisLabels({
|
|
340
359
|
domain: leftDomain,
|
|
341
360
|
ticks: leftTicks,
|
|
361
|
+
nice: y?.nice ?? false,
|
|
342
362
|
...(y?.format !== undefined && { format: y.format }),
|
|
343
363
|
}),
|
|
344
|
-
[leftDomain, leftTicks, y?.format],
|
|
364
|
+
[leftDomain, leftTicks, y?.format, y?.nice],
|
|
345
365
|
)
|
|
346
366
|
|
|
347
367
|
const { labels: rightLabels, format: rightFormat } = useMemo(
|
|
@@ -351,9 +371,10 @@ function CartesianPlot<T>({
|
|
|
351
371
|
: probeAxisLabels({
|
|
352
372
|
domain: rightDomain,
|
|
353
373
|
ticks: rightTicks,
|
|
374
|
+
nice: y2?.nice ?? false,
|
|
354
375
|
...(y2?.format !== undefined && { format: y2.format }),
|
|
355
376
|
}),
|
|
356
|
-
[rightDomain, rightTicks, y2?.format],
|
|
377
|
+
[rightDomain, rightTicks, y2?.format, y2?.nice],
|
|
357
378
|
)
|
|
358
379
|
|
|
359
380
|
const xLabels = useMemo(() => keys.map(formatX), [keys, formatX])
|
|
@@ -378,13 +399,24 @@ function CartesianPlot<T>({
|
|
|
378
399
|
[keys, xMax],
|
|
379
400
|
)
|
|
380
401
|
const yScale = useMemo(
|
|
381
|
-
() =>
|
|
382
|
-
|
|
402
|
+
() =>
|
|
403
|
+
scaleLinear<number>({
|
|
404
|
+
domain: leftDomain,
|
|
405
|
+
range: [yMax, 0],
|
|
406
|
+
...(y?.nice === true && { nice: true }),
|
|
407
|
+
}),
|
|
408
|
+
[leftDomain, yMax, y?.nice],
|
|
383
409
|
)
|
|
384
410
|
const y2Scale = useMemo(
|
|
385
411
|
() =>
|
|
386
|
-
rightDomain === null
|
|
387
|
-
|
|
412
|
+
rightDomain === null
|
|
413
|
+
? null
|
|
414
|
+
: scaleLinear<number>({
|
|
415
|
+
domain: rightDomain,
|
|
416
|
+
range: [yMax, 0],
|
|
417
|
+
...(y2?.nice === true && { nice: true }),
|
|
418
|
+
}),
|
|
419
|
+
[rightDomain, yMax, y2?.nice],
|
|
388
420
|
)
|
|
389
421
|
|
|
390
422
|
const tickValues = useMemo(
|
|
@@ -545,7 +577,7 @@ function CartesianPlot<T>({
|
|
|
545
577
|
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
546
578
|
/>
|
|
547
579
|
<TooltipBody>
|
|
548
|
-
{tooltipCfg?.prependRows?.(point)}
|
|
580
|
+
{tooltipCfg?.prependRows?.(point, { visible, hidden })}
|
|
549
581
|
{rows.map((row) => (
|
|
550
582
|
<TooltipRow
|
|
551
583
|
key={row.key}
|
|
@@ -557,7 +589,7 @@ function CartesianPlot<T>({
|
|
|
557
589
|
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
558
590
|
/>
|
|
559
591
|
))}
|
|
560
|
-
{tooltipCfg?.extraRows?.(point)}
|
|
592
|
+
{tooltipCfg?.extraRows?.(point, { visible, hidden })}
|
|
561
593
|
</TooltipBody>
|
|
562
594
|
</ChartTooltipFloat>
|
|
563
595
|
)}
|
|
@@ -46,3 +46,33 @@ describe('ChartLegend note', () => {
|
|
|
46
46
|
expect(markup).toContain('est.')
|
|
47
47
|
})
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('ChartLegend strokeOpacity — the swatch cannot lie about a dimmed line', () => {
|
|
51
|
+
test('a line swatch honors a fractional strokeOpacity', () => {
|
|
52
|
+
const markup = render([{ ...BASE, shape: 'line', strokeOpacity: 0.4 }])
|
|
53
|
+
expect(markup).toContain('stroke-opacity="0.4"')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('defaults to full opacity when unset', () => {
|
|
57
|
+
const markup = render([{ ...BASE, shape: 'line' }])
|
|
58
|
+
expect(markup).toContain('stroke-opacity="1"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a folded MA-companion child swatch honors it too', () => {
|
|
62
|
+
const markup = render([
|
|
63
|
+
{
|
|
64
|
+
...BASE,
|
|
65
|
+
children: [
|
|
66
|
+
{
|
|
67
|
+
key: 'ma',
|
|
68
|
+
label: '7d MA',
|
|
69
|
+
color: 'var(--vx-fill-2)',
|
|
70
|
+
shape: 'line',
|
|
71
|
+
strokeOpacity: 0.4,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
])
|
|
76
|
+
expect(markup).toContain('stroke-opacity="0.4"')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -13,6 +13,9 @@ export type LegendEntry = {
|
|
|
13
13
|
dashed?: boolean
|
|
14
14
|
/** bar swatch opacity — honored instead of a hardcoded value, so it cannot lie about the fill. */
|
|
15
15
|
fillOpacity?: number
|
|
16
|
+
/** line/splitLine swatch stroke opacity — parity with `fillOpacity` above, so a dimmed
|
|
17
|
+
* companion line's swatch cannot lie about its own opacity either. */
|
|
18
|
+
strokeOpacity?: number
|
|
16
19
|
/** Drives `groups` rendering (series → hairline divider → overlay → reference). */
|
|
17
20
|
role?: SeriesRole
|
|
18
21
|
/** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
|
|
@@ -113,6 +116,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
113
116
|
y2={7}
|
|
114
117
|
stroke={item.color}
|
|
115
118
|
strokeWidth={item.strokeWidth ?? 2.5}
|
|
119
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
116
120
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
117
121
|
/>
|
|
118
122
|
<line
|
|
@@ -122,6 +126,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
122
126
|
y2={7}
|
|
123
127
|
stroke={item.secondColor}
|
|
124
128
|
strokeWidth={item.strokeWidth ?? 2.5}
|
|
129
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
125
130
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
126
131
|
/>
|
|
127
132
|
</svg>
|
|
@@ -173,6 +178,7 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
173
178
|
y2={3}
|
|
174
179
|
stroke={item.color}
|
|
175
180
|
strokeWidth={item.strokeWidth ?? 3}
|
|
181
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
176
182
|
strokeLinecap="round"
|
|
177
183
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
178
184
|
/>
|
|
@@ -222,6 +228,7 @@ function LegendChildSwatch({ item }: { item: LegendEntry }) {
|
|
|
222
228
|
y2={5}
|
|
223
229
|
stroke={item.color}
|
|
224
230
|
strokeWidth={item.strokeWidth ?? 2}
|
|
231
|
+
strokeOpacity={item.strokeOpacity ?? 1}
|
|
225
232
|
strokeDasharray={item.dashed ? VX.dashArray : undefined}
|
|
226
233
|
/>
|
|
227
234
|
</svg>
|
|
@@ -60,4 +60,64 @@ describe('XZoneRects', () => {
|
|
|
60
60
|
test('an empty zones array renders no rects', () => {
|
|
61
61
|
expect(render([])).not.toContain('<rect')
|
|
62
62
|
})
|
|
63
|
+
|
|
64
|
+
// Mirrors XZoneRects' own bound resolution exactly (same clamp + sign math), so expectations
|
|
65
|
+
// never drift from the implementation's floating-point arithmetic.
|
|
66
|
+
function edgeBound(key: string, sign: 1 | -1): number {
|
|
67
|
+
const step = xScale.step()
|
|
68
|
+
const [rangeStart, rangeEnd] = xScale.range() as [number, number]
|
|
69
|
+
const rangeMin = Math.min(rangeStart, rangeEnd)
|
|
70
|
+
const rangeMax = Math.max(rangeStart, rangeEnd)
|
|
71
|
+
const center = xScale(key) as number
|
|
72
|
+
return Math.min(Math.max(center + sign * (step / 2), rangeMin), rangeMax)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('align: "edge" widens a two-key band by exactly one step over center mode', () => {
|
|
76
|
+
const step = xScale.step()
|
|
77
|
+
const centerFrom = xScale('b') as number
|
|
78
|
+
const centerTo = xScale('c') as number
|
|
79
|
+
const centerMarkup = render([{ from: 'b', to: 'c', fill: 'red' }])
|
|
80
|
+
const edgeMarkup = render([{ from: 'b', to: 'c', fill: 'red', align: 'edge' }])
|
|
81
|
+
expect(centerMarkup).toContain(`width="${centerTo - centerFrom}"`)
|
|
82
|
+
const edgeFrom = edgeBound('b', -1)
|
|
83
|
+
const edgeTo = edgeBound('c', 1)
|
|
84
|
+
expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
|
|
85
|
+
expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
|
|
86
|
+
expect(edgeTo - edgeFrom).toBeCloseTo(centerTo - centerFrom + step, 10)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('align: "edge" with from === to renders exactly one step wide, where center renders nothing', () => {
|
|
90
|
+
const step = xScale.step()
|
|
91
|
+
const centerMarkup = render([{ from: 'b', to: 'b', fill: 'red' }])
|
|
92
|
+
const edgeMarkup = render([{ from: 'b', to: 'b', fill: 'red', align: 'edge' }])
|
|
93
|
+
expect(centerMarkup).not.toContain('<rect')
|
|
94
|
+
const edgeFrom = edgeBound('b', -1)
|
|
95
|
+
const edgeTo = edgeBound('b', 1)
|
|
96
|
+
expect(edgeMarkup).toContain(`x="${edgeFrom}"`)
|
|
97
|
+
expect(edgeMarkup).toContain(`width="${edgeTo - edgeFrom}"`)
|
|
98
|
+
expect(edgeTo - edgeFrom).toBeCloseTo(step, 10)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an unknown key is skipped in both align modes', () => {
|
|
102
|
+
const centerMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
|
|
103
|
+
const edgeMarkup = render([{ from: 'not-in-domain', to: 'c', fill: 'red', align: 'edge' }])
|
|
104
|
+
expect(centerMarkup).not.toContain('<rect')
|
|
105
|
+
expect(edgeMarkup).not.toContain('<rect')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('align: "edge" clamps the widened left edge at the first sample into the plot range', () => {
|
|
109
|
+
const [rangeStart] = xScale.range() as [number, number]
|
|
110
|
+
const markup = render([{ from: 'a', to: 'a', fill: 'red', align: 'edge' }])
|
|
111
|
+
const edgeTo = edgeBound('a', 1)
|
|
112
|
+
expect(markup).toContain(`x="${rangeStart}"`)
|
|
113
|
+
expect(markup).toContain(`width="${edgeTo - rangeStart}"`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('align: "edge" clamps the widened right edge at the last sample into the plot range', () => {
|
|
117
|
+
const [, rangeEnd] = xScale.range() as [number, number]
|
|
118
|
+
const markup = render([{ from: 'd', to: 'd', fill: 'red', align: 'edge' }])
|
|
119
|
+
const edgeFrom = edgeBound('d', -1)
|
|
120
|
+
expect(markup).toContain(`x="${edgeFrom}"`)
|
|
121
|
+
expect(markup).toContain(`width="${rangeEnd - edgeFrom}"`)
|
|
122
|
+
})
|
|
63
123
|
})
|