basalt-ui 1.16.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 +7 -0
- package/agent/rules/basalt-charts.md +46 -4
- 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 +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +9 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +4 -0
- package/dist/charts/kinds/DualPanel.d.ts +17 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +29 -6
- 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 +10 -8
- 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 +4 -0
- package/dist/charts/primitives/CartesianChart.d.ts +23 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +11 -4
- 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/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/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -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 +1 -0
- package/src/charts/kinds/Bars.test.tsx +85 -0
- package/src/charts/kinds/Bars.tsx +13 -0
- package/src/charts/kinds/DualPanel.test.tsx +310 -0
- package/src/charts/kinds/DualPanel.tsx +48 -4
- package/src/charts/kinds/MultiLine.test.tsx +181 -0
- package/src/charts/kinds/MultiLine.tsx +19 -8
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +110 -0
- package/src/charts/kinds/ZonedLine.tsx +13 -0
- package/src/charts/primitives/CartesianChart.test.tsx +111 -2
- package/src/charts/primitives/CartesianChart.tsx +35 -7
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartTooltip.test.tsx +23 -0
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/series.ts +3 -2
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* `MultiLine` — the `strokeOpacity` wiring: `dimOpacity(s) * (s.strokeOpacity ?? 1)`. SSR harness
|
|
3
3
|
* (no hover needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
|
|
4
4
|
*/
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
5
6
|
import { describe, expect, test } from 'bun:test'
|
|
6
7
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
8
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
9
|
+
import { CartesianChart } from '../primitives/CartesianChart'
|
|
7
10
|
import { MultiLine } from './MultiLine'
|
|
8
11
|
import type { ChartSeries } from '../series'
|
|
9
12
|
|
|
@@ -35,3 +38,181 @@ describe('MultiLine — series.strokeOpacity dims the plotted stroke', () => {
|
|
|
35
38
|
expect(html).toContain('stroke-opacity="0.35"')
|
|
36
39
|
})
|
|
37
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
|
+
})
|
|
@@ -2,6 +2,7 @@ import { curveMonotoneX } from '@visx/curve'
|
|
|
2
2
|
import { LinePath } from '@visx/shape'
|
|
3
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 })}
|
|
@@ -196,16 +209,16 @@ function MultiLineMarks<T>({
|
|
|
196
209
|
const cy = scale(p.__y)
|
|
197
210
|
const color = m.color ?? s.color
|
|
198
211
|
const r = m.r ?? (markerShape === 'star' ? STAR_R : VX.dotR)
|
|
212
|
+
const ring = m.ring ?? true
|
|
213
|
+
const fillOpacity = (m.fillOpacity ?? 1) * op
|
|
199
214
|
markers.push(
|
|
200
215
|
markerShape === 'star' ? (
|
|
201
216
|
<path
|
|
202
217
|
key={`mk-${s.key}-${getX(p.__d)}`}
|
|
203
218
|
d={starPath(cx, cy, r)}
|
|
204
219
|
fill={color}
|
|
205
|
-
|
|
206
|
-
strokeWidth
|
|
207
|
-
fillOpacity={op}
|
|
208
|
-
strokeOpacity={op}
|
|
220
|
+
fillOpacity={fillOpacity}
|
|
221
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 1.5, strokeOpacity: op })}
|
|
209
222
|
/>
|
|
210
223
|
) : (
|
|
211
224
|
<circle
|
|
@@ -214,10 +227,8 @@ function MultiLineMarks<T>({
|
|
|
214
227
|
cy={cy}
|
|
215
228
|
r={r}
|
|
216
229
|
fill={color}
|
|
217
|
-
|
|
218
|
-
strokeWidth
|
|
219
|
-
fillOpacity={op}
|
|
220
|
-
strokeOpacity={op}
|
|
230
|
+
fillOpacity={fillOpacity}
|
|
231
|
+
{...(ring && { stroke: VX.dotStroke, strokeWidth: 2, strokeOpacity: op })}
|
|
221
232
|
/>
|
|
222
233
|
),
|
|
223
234
|
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StackedArea` — the `formatX` seam forwarded to `CartesianChart` (parity with the other
|
|
3
|
+
* `CartesianChart`-composing kinds). SSR harness, no hover needed.
|
|
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 { StackedArea } from './StackedArea'
|
|
11
|
+
import type { ChartSeries } from '../series'
|
|
12
|
+
|
|
13
|
+
type Row = { date: string; a: number; b: number }
|
|
14
|
+
|
|
15
|
+
const rows: Row[] = [
|
|
16
|
+
{ date: '2026-08-01', a: 10, b: 4 },
|
|
17
|
+
{ date: '2026-08-02', a: 12, b: 6 },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const series: ChartSeries<Row>[] = [
|
|
21
|
+
{ key: 'a', label: 'A', color: '#111', mark: 'area', getValue: (d) => d.a },
|
|
22
|
+
{ key: 'b', label: 'B', color: '#222', mark: 'area', getValue: (d) => d.b },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('StackedArea — formatX', () => {
|
|
26
|
+
test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
|
|
27
|
+
const html = renderToStaticMarkup(
|
|
28
|
+
<StackedArea<Row>
|
|
29
|
+
data={rows}
|
|
30
|
+
chartId="sa-formatx"
|
|
31
|
+
getX={(d) => d.date}
|
|
32
|
+
series={series}
|
|
33
|
+
formatX={(key) => `X:${key}`}
|
|
34
|
+
/>,
|
|
35
|
+
)
|
|
36
|
+
expect(html).toContain('X:2026-08-01')
|
|
37
|
+
expect(html).not.toContain('>01.08<')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('omitting formatX keeps the default DD.MM rendering', () => {
|
|
41
|
+
const html = renderToStaticMarkup(
|
|
42
|
+
<StackedArea<Row> data={rows} chartId="sa-default" getX={(d) => d.date} series={series} />,
|
|
43
|
+
)
|
|
44
|
+
expect(html).toContain('>01.08<')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('StackedArea — cursorResolution threads through to sibling resolution', () => {
|
|
49
|
+
// Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a StackedArea sibling
|
|
50
|
+
// folded into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
|
|
51
|
+
// `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
|
|
52
|
+
// `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
|
|
53
|
+
type DailyRow = { date: string; v: number }
|
|
54
|
+
|
|
55
|
+
const dailyRows: DailyRow[] = Array.from({ length: 14 }, (_, i) => ({
|
|
56
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
57
|
+
v: i,
|
|
58
|
+
}))
|
|
59
|
+
const foldedRows: Row[] = [
|
|
60
|
+
{ date: '2026-08-01', a: 0, b: 0 },
|
|
61
|
+
{ date: '2026-08-08', a: 1, b: 1 },
|
|
62
|
+
]
|
|
63
|
+
const dailySeries: ChartSeries<DailyRow>[] = [
|
|
64
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
65
|
+
]
|
|
66
|
+
const foldedSeries: ChartSeries<Row>[] = [
|
|
67
|
+
{ key: 'a', label: 'A', color: '#111', mark: 'area', getValue: (d) => d.a },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
71
|
+
render(
|
|
72
|
+
<ChartCursorScope>
|
|
73
|
+
<CartesianChart<DailyRow>
|
|
74
|
+
data={dailyRows}
|
|
75
|
+
chartId="sa-daily"
|
|
76
|
+
getX={(d) => d.date}
|
|
77
|
+
series={dailySeries}
|
|
78
|
+
ariaLabel="Daily"
|
|
79
|
+
>
|
|
80
|
+
{() => null}
|
|
81
|
+
</CartesianChart>
|
|
82
|
+
<StackedArea<Row>
|
|
83
|
+
data={foldedRows}
|
|
84
|
+
chartId="sa-folded"
|
|
85
|
+
getX={(d) => d.date}
|
|
86
|
+
series={foldedSeries}
|
|
87
|
+
ariaLabel="Folded"
|
|
88
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
89
|
+
/>
|
|
90
|
+
</ChartCursorScope>,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
|
|
95
|
+
// via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
96
|
+
function driveDailyToAug05() {
|
|
97
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
98
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
102
|
+
renderPair('leading')
|
|
103
|
+
driveDailyToAug05()
|
|
104
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
105
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
|
|
109
|
+
renderPair()
|
|
110
|
+
driveDailyToAug05()
|
|
111
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
112
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { curveMonotoneX } from '@visx/curve'
|
|
2
2
|
import { AreaStack } from '@visx/shape'
|
|
3
3
|
import { memo, useCallback, useMemo } from 'react'
|
|
4
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
4
5
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
5
6
|
import type { AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
6
7
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
@@ -21,6 +22,14 @@ export type StackedAreaProps<T> = {
|
|
|
21
22
|
y?: AxisConfig<T>
|
|
22
23
|
/** X tick count override. Default: as many as fit. */
|
|
23
24
|
xTicks?: number
|
|
25
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
26
|
+
formatX?: (key: string) => string
|
|
27
|
+
/**
|
|
28
|
+
* How a sibling chart's broadcast cursor key resolves against this chart's points. Default
|
|
29
|
+
* `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
|
|
30
|
+
* keyed by its Monday) — see `CursorResolution`.
|
|
31
|
+
*/
|
|
32
|
+
cursorResolution?: CursorResolution
|
|
24
33
|
/** Legend config forwarded to `CartesianChart`; `false` disables the legend (sparkline escape).
|
|
25
34
|
* Default `{ placement: 'bottom' }`. */
|
|
26
35
|
legend?: ChartLegendConfig | false
|
|
@@ -44,7 +53,20 @@ export type StackedAreaProps<T> = {
|
|
|
44
53
|
* to restore the original bottom-to-top stacking order for `AreaStack`'s `keys`.
|
|
45
54
|
*/
|
|
46
55
|
function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
47
|
-
const {
|
|
56
|
+
const {
|
|
57
|
+
data,
|
|
58
|
+
chartId,
|
|
59
|
+
getX,
|
|
60
|
+
series,
|
|
61
|
+
y,
|
|
62
|
+
xTicks,
|
|
63
|
+
formatX,
|
|
64
|
+
cursorResolution,
|
|
65
|
+
height,
|
|
66
|
+
legend,
|
|
67
|
+
ariaLabel,
|
|
68
|
+
isPending,
|
|
69
|
+
} = props
|
|
48
70
|
|
|
49
71
|
// The plotted quantity is the CUMULATIVE stack top, not any one series' value — the built-in
|
|
50
72
|
// 'auto' domain (per-series max) would clip a multi-band stack. Skipped when the caller already
|
|
@@ -99,6 +121,8 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
|
99
121
|
y={yConfig}
|
|
100
122
|
cursorValue={stackedCursorValue}
|
|
101
123
|
{...(xTicks !== undefined && { xTicks })}
|
|
124
|
+
{...(formatX !== undefined && { formatX })}
|
|
125
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
102
126
|
{...(height !== undefined && { height })}
|
|
103
127
|
{...(legend !== undefined && { legend })}
|
|
104
128
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* `ZonedLine` — the `strokeOpacity` wiring: `primary.strokeOpacity ?? 1`. SSR harness (no hover
|
|
3
3
|
* needed), same pattern as `CartesianChart.test.tsx`'s `nice` assertions.
|
|
4
4
|
*/
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
5
6
|
import { describe, expect, test } from 'bun:test'
|
|
6
7
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
8
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
9
|
+
import { CartesianChart } from '../primitives/CartesianChart'
|
|
7
10
|
import { ZonedLine } from './ZonedLine'
|
|
8
11
|
import type { ChartSeries } from '../series'
|
|
9
12
|
|
|
@@ -35,3 +38,110 @@ describe('ZonedLine — series.strokeOpacity dims the plotted stroke', () => {
|
|
|
35
38
|
expect(html).toContain('stroke-opacity="0.5"')
|
|
36
39
|
})
|
|
37
40
|
})
|
|
41
|
+
|
|
42
|
+
describe('ZonedLine — 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
|
+
<ZonedLine<Row>
|
|
49
|
+
data={rows}
|
|
50
|
+
chartId="zl-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('ZonedLine — tooltip.formatHeader', () => {
|
|
62
|
+
test('overrides the tooltip header date text', async () => {
|
|
63
|
+
const series: ChartSeries<Row>[] = [
|
|
64
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
65
|
+
]
|
|
66
|
+
render(
|
|
67
|
+
<ZonedLine<Row>
|
|
68
|
+
data={rows}
|
|
69
|
+
chartId="zl-formatheader"
|
|
70
|
+
getX={(d) => d.date}
|
|
71
|
+
series={series}
|
|
72
|
+
tooltip={{ formatHeader: (key) => `hdr:${key}` }}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const slider = screen.getByRole('slider')
|
|
77
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
78
|
+
|
|
79
|
+
expect(await screen.findByText('hdr:2026-08-01')).toBeTruthy()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('ZonedLine — cursorResolution threads through to sibling resolution', () => {
|
|
84
|
+
// Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a ZonedLine sibling folded
|
|
85
|
+
// into 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — parity with
|
|
86
|
+
// `Bars.test.tsx`'s equivalent block. Proves the prop reaches THIS kind, not just
|
|
87
|
+
// `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
|
|
88
|
+
const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
|
|
89
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
90
|
+
v: i,
|
|
91
|
+
}))
|
|
92
|
+
const foldedRows: Row[] = [
|
|
93
|
+
{ date: '2026-08-01', v: 0 },
|
|
94
|
+
{ date: '2026-08-08', v: 1 },
|
|
95
|
+
]
|
|
96
|
+
const dailySeries: ChartSeries<Row>[] = [
|
|
97
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
98
|
+
]
|
|
99
|
+
const foldedSeries: ChartSeries<Row>[] = [
|
|
100
|
+
{ key: 'v', label: 'V', color: '#111', mark: 'line', getValue: (d) => d.v },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
104
|
+
render(
|
|
105
|
+
<ChartCursorScope>
|
|
106
|
+
<CartesianChart<Row>
|
|
107
|
+
data={dailyRows}
|
|
108
|
+
chartId="zl-daily"
|
|
109
|
+
getX={(d) => d.date}
|
|
110
|
+
series={dailySeries}
|
|
111
|
+
ariaLabel="Daily"
|
|
112
|
+
>
|
|
113
|
+
{() => null}
|
|
114
|
+
</CartesianChart>
|
|
115
|
+
<ZonedLine<Row>
|
|
116
|
+
data={foldedRows}
|
|
117
|
+
chartId="zl-folded"
|
|
118
|
+
getX={(d) => d.date}
|
|
119
|
+
series={foldedSeries}
|
|
120
|
+
ariaLabel="Folded"
|
|
121
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
122
|
+
/>
|
|
123
|
+
</ChartCursorScope>,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
|
|
128
|
+
// via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
129
|
+
function driveDailyToAug05() {
|
|
130
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
131
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
135
|
+
renderPair('leading')
|
|
136
|
+
driveDailyToAug05()
|
|
137
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
138
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
|
|
142
|
+
renderPair()
|
|
143
|
+
driveDailyToAug05()
|
|
144
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
145
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -2,6 +2,7 @@ import { curveMonotoneX } from '@visx/curve'
|
|
|
2
2
|
import { AreaClosed, LinePath } from '@visx/shape'
|
|
3
3
|
import { Threshold } from '@visx/threshold'
|
|
4
4
|
import { memo, useMemo } from 'react'
|
|
5
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
5
6
|
import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
|
|
6
7
|
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
7
8
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
@@ -51,6 +52,14 @@ export type ZonedLineProps<T> = {
|
|
|
51
52
|
refLines?: ZonedLineRefLine[]
|
|
52
53
|
/** Exact number of x ticks. Default: as many as fit. */
|
|
53
54
|
xTicks?: number
|
|
55
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
56
|
+
formatX?: (key: string) => string
|
|
57
|
+
/**
|
|
58
|
+
* How a sibling chart's broadcast cursor key resolves against this chart's points. Default
|
|
59
|
+
* `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
|
|
60
|
+
* keyed by its Monday) — see `CursorResolution`.
|
|
61
|
+
*/
|
|
62
|
+
cursorResolution?: CursorResolution
|
|
54
63
|
/** Tooltip config — `label` for a right-aligned header badge (e.g. zone name with zone color),
|
|
55
64
|
* `extraRows` for rows appended after the derived row. `false` disables the tooltip entirely. */
|
|
56
65
|
tooltip?: CartesianTooltipConfig<T> | false
|
|
@@ -110,6 +119,8 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
|
110
119
|
thresholds,
|
|
111
120
|
refLines,
|
|
112
121
|
xTicks,
|
|
122
|
+
formatX,
|
|
123
|
+
cursorResolution,
|
|
113
124
|
tooltip,
|
|
114
125
|
areaFill,
|
|
115
126
|
height,
|
|
@@ -140,6 +151,8 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
|
140
151
|
{...(xZones !== undefined && { xZones })}
|
|
141
152
|
{...(refLines !== undefined && { refLines })}
|
|
142
153
|
{...(xTicks !== undefined && { xTicks })}
|
|
154
|
+
{...(formatX !== undefined && { formatX })}
|
|
155
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
143
156
|
{...(tooltip !== undefined && { tooltip })}
|
|
144
157
|
{...(height !== undefined && { height })}
|
|
145
158
|
{...(legend !== undefined && { legend })}
|