basalt-ui 1.14.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/AGENTS.md +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +38 -2
- package/agent/rules/basalt-charts.md +279 -108
- package/agent/rules/basalt-mantine.md +4 -2
- package/agent/skills/basalt-charts/SKILL.md +114 -64
- package/agent/skills/basalt-design/SKILL.md +4 -3
- package/agent/templates/CLAUDE-block.md.tpl +9 -6
- package/configs/oxlint-plugin.js +152 -0
- package/configs/oxlint-plugin.test.ts +107 -0
- package/configs/oxlint.json +2 -0
- package/dist/charts/cursor/resolve.d.ts +33 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -0
- package/dist/charts/cursor/resolve.js +61 -0
- package/dist/charts/cursor/resolve.test.d.ts +2 -0
- package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
- package/dist/charts/cursor/scope.d.ts +17 -0
- package/dist/charts/cursor/scope.d.ts.map +1 -0
- package/dist/charts/cursor/scope.js +20 -0
- package/dist/charts/cursor/store.d.ts +31 -0
- package/dist/charts/cursor/store.d.ts.map +1 -0
- package/dist/charts/cursor/store.js +25 -0
- package/dist/charts/cursor/store.test.d.ts +2 -0
- package/dist/charts/cursor/store.test.d.ts.map +1 -0
- package/dist/charts/hooks/useChartCursor.d.ts +39 -0
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
- package/dist/charts/hooks/useChartCursor.js +104 -0
- package/dist/charts/hooks/useChartSize.d.ts +8 -4
- package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
- package/dist/charts/index.d.ts +16 -13
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +38 -19
- package/dist/charts/kinds/Bars.d.ts +45 -54
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +235 -392
- 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/Donut.d.ts +5 -4
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +17 -14
- 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 +185 -92
- 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/Heatmap.d.ts +15 -11
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +38 -22
- package/dist/charts/kinds/MultiLine.d.ts +27 -35
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +122 -235
- 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 +21 -10
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +74 -132
- package/dist/charts/kinds/ZonedLine.d.ts +24 -40
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +104 -184
- package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
- package/dist/charts/layout/auto-margin.d.ts +60 -0
- package/dist/charts/layout/auto-margin.d.ts.map +1 -0
- package/dist/charts/layout/auto-margin.js +37 -0
- package/dist/charts/layout/auto-margin.test.d.ts +2 -0
- package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +161 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.js +359 -0
- package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.d.ts +16 -5
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +17 -2
- package/dist/charts/primitives/ChartLegend.d.ts +15 -1
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +25 -3
- package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
- package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +45 -19
- package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +22 -1
- package/dist/charts/primitives/XZoneRects.d.ts +54 -0
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
- package/dist/charts/primitives/XZoneRects.js +65 -0
- package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
- package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
- package/dist/charts/series.d.ts +28 -3
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +5 -2
- package/dist/charts/series.test.d.ts +2 -0
- package/dist/charts/series.test.d.ts.map +1 -0
- package/dist/charts/utils/measure-text.d.ts +25 -0
- package/dist/charts/utils/measure-text.d.ts.map +1 -0
- package/dist/charts/utils/measure-text.js +64 -0
- package/dist/charts/utils/measure-text.test.d.ts +2 -0
- package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/chart-margin.test.d.ts +2 -0
- package/dist/tokens/chart-margin.test.d.ts.map +1 -0
- package/dist/tokens/index.d.ts +29 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +10 -0
- package/dist/tokens/palette.d.ts +4 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +3 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +61 -0
- package/src/charts/cursor/resolve.ts +102 -0
- package/src/charts/cursor/scope.tsx +32 -0
- package/src/charts/cursor/store.test.ts +46 -0
- package/src/charts/cursor/store.ts +53 -0
- package/src/charts/hooks/useChartCursor.ts +163 -0
- package/src/charts/hooks/useChartSize.ts +8 -4
- package/src/charts/index.ts +35 -14
- package/src/charts/kinds/Bars.test.tsx +98 -0
- package/src/charts/kinds/Bars.tsx +350 -467
- package/src/charts/kinds/Donut.tsx +31 -23
- package/src/charts/kinds/DualPanel.test.tsx +131 -0
- package/src/charts/kinds/DualPanel.tsx +301 -135
- package/src/charts/kinds/Heatmap.tsx +72 -47
- package/src/charts/kinds/MultiLine.test.tsx +37 -0
- package/src/charts/kinds/MultiLine.tsx +171 -290
- package/src/charts/kinds/StackedArea.tsx +116 -175
- package/src/charts/kinds/ZonedLine.test.tsx +37 -0
- package/src/charts/kinds/ZonedLine.tsx +149 -255
- package/src/charts/layout/auto-margin.test.ts +42 -0
- package/src/charts/layout/auto-margin.ts +110 -0
- package/src/charts/primitives/CartesianChart.test.tsx +160 -0
- package/src/charts/primitives/CartesianChart.tsx +598 -0
- package/src/charts/primitives/ChartFrame.test.tsx +33 -0
- package/src/charts/primitives/ChartFrame.tsx +40 -4
- package/src/charts/primitives/ChartLegend.test.tsx +78 -0
- package/src/charts/primitives/ChartLegend.tsx +52 -2
- package/src/charts/primitives/ChartTooltip.tsx +67 -37
- package/src/charts/primitives/HoverOverlay.tsx +39 -3
- package/src/charts/primitives/XZoneRects.test.tsx +123 -0
- package/src/charts/primitives/XZoneRects.tsx +132 -0
- package/src/charts/series.test.ts +112 -0
- package/src/charts/series.ts +33 -5
- package/src/charts/utils/measure-text.test.ts +36 -0
- package/src/charts/utils/measure-text.ts +109 -0
- package/src/content/fence-block.tsx +2 -3
- package/src/surfaces.ts +2 -1
- package/src/theme/spacing.test.ts +2 -0
- package/src/tokens/chart-margin.test.ts +39 -0
- package/src/tokens/density.test.ts +1 -0
- package/src/tokens/index.ts +36 -0
- package/src/tokens/palette.ts +3 -0
- package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
- package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
- package/dist/charts/hooks/useChartTooltip.js +0 -26
- package/dist/charts/hooks/useHoverSync.d.ts +0 -46
- package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
- package/dist/charts/hooks/useHoverSync.js +0 -75
- package/dist/charts/hover-context.d.ts +0 -21
- package/dist/charts/hover-context.d.ts.map +0 -1
- package/dist/charts/hover-context.js +0 -12
- package/dist/charts/hover-sync.d.ts +0 -14
- package/dist/charts/hover-sync.d.ts.map +0 -1
- package/dist/charts/hover-sync.js +0 -17
- package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
- package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
- package/dist/charts/primitives/ResponsiveChart.js +0 -24
- package/src/charts/hooks/useChartTooltip.ts +0 -31
- package/src/charts/hooks/useHoverSync.ts +0 -152
- package/src/charts/hover-context.ts +0 -28
- package/src/charts/hover-sync.tsx +0 -37
- package/src/charts/primitives/ResponsiveChart.tsx +0 -64
|
@@ -5,23 +5,25 @@ 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,
|
|
11
12
|
TooltipBody,
|
|
12
13
|
TooltipHeader,
|
|
13
14
|
TooltipRow,
|
|
14
|
-
useTooltipStyles,
|
|
15
15
|
} from '../primitives/ChartTooltip'
|
|
16
16
|
import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
|
|
17
17
|
import { Crosshair, SeriesDot } from '../primitives/Crosshair'
|
|
18
18
|
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
19
19
|
import { ZoneRects } from '../primitives/ZoneRects'
|
|
20
20
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
21
|
-
import {
|
|
21
|
+
import { useChartCursor } from '../hooks/useChartCursor'
|
|
22
|
+
import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
|
|
22
23
|
import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
23
24
|
import type { ChartLegendConfig, ChartSeries, SeriesStyle } from '../series'
|
|
24
25
|
import { VX } from '../../tokens'
|
|
26
|
+
import { fmtAxisDate } from '../utils/format'
|
|
25
27
|
import { smartTicks } from '../utils/ticks'
|
|
26
28
|
|
|
27
29
|
export type DualPanelProps<T> = {
|
|
@@ -49,7 +51,20 @@ export type DualPanelProps<T> = {
|
|
|
49
51
|
/** Top pane share of the inner plot height. Default 0.62. */
|
|
50
52
|
topFraction?: number
|
|
51
53
|
formatTop: (v: number) => string
|
|
54
|
+
/** Drives the bottom-pane TICKS. Also the tooltip-row fallback when `formatBar` is omitted. */
|
|
52
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
|
|
53
68
|
/** Tooltip badge — appears at the right of the tooltip header. */
|
|
54
69
|
tooltipLabel?: (d: T) => { text: string; color: string } | null
|
|
55
70
|
/** Legend config forwarded to `ChartFrame`; `false` disables the legend (sparkline escape).
|
|
@@ -113,10 +128,10 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
type DualPanelPlotProps<T> = DualPanelProps<T> & {
|
|
116
|
-
plot: { width: number; height: number }
|
|
131
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
117
132
|
}
|
|
118
133
|
|
|
119
|
-
/** The measured plot — split from {@link DualPanelInner} so its scale/
|
|
134
|
+
/** The measured plot — split from {@link DualPanelInner} so its scale/cursor hooks only run
|
|
120
135
|
* once `ChartFrame` has resolved a non-empty plot rect. */
|
|
121
136
|
function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
122
137
|
const {
|
|
@@ -135,32 +150,36 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
135
150
|
topFraction = 0.62,
|
|
136
151
|
formatTop,
|
|
137
152
|
formatBottom,
|
|
153
|
+
formatBar,
|
|
154
|
+
bottomYDomain = 'auto',
|
|
155
|
+
bottomMaxAbsFloor,
|
|
138
156
|
tooltipLabel,
|
|
139
157
|
plot,
|
|
140
158
|
} = props
|
|
141
159
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
const { hidden } = plot
|
|
161
|
+
// Legend-hidden series drop out of the domain, the drawn lines/dots, and the tooltip together
|
|
162
|
+
// (`docs/CHARTS-SPEC.md` §5) — the synthesized `__divergence` key gates the bottom histogram the
|
|
163
|
+
// same way.
|
|
164
|
+
const visibleSeries = useMemo(() => series.filter((s) => !hidden.has(s.key)), [series, hidden])
|
|
165
|
+
const barVisible = !hidden.has('__divergence')
|
|
166
|
+
|
|
167
|
+
// ── Pass 1: y domains + tick labels, both independent of the pixel rect ─────────────────────
|
|
168
|
+
// A linear scale's domain (and, with `nice`, its rounded bounds) is a pure function of the
|
|
169
|
+
// input domain — not of its pixel range — so the tick labels used to MEASURE the margin can be
|
|
170
|
+
// read off a throwaway [1, 0] range before the real [topH, 0] / [bottomH, 0] range is known.
|
|
171
|
+
const useNiceTop = topYDomain === 'auto'
|
|
172
|
+
// Deliberately NOT `resolveAxisDomain`'s law, and not drift: that one anchors to a zero baseline
|
|
173
|
+
// and pads multiplicatively, which is right for a magnitude chart. This pane plots a signal line
|
|
174
|
+
// whose interesting range is a narrow band far from zero (MACD, a price series) — a zero
|
|
175
|
+
// baseline would squash it into a sliver at the top of the pane. Hence a SPAN-relative symmetric
|
|
176
|
+
// pad instead. If a third pane-shaped kind ever wants this law, promote it into
|
|
177
|
+
// `layout/auto-margin.ts` beside `probeAxisLabels` rather than copying it again.
|
|
178
|
+
const topDomain = useMemo<[number, number]>(() => {
|
|
179
|
+
if (topYDomain !== 'auto') return topYDomain
|
|
161
180
|
let lo = Infinity
|
|
162
181
|
let hi = -Infinity
|
|
163
|
-
for (const s of
|
|
182
|
+
for (const s of visibleSeries) {
|
|
164
183
|
for (const d of data) {
|
|
165
184
|
const v = s.getValue(d)
|
|
166
185
|
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
@@ -173,50 +192,153 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
173
192
|
hi = 1
|
|
174
193
|
}
|
|
175
194
|
const span = hi - lo || Math.abs(hi) || 1
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
return [lo - span * 0.08, hi + span * 0.08]
|
|
196
|
+
}, [data, visibleSeries, topYDomain])
|
|
197
|
+
|
|
198
|
+
const topLabels = useMemo(
|
|
199
|
+
() =>
|
|
200
|
+
probeAxisLabels({ domain: topDomain, ticks: 4, format: formatTop, nice: useNiceTop }).labels,
|
|
201
|
+
[topDomain, useNiceTop, formatTop],
|
|
202
|
+
)
|
|
203
|
+
|
|
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'
|
|
207
|
+
const maxAbs = useMemo(() => {
|
|
208
|
+
if (bottomYDomain !== 'auto') return 0
|
|
209
|
+
let m = 0
|
|
186
210
|
for (const d of data) {
|
|
187
211
|
const v = getBar(d)
|
|
188
212
|
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
189
213
|
const a = Math.abs(v)
|
|
190
|
-
if (a >
|
|
214
|
+
if (a > m) m = a
|
|
191
215
|
}
|
|
192
|
-
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
216
|
+
const floored = bottomMaxAbsFloor !== undefined ? Math.max(m, bottomMaxAbsFloor) : m
|
|
217
|
+
return floored === 0 ? 1 : floored
|
|
218
|
+
}, [data, getBar, bottomYDomain, bottomMaxAbsFloor])
|
|
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.
|
|
227
|
+
const bottomLabels = useMemo(
|
|
228
|
+
() =>
|
|
229
|
+
probeAxisLabels({
|
|
230
|
+
domain: bottomDomain,
|
|
231
|
+
ticks: 3,
|
|
232
|
+
format: formatBottom,
|
|
233
|
+
nice: useNiceBottom,
|
|
234
|
+
}).labels,
|
|
235
|
+
[bottomDomain, formatBottom, useNiceBottom],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
|
|
239
|
+
// labels, never widens the max, so measuring against the full set is a safe over-estimate.
|
|
240
|
+
const xLabelsAll = useMemo(() => data.map((d) => fmtAxisDate(getX(d))), [data, getX])
|
|
241
|
+
|
|
242
|
+
const margin = useMemo(
|
|
243
|
+
() => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
|
|
244
|
+
[topLabels, bottomLabels, xLabelsAll],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
// ── Pass 2: the real scales, now that the plot rect is known ────────────────────────────────
|
|
248
|
+
const xMax = Math.max(plot.width - margin.left - margin.right, 0)
|
|
249
|
+
// Inner plot height shared by both panes (excludes top/bottom margin + gutter).
|
|
250
|
+
const plotH = plot.height - margin.top - margin.bottom - PANE_GAP
|
|
251
|
+
const topH = Math.max(Math.round(plotH * topFraction), 1)
|
|
252
|
+
const bottomH = Math.max(plotH - topH, 1)
|
|
253
|
+
const bottomTop = topH + PANE_GAP
|
|
254
|
+
// Full inner span — the synced crosshair line covers BOTH panes.
|
|
255
|
+
const innerH = topH + PANE_GAP + bottomH
|
|
256
|
+
|
|
257
|
+
const xScale = useMemo(
|
|
258
|
+
() => scalePoint<string>({ domain: data.map(getX), range: [0, xMax], padding: 0.3 }),
|
|
259
|
+
[data, xMax, getX],
|
|
209
260
|
)
|
|
210
261
|
|
|
262
|
+
const topYScale = useMemo(
|
|
263
|
+
() =>
|
|
264
|
+
scaleLinear<number>({
|
|
265
|
+
domain: topDomain,
|
|
266
|
+
range: [topH, 0],
|
|
267
|
+
...(useNiceTop && { nice: true }),
|
|
268
|
+
}),
|
|
269
|
+
[topDomain, useNiceTop, topH],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const bottomYScale = useMemo(
|
|
273
|
+
() =>
|
|
274
|
+
scaleLinear<number>({
|
|
275
|
+
domain: bottomDomain,
|
|
276
|
+
range: [bottomH, 0],
|
|
277
|
+
...(useNiceBottom && { nice: true }),
|
|
278
|
+
}),
|
|
279
|
+
[bottomDomain, bottomH, useNiceBottom],
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const cursor = useChartCursor<T>({
|
|
283
|
+
data,
|
|
284
|
+
chartId,
|
|
285
|
+
getKey: getX,
|
|
286
|
+
xScale,
|
|
287
|
+
marginLeft: margin.left,
|
|
288
|
+
})
|
|
289
|
+
|
|
211
290
|
const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
|
|
212
291
|
|
|
213
292
|
const barWidth = data.length > 0 ? Math.max((xMax / data.length) * 0.6, 2) : 2
|
|
214
293
|
|
|
294
|
+
type HistogramBar = {
|
|
295
|
+
key: string
|
|
296
|
+
x: number
|
|
297
|
+
y: number
|
|
298
|
+
width: number
|
|
299
|
+
height: number
|
|
300
|
+
fill: string
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Bottom-pane histogram geometry — memoized since the shared cursor re-renders every mounted
|
|
304
|
+
// chart on every pointer frame, and this walks the full `data` array building fresh bar
|
|
305
|
+
// geometry each time otherwise (parity with Bars' barGroups).
|
|
306
|
+
const histogramBars = useMemo<HistogramBar[]>(() => {
|
|
307
|
+
if (!barVisible) return []
|
|
308
|
+
const y0 = bottomYScale(0)
|
|
309
|
+
const bars: HistogramBar[] = []
|
|
310
|
+
for (const d of data) {
|
|
311
|
+
const v = getBar(d)
|
|
312
|
+
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
313
|
+
const cx = xScale(getX(d)) ?? 0
|
|
314
|
+
const yVal = bottomYScale(v)
|
|
315
|
+
bars.push({
|
|
316
|
+
key: `bar-${getX(d)}`,
|
|
317
|
+
x: cx - barWidth / 2,
|
|
318
|
+
y: Math.min(y0, yVal),
|
|
319
|
+
width: barWidth,
|
|
320
|
+
height: Math.max(Math.abs(yVal - y0), 1),
|
|
321
|
+
fill: v >= 0 ? barColorPositive : barColorNegative,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
return bars
|
|
325
|
+
}, [
|
|
326
|
+
barVisible,
|
|
327
|
+
data,
|
|
328
|
+
getBar,
|
|
329
|
+
getX,
|
|
330
|
+
xScale,
|
|
331
|
+
bottomYScale,
|
|
332
|
+
barWidth,
|
|
333
|
+
barColorPositive,
|
|
334
|
+
barColorNegative,
|
|
335
|
+
])
|
|
336
|
+
|
|
215
337
|
type FillPt = { __d: T; __from: number; __to: number }
|
|
216
338
|
const fillPts = useMemo<FillPt[]>(() => {
|
|
217
339
|
if (!fillBetween) return []
|
|
218
|
-
const fromLine =
|
|
219
|
-
const toLine =
|
|
340
|
+
const fromLine = visibleSeries.find((s) => s.key === fillBetween.from)
|
|
341
|
+
const toLine = visibleSeries.find((s) => s.key === fillBetween.to)
|
|
220
342
|
if (!fromLine || !toLine) return []
|
|
221
343
|
const out: FillPt[] = []
|
|
222
344
|
for (const d of data) {
|
|
@@ -227,14 +349,14 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
227
349
|
out.push({ __d: d, __from: f, __to: t })
|
|
228
350
|
}
|
|
229
351
|
return out
|
|
230
|
-
}, [data, fillBetween,
|
|
352
|
+
}, [data, fillBetween, visibleSeries])
|
|
231
353
|
|
|
232
354
|
type LinePt = { __d: T; __y: number }
|
|
233
|
-
// Per-line valid points, computed once per (data,
|
|
234
|
-
// every paint (parity with MultiLine's seriesPts).
|
|
355
|
+
// Per-line valid points, computed once per (data, visibleSeries) — not re-walked inside the
|
|
356
|
+
// render map every paint (parity with MultiLine's seriesPts).
|
|
235
357
|
const lineValid = useMemo(() => {
|
|
236
358
|
const out = new Map<string, LinePt[]>()
|
|
237
|
-
for (const s of
|
|
359
|
+
for (const s of visibleSeries) {
|
|
238
360
|
const pts: LinePt[] = []
|
|
239
361
|
for (const d of data) {
|
|
240
362
|
const v = s.getValue(d)
|
|
@@ -243,16 +365,49 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
243
365
|
out.set(s.key, pts)
|
|
244
366
|
}
|
|
245
367
|
return out
|
|
246
|
-
}, [data,
|
|
368
|
+
}, [data, visibleSeries])
|
|
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
|
+
)
|
|
247
401
|
|
|
248
|
-
const
|
|
249
|
-
const
|
|
402
|
+
const point = cursor.point
|
|
403
|
+
const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
|
|
404
|
+
const syncedBar = point !== null ? getBar(point) : null
|
|
250
405
|
|
|
251
406
|
return (
|
|
252
407
|
<>
|
|
253
408
|
<svg width={plot.width} height={plot.height}>
|
|
254
409
|
{/* Top pane: line series + fill-between + zones + ref lines. */}
|
|
255
|
-
<Group left={
|
|
410
|
+
<Group left={margin.left} top={margin.top}>
|
|
256
411
|
<GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
|
|
257
412
|
|
|
258
413
|
<ZoneRects zones={topZones} width={xMax} leftScale={topYScale} />
|
|
@@ -284,7 +439,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
284
439
|
/>
|
|
285
440
|
))}
|
|
286
441
|
|
|
287
|
-
{
|
|
442
|
+
{visibleSeries.map((s) => {
|
|
288
443
|
const valid = lineValid.get(s.key) ?? []
|
|
289
444
|
if (valid.length === 0) return null
|
|
290
445
|
return (
|
|
@@ -296,16 +451,21 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
296
451
|
stroke={s.color}
|
|
297
452
|
strokeWidth={s.strokeWidth ?? VX.lineWidth}
|
|
298
453
|
strokeDasharray={s.dash === 'dashed' ? VX.dashArray : undefined}
|
|
454
|
+
strokeOpacity={s.strokeOpacity ?? 1}
|
|
299
455
|
curve={curveMonotoneX}
|
|
300
456
|
/>
|
|
301
457
|
)
|
|
302
458
|
})}
|
|
303
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
|
+
|
|
304
464
|
{/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
|
|
305
|
-
{
|
|
465
|
+
{point !== null && (
|
|
306
466
|
<>
|
|
307
|
-
{
|
|
308
|
-
const v = s.getValue(
|
|
467
|
+
{visibleSeries.map((s) => {
|
|
468
|
+
const v = s.getValue(point)
|
|
309
469
|
if (v === null || v === undefined || Number.isNaN(v)) return null
|
|
310
470
|
return (
|
|
311
471
|
<SeriesDot key={`top-dot-${s.key}`} cx={sx} cy={topYScale(v)} color={s.color} />
|
|
@@ -314,20 +474,28 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
314
474
|
</>
|
|
315
475
|
)}
|
|
316
476
|
|
|
477
|
+
{/* theme-allow basalt/hand-rolled-plot: TWO panes over one x scale is not a single
|
|
478
|
+
cartesian plot, so `CartesianChart` (one plot rect, one or two y axes) cannot express
|
|
479
|
+
it. This file therefore assembles the plot itself — but from the SAME parts every
|
|
480
|
+
other chart gets: `ChartFrame`, `autoMargin` + `probeAxisLabels`, `useChartCursor`,
|
|
481
|
+
`ChartTooltipFloat`. If a second multi-pane kind ever appears, promote the shared
|
|
482
|
+
choreography rather than copying this file. */}
|
|
317
483
|
<AxisLeftNumeric scale={topYScale} numTicks={4} tickFormat={formatTop} />
|
|
318
|
-
{/* One HoverOverlay per pane, both driving the SAME
|
|
484
|
+
{/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
|
|
319
485
|
x-only, so either overlay yields the same point. The top overlay extends over the gutter
|
|
320
486
|
(height = topH + PANE_GAP) so there's no dead zone between the panes. */}
|
|
321
487
|
<HoverOverlay
|
|
322
488
|
width={xMax}
|
|
323
489
|
height={topH + PANE_GAP}
|
|
324
|
-
onMove={
|
|
325
|
-
onLeave={
|
|
490
|
+
onMove={cursor.onPointerMove}
|
|
491
|
+
onLeave={cursor.onPointerLeave}
|
|
492
|
+
onKeyDown={cursor.onKeyDown}
|
|
493
|
+
onBlur={cursor.onBlur}
|
|
326
494
|
/>
|
|
327
495
|
</Group>
|
|
328
496
|
|
|
329
497
|
{/* Bottom pane: signed histogram around a zero baseline. */}
|
|
330
|
-
<Group left={
|
|
498
|
+
<Group left={margin.left} top={margin.top + bottomTop}>
|
|
331
499
|
<line
|
|
332
500
|
x1={0}
|
|
333
501
|
x2={xMax}
|
|
@@ -337,94 +505,92 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
337
505
|
strokeWidth={1}
|
|
338
506
|
/>
|
|
339
507
|
|
|
340
|
-
{
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
height={Math.max(Math.abs(yVal - y0), 1)}
|
|
353
|
-
fill={v >= 0 ? barColorPositive : barColorNegative}
|
|
354
|
-
fillOpacity={0.7}
|
|
355
|
-
rx={1.4}
|
|
356
|
-
/>
|
|
357
|
-
)
|
|
358
|
-
})}
|
|
508
|
+
{histogramBars.map((b) => (
|
|
509
|
+
<Bar
|
|
510
|
+
key={b.key}
|
|
511
|
+
x={b.x}
|
|
512
|
+
y={b.y}
|
|
513
|
+
width={b.width}
|
|
514
|
+
height={b.height}
|
|
515
|
+
fill={b.fill}
|
|
516
|
+
fillOpacity={0.7}
|
|
517
|
+
rx={1.4}
|
|
518
|
+
/>
|
|
519
|
+
))}
|
|
359
520
|
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
521
|
+
{barVisible &&
|
|
522
|
+
point !== null &&
|
|
523
|
+
syncedBar !== null &&
|
|
524
|
+
syncedBar !== undefined &&
|
|
525
|
+
!Number.isNaN(syncedBar) && (
|
|
526
|
+
<SeriesDot
|
|
527
|
+
cx={sx}
|
|
528
|
+
cy={bottomYScale(syncedBar)}
|
|
529
|
+
color={syncedBar >= 0 ? barColorPositive : barColorNegative}
|
|
530
|
+
/>
|
|
531
|
+
)}
|
|
371
532
|
|
|
372
533
|
<AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
|
|
373
534
|
<AxisBottomDate top={bottomH} scale={xScale} tickValues={tickValues} />
|
|
535
|
+
{/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
|
|
536
|
+
Both panes scrub the same single x cursor, so a second tab stop would be a second
|
|
537
|
+
control for one logical axis: a keyboard user would tab twice through one chart and
|
|
538
|
+
find the arrow keys doing the same thing. The top overlay owns the keyboard. */}
|
|
374
539
|
<HoverOverlay
|
|
375
540
|
width={xMax}
|
|
376
|
-
height={bottomH +
|
|
377
|
-
onMove={
|
|
378
|
-
onLeave={
|
|
541
|
+
height={bottomH + margin.bottom}
|
|
542
|
+
onMove={cursor.onPointerMove}
|
|
543
|
+
onLeave={cursor.onPointerLeave}
|
|
379
544
|
/>
|
|
380
545
|
</Group>
|
|
381
546
|
|
|
382
547
|
{/* Continuous crosshair spanning the gutter between panes. */}
|
|
383
|
-
{
|
|
384
|
-
<Group left={
|
|
548
|
+
{point !== null && (
|
|
549
|
+
<Group left={margin.left} top={margin.top}>
|
|
385
550
|
<Crosshair x={sx} top={0} bottom={innerH} />
|
|
386
551
|
</Group>
|
|
387
552
|
)}
|
|
388
553
|
</svg>
|
|
389
554
|
|
|
390
|
-
|
|
391
|
-
{
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const v = getBar(
|
|
555
|
+
{cursor.isSource && point !== null && (
|
|
556
|
+
<ChartTooltipFloat anchor={cursor.anchor}>
|
|
557
|
+
<TooltipHeader
|
|
558
|
+
date={getX(point)}
|
|
559
|
+
{...(() => {
|
|
560
|
+
const lbl = tooltipLabel?.(point) ?? null
|
|
561
|
+
return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
|
|
562
|
+
})()}
|
|
563
|
+
/>
|
|
564
|
+
<TooltipBody>
|
|
565
|
+
{deriveTooltipRows(visibleSeries, point, formatTop).map((row) => (
|
|
566
|
+
<TooltipRow
|
|
567
|
+
key={row.key}
|
|
568
|
+
color={row.color}
|
|
569
|
+
label={row.label}
|
|
570
|
+
value={row.value}
|
|
571
|
+
shape={row.shape}
|
|
572
|
+
dashed={row.dashed}
|
|
573
|
+
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
574
|
+
/>
|
|
575
|
+
))}
|
|
576
|
+
{barVisible &&
|
|
577
|
+
(() => {
|
|
578
|
+
const v = getBar(point)
|
|
414
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)
|
|
415
582
|
return (
|
|
416
583
|
<TooltipRow
|
|
417
584
|
color={v >= 0 ? barColorPositive : barColorNegative}
|
|
418
585
|
label={barLabel}
|
|
419
|
-
value={
|
|
586
|
+
value={value}
|
|
420
587
|
shape="bar"
|
|
421
588
|
/>
|
|
422
589
|
)
|
|
423
590
|
})()}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
</ChartTooltip>
|
|
591
|
+
</TooltipBody>
|
|
592
|
+
</ChartTooltipFloat>
|
|
593
|
+
)}
|
|
428
594
|
</>
|
|
429
595
|
)
|
|
430
596
|
}
|