basalt-ui 1.13.0 → 1.15.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 +260 -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/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 +25 -47
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +230 -390
- 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.map +1 -1
- package/dist/charts/kinds/DualPanel.js +136 -90
- 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 +114 -237
- 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 +103 -184
- 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 +143 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.js +349 -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 +12 -1
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +21 -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 +40 -0
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
- package/dist/charts/primitives/XZoneRects.js +28 -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 +16 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +3 -1
- 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/shell/app-sidebar.d.ts +19 -1
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +30 -26
- package/dist/shell/app-sidebar.module.css +11 -0
- package/dist/shell/app-sidebar.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.test.d.ts +2 -0
- package/dist/shell/app-sidebar.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +10 -1
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +2 -0
- package/dist/shell/index.test.d.ts +2 -0
- package/dist/shell/index.test.d.ts.map +1 -0
- 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 +34 -14
- package/src/charts/kinds/Bars.tsx +327 -460
- package/src/charts/kinds/Donut.tsx +31 -23
- package/src/charts/kinds/DualPanel.tsx +228 -134
- package/src/charts/kinds/Heatmap.tsx +72 -47
- package/src/charts/kinds/MultiLine.tsx +158 -294
- package/src/charts/kinds/StackedArea.tsx +116 -175
- package/src/charts/kinds/ZonedLine.tsx +148 -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.ts +99 -0
- package/src/charts/primitives/CartesianChart.tsx +566 -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 +48 -0
- package/src/charts/primitives/ChartLegend.tsx +45 -2
- package/src/charts/primitives/ChartTooltip.tsx +67 -37
- package/src/charts/primitives/HoverOverlay.tsx +39 -3
- package/src/charts/primitives/XZoneRects.test.tsx +63 -0
- package/src/charts/primitives/XZoneRects.tsx +67 -0
- package/src/charts/series.test.ts +61 -0
- package/src/charts/series.ts +18 -1
- 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/shell/app-sidebar.module.css +11 -0
- package/src/shell/app-sidebar.module.css.d.ts +1 -0
- package/src/shell/app-sidebar.test.tsx +98 -0
- package/src/shell/app-sidebar.tsx +22 -0
- package/src/shell/index.test.tsx +35 -0
- package/src/shell/index.tsx +11 -0
- 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
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Group } from '@visx/group'
|
|
2
2
|
import { Pie } from '@visx/shape'
|
|
3
|
-
import { memo, useMemo } from 'react'
|
|
4
|
-
import type { ReactNode } from 'react'
|
|
5
|
-
import {
|
|
3
|
+
import { memo, useMemo, useState } from 'react'
|
|
4
|
+
import type { PointerEvent, ReactNode } from 'react'
|
|
5
|
+
import { ChartTooltipFloat, TooltipBody, TooltipRow } from '../primitives/ChartTooltip'
|
|
6
6
|
import { ChartFrame } from '../primitives/ChartFrame'
|
|
7
|
-
import { useChartTooltip } from '../hooks/useChartTooltip'
|
|
8
7
|
import { VX } from '../../tokens'
|
|
9
8
|
import type { SeriesStyle } from '../series'
|
|
10
9
|
import type { SeriesKey } from '../../register'
|
|
@@ -60,10 +59,11 @@ export type DonutProps<K extends string = SeriesKey> = {
|
|
|
60
59
|
/**
|
|
61
60
|
* Radial slice-share chart with a punched-out center label. Composes `ChartFrame` for a
|
|
62
61
|
* categorical legend derived from the slices (one `SeriesStyle` per slice, `mark: 'bar'`) so the
|
|
63
|
-
* legend can never drift from what's plotted
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
62
|
+
* legend can never drift from what's plotted — legend-hidden slices drop out of the ring, the
|
|
63
|
+
* center total, and the tooltip's "Share" row together (`docs/CHARTS-SPEC.md` §5). No crosshair —
|
|
64
|
+
* meaningless for a radial layout. Hover stays local to the pie (dimming siblings on hover) rather
|
|
65
|
+
* than joining the shared cursor: a date-keyed cursor has no counterpart on a donut, and cross-kind
|
|
66
|
+
* category sync (donut ↔ bar, via a generalized key) is a distinct, deliberately deferred feature.
|
|
67
67
|
*/
|
|
68
68
|
function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
69
69
|
const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel, isPending } = props
|
|
@@ -88,9 +88,12 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
type DonutPlotProps<K extends string = SeriesKey> = DonutProps<K> & {
|
|
91
|
-
plot: { width: number; height: number }
|
|
91
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** A hovered slice plus the viewport anchor `ChartTooltipFloat` positions against. */
|
|
95
|
+
type DonutTip<K extends string> = { key: K; value: number; anchor: { x: number; y: number } }
|
|
96
|
+
|
|
94
97
|
/** The measured plot — split from {@link DonutInner} so it only draws once `ChartFrame` has
|
|
95
98
|
* resolved a non-empty plot rect (radius/center depend on the measured size). */
|
|
96
99
|
function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
|
|
@@ -106,26 +109,31 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
|
|
|
106
109
|
innerRatio = 0.6,
|
|
107
110
|
padAngle = 0.01,
|
|
108
111
|
} = props
|
|
109
|
-
const { width, height } = plot
|
|
112
|
+
const { width, height, hidden } = plot
|
|
110
113
|
|
|
111
|
-
const
|
|
114
|
+
const [tip, setTip] = useState<DonutTip<K> | null>(null)
|
|
115
|
+
const hoveredKey = tip?.key ?? null
|
|
112
116
|
|
|
113
|
-
const
|
|
114
|
-
const hoveredKey = tip?.data.key ?? null
|
|
117
|
+
const visibleData = useMemo(() => data.filter((d) => !hidden.has(d.key)), [data, hidden])
|
|
115
118
|
|
|
116
119
|
const radius = Math.min(width, height) / 2 - 4
|
|
117
120
|
const innerRadius = radius * innerRatio
|
|
118
121
|
const centerX = width / 2
|
|
119
122
|
const centerY = height / 2
|
|
120
123
|
|
|
121
|
-
const total = useMemo(() =>
|
|
124
|
+
const total = useMemo(() => visibleData.reduce((sum, d) => sum + d.value, 0), [visibleData])
|
|
125
|
+
|
|
126
|
+
const show = (d: DonutDatum<K>, event: PointerEvent<SVGGElement>) => {
|
|
127
|
+
setTip({ key: d.key, value: d.value, anchor: { x: event.clientX, y: event.clientY } })
|
|
128
|
+
}
|
|
129
|
+
const hide = () => setTip(null)
|
|
122
130
|
|
|
123
131
|
return (
|
|
124
132
|
<div style={{ position: 'relative' }}>
|
|
125
133
|
<svg width={width} height={height}>
|
|
126
134
|
<Group left={centerX} top={centerY}>
|
|
127
135
|
<Pie<DonutDatum<K>>
|
|
128
|
-
data={
|
|
136
|
+
data={visibleData}
|
|
129
137
|
pieValue={(d) => d.value}
|
|
130
138
|
pieSortValues={() => 0}
|
|
131
139
|
outerRadius={radius}
|
|
@@ -210,24 +218,24 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
|
|
|
210
218
|
</div>
|
|
211
219
|
)}
|
|
212
220
|
|
|
213
|
-
|
|
214
|
-
{tip
|
|
221
|
+
{tip !== null && (
|
|
222
|
+
<ChartTooltipFloat anchor={tip.anchor}>
|
|
215
223
|
<TooltipBody>
|
|
216
224
|
<TooltipRow
|
|
217
|
-
color={colorForKey(tip.
|
|
218
|
-
label={seriesLabel(tip.
|
|
219
|
-
value={formatValue(tip.
|
|
225
|
+
color={colorForKey(tip.key)}
|
|
226
|
+
label={seriesLabel(tip.key)}
|
|
227
|
+
value={formatValue(tip.value)}
|
|
220
228
|
shape="bar"
|
|
221
229
|
/>
|
|
222
230
|
<TooltipRow
|
|
223
231
|
color={VX.grid}
|
|
224
232
|
label="Share"
|
|
225
|
-
value={`${total > 0 ? Math.round((tip.
|
|
233
|
+
value={`${total > 0 ? Math.round((tip.value / total) * 100) : 0}%`}
|
|
226
234
|
shape="bar"
|
|
227
235
|
/>
|
|
228
236
|
</TooltipBody>
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
</ChartTooltipFloat>
|
|
238
|
+
)}
|
|
231
239
|
</div>
|
|
232
240
|
)
|
|
233
241
|
}
|
|
@@ -7,21 +7,22 @@ import { Threshold } from '@visx/threshold'
|
|
|
7
7
|
import { memo, useMemo } from 'react'
|
|
8
8
|
import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
ChartTooltipFloat,
|
|
11
11
|
TooltipBody,
|
|
12
12
|
TooltipHeader,
|
|
13
13
|
TooltipRow,
|
|
14
|
-
useTooltipStyles,
|
|
15
14
|
} from '../primitives/ChartTooltip'
|
|
16
15
|
import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
|
|
17
16
|
import { Crosshair, SeriesDot } from '../primitives/Crosshair'
|
|
18
17
|
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
19
18
|
import { ZoneRects } from '../primitives/ZoneRects'
|
|
20
19
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
21
|
-
import {
|
|
20
|
+
import { useChartCursor } from '../hooks/useChartCursor'
|
|
21
|
+
import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
|
|
22
22
|
import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
23
23
|
import type { ChartLegendConfig, ChartSeries, SeriesStyle } from '../series'
|
|
24
24
|
import { VX } from '../../tokens'
|
|
25
|
+
import { fmtAxisDate } from '../utils/format'
|
|
25
26
|
import { smartTicks } from '../utils/ticks'
|
|
26
27
|
|
|
27
28
|
export type DualPanelProps<T> = {
|
|
@@ -113,10 +114,10 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
type DualPanelPlotProps<T> = DualPanelProps<T> & {
|
|
116
|
-
plot: { width: number; height: number }
|
|
117
|
+
plot: { width: number; height: number; hidden: ReadonlySet<string> }
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
/** The measured plot — split from {@link DualPanelInner} so its scale/
|
|
120
|
+
/** The measured plot — split from {@link DualPanelInner} so its scale/cursor hooks only run
|
|
120
121
|
* once `ChartFrame` has resolved a non-empty plot rect. */
|
|
121
122
|
function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
122
123
|
const {
|
|
@@ -139,28 +140,29 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
139
140
|
plot,
|
|
140
141
|
} = props
|
|
141
142
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
143
|
+
const { hidden } = plot
|
|
144
|
+
// Legend-hidden series drop out of the domain, the drawn lines/dots, and the tooltip together
|
|
145
|
+
// (`docs/CHARTS-SPEC.md` §5) — the synthesized `__divergence` key gates the bottom histogram the
|
|
146
|
+
// same way.
|
|
147
|
+
const visibleSeries = useMemo(() => series.filter((s) => !hidden.has(s.key)), [series, hidden])
|
|
148
|
+
const barVisible = !hidden.has('__divergence')
|
|
149
|
+
|
|
150
|
+
// ── Pass 1: y domains + tick labels, both independent of the pixel rect ─────────────────────
|
|
151
|
+
// A linear scale's domain (and, with `nice`, its rounded bounds) is a pure function of the
|
|
152
|
+
// input domain — not of its pixel range — so the tick labels used to MEASURE the margin can be
|
|
153
|
+
// read off a throwaway [1, 0] range before the real [topH, 0] / [bottomH, 0] range is known.
|
|
154
|
+
const useNiceTop = topYDomain === 'auto'
|
|
155
|
+
// Deliberately NOT `resolveAxisDomain`'s law, and not drift: that one anchors to a zero baseline
|
|
156
|
+
// and pads multiplicatively, which is right for a magnitude chart. This pane plots a signal line
|
|
157
|
+
// whose interesting range is a narrow band far from zero (MACD, a price series) — a zero
|
|
158
|
+
// baseline would squash it into a sliver at the top of the pane. Hence a SPAN-relative symmetric
|
|
159
|
+
// pad instead. If a third pane-shaped kind ever wants this law, promote it into
|
|
160
|
+
// `layout/auto-margin.ts` beside `probeAxisLabels` rather than copying it again.
|
|
161
|
+
const topDomain = useMemo<[number, number]>(() => {
|
|
162
|
+
if (topYDomain !== 'auto') return topYDomain
|
|
161
163
|
let lo = Infinity
|
|
162
164
|
let hi = -Infinity
|
|
163
|
-
for (const s of
|
|
165
|
+
for (const s of visibleSeries) {
|
|
164
166
|
for (const d of data) {
|
|
165
167
|
const v = s.getValue(d)
|
|
166
168
|
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
@@ -173,50 +175,137 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
173
175
|
hi = 1
|
|
174
176
|
}
|
|
175
177
|
const span = hi - lo || Math.abs(hi) || 1
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
return [lo - span * 0.08, hi + span * 0.08]
|
|
179
|
+
}, [data, visibleSeries, topYDomain])
|
|
180
|
+
|
|
181
|
+
const topLabels = useMemo(
|
|
182
|
+
() =>
|
|
183
|
+
probeAxisLabels({ domain: topDomain, ticks: 4, format: formatTop, nice: useNiceTop }).labels,
|
|
184
|
+
[topDomain, useNiceTop, formatTop],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Bottom pane: symmetric signed domain around zero.
|
|
188
|
+
const maxAbs = useMemo(() => {
|
|
189
|
+
let m = 0
|
|
186
190
|
for (const d of data) {
|
|
187
191
|
const v = getBar(d)
|
|
188
192
|
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
189
193
|
const a = Math.abs(v)
|
|
190
|
-
if (a >
|
|
194
|
+
if (a > m) m = a
|
|
191
195
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
chartId,
|
|
205
|
-
getKey: getX,
|
|
206
|
-
xScale,
|
|
207
|
-
marginLeft: MARGIN.left,
|
|
208
|
-
},
|
|
196
|
+
return m === 0 ? 1 : m
|
|
197
|
+
}, [data, getBar])
|
|
198
|
+
|
|
199
|
+
const bottomLabels = useMemo(
|
|
200
|
+
() =>
|
|
201
|
+
probeAxisLabels({
|
|
202
|
+
domain: [-maxAbs, maxAbs],
|
|
203
|
+
ticks: 3,
|
|
204
|
+
format: formatBottom,
|
|
205
|
+
nice: true,
|
|
206
|
+
}).labels,
|
|
207
|
+
[maxAbs, formatBottom],
|
|
209
208
|
)
|
|
210
209
|
|
|
210
|
+
// Full (untinned) x labels are enough to measure the bottom/right gutters — thinning only drops
|
|
211
|
+
// labels, never widens the max, so measuring against the full set is a safe over-estimate.
|
|
212
|
+
const xLabelsAll = useMemo(() => data.map((d) => fmtAxisDate(getX(d))), [data, getX])
|
|
213
|
+
|
|
214
|
+
const margin = useMemo(
|
|
215
|
+
() => autoMargin({ left: [...topLabels, ...bottomLabels], bottom: xLabelsAll }),
|
|
216
|
+
[topLabels, bottomLabels, xLabelsAll],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
// ── Pass 2: the real scales, now that the plot rect is known ────────────────────────────────
|
|
220
|
+
const xMax = Math.max(plot.width - margin.left - margin.right, 0)
|
|
221
|
+
// Inner plot height shared by both panes (excludes top/bottom margin + gutter).
|
|
222
|
+
const plotH = plot.height - margin.top - margin.bottom - PANE_GAP
|
|
223
|
+
const topH = Math.max(Math.round(plotH * topFraction), 1)
|
|
224
|
+
const bottomH = Math.max(plotH - topH, 1)
|
|
225
|
+
const bottomTop = topH + PANE_GAP
|
|
226
|
+
// Full inner span — the synced crosshair line covers BOTH panes.
|
|
227
|
+
const innerH = topH + PANE_GAP + bottomH
|
|
228
|
+
|
|
229
|
+
const xScale = useMemo(
|
|
230
|
+
() => scalePoint<string>({ domain: data.map(getX), range: [0, xMax], padding: 0.3 }),
|
|
231
|
+
[data, xMax, getX],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const topYScale = useMemo(
|
|
235
|
+
() =>
|
|
236
|
+
scaleLinear<number>({
|
|
237
|
+
domain: topDomain,
|
|
238
|
+
range: [topH, 0],
|
|
239
|
+
...(useNiceTop && { nice: true }),
|
|
240
|
+
}),
|
|
241
|
+
[topDomain, useNiceTop, topH],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const bottomYScale = useMemo(
|
|
245
|
+
() => scaleLinear<number>({ domain: [-maxAbs, maxAbs], range: [bottomH, 0], nice: true }),
|
|
246
|
+
[maxAbs, bottomH],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const cursor = useChartCursor<T>({
|
|
250
|
+
data,
|
|
251
|
+
chartId,
|
|
252
|
+
getKey: getX,
|
|
253
|
+
xScale,
|
|
254
|
+
marginLeft: margin.left,
|
|
255
|
+
})
|
|
256
|
+
|
|
211
257
|
const tickValues = useMemo(() => smartTicks(data.map(getX), xMax), [data, xMax, getX])
|
|
212
258
|
|
|
213
259
|
const barWidth = data.length > 0 ? Math.max((xMax / data.length) * 0.6, 2) : 2
|
|
214
260
|
|
|
261
|
+
type HistogramBar = {
|
|
262
|
+
key: string
|
|
263
|
+
x: number
|
|
264
|
+
y: number
|
|
265
|
+
width: number
|
|
266
|
+
height: number
|
|
267
|
+
fill: string
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Bottom-pane histogram geometry — memoized since the shared cursor re-renders every mounted
|
|
271
|
+
// chart on every pointer frame, and this walks the full `data` array building fresh bar
|
|
272
|
+
// geometry each time otherwise (parity with Bars' barGroups).
|
|
273
|
+
const histogramBars = useMemo<HistogramBar[]>(() => {
|
|
274
|
+
if (!barVisible) return []
|
|
275
|
+
const y0 = bottomYScale(0)
|
|
276
|
+
const bars: HistogramBar[] = []
|
|
277
|
+
for (const d of data) {
|
|
278
|
+
const v = getBar(d)
|
|
279
|
+
if (v === null || v === undefined || Number.isNaN(v)) continue
|
|
280
|
+
const cx = xScale(getX(d)) ?? 0
|
|
281
|
+
const yVal = bottomYScale(v)
|
|
282
|
+
bars.push({
|
|
283
|
+
key: `bar-${getX(d)}`,
|
|
284
|
+
x: cx - barWidth / 2,
|
|
285
|
+
y: Math.min(y0, yVal),
|
|
286
|
+
width: barWidth,
|
|
287
|
+
height: Math.max(Math.abs(yVal - y0), 1),
|
|
288
|
+
fill: v >= 0 ? barColorPositive : barColorNegative,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
return bars
|
|
292
|
+
}, [
|
|
293
|
+
barVisible,
|
|
294
|
+
data,
|
|
295
|
+
getBar,
|
|
296
|
+
getX,
|
|
297
|
+
xScale,
|
|
298
|
+
bottomYScale,
|
|
299
|
+
barWidth,
|
|
300
|
+
barColorPositive,
|
|
301
|
+
barColorNegative,
|
|
302
|
+
])
|
|
303
|
+
|
|
215
304
|
type FillPt = { __d: T; __from: number; __to: number }
|
|
216
305
|
const fillPts = useMemo<FillPt[]>(() => {
|
|
217
306
|
if (!fillBetween) return []
|
|
218
|
-
const fromLine =
|
|
219
|
-
const toLine =
|
|
307
|
+
const fromLine = visibleSeries.find((s) => s.key === fillBetween.from)
|
|
308
|
+
const toLine = visibleSeries.find((s) => s.key === fillBetween.to)
|
|
220
309
|
if (!fromLine || !toLine) return []
|
|
221
310
|
const out: FillPt[] = []
|
|
222
311
|
for (const d of data) {
|
|
@@ -227,14 +316,14 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
227
316
|
out.push({ __d: d, __from: f, __to: t })
|
|
228
317
|
}
|
|
229
318
|
return out
|
|
230
|
-
}, [data, fillBetween,
|
|
319
|
+
}, [data, fillBetween, visibleSeries])
|
|
231
320
|
|
|
232
321
|
type LinePt = { __d: T; __y: number }
|
|
233
|
-
// Per-line valid points, computed once per (data,
|
|
234
|
-
// every paint (parity with MultiLine's seriesPts).
|
|
322
|
+
// Per-line valid points, computed once per (data, visibleSeries) — not re-walked inside the
|
|
323
|
+
// render map every paint (parity with MultiLine's seriesPts).
|
|
235
324
|
const lineValid = useMemo(() => {
|
|
236
325
|
const out = new Map<string, LinePt[]>()
|
|
237
|
-
for (const s of
|
|
326
|
+
for (const s of visibleSeries) {
|
|
238
327
|
const pts: LinePt[] = []
|
|
239
328
|
for (const d of data) {
|
|
240
329
|
const v = s.getValue(d)
|
|
@@ -243,16 +332,17 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
243
332
|
out.set(s.key, pts)
|
|
244
333
|
}
|
|
245
334
|
return out
|
|
246
|
-
}, [data,
|
|
335
|
+
}, [data, visibleSeries])
|
|
247
336
|
|
|
248
|
-
const
|
|
249
|
-
const
|
|
337
|
+
const point = cursor.point
|
|
338
|
+
const sx = point !== null ? (xScale(getX(point)) ?? 0) : 0
|
|
339
|
+
const syncedBar = point !== null ? getBar(point) : null
|
|
250
340
|
|
|
251
341
|
return (
|
|
252
342
|
<>
|
|
253
343
|
<svg width={plot.width} height={plot.height}>
|
|
254
344
|
{/* Top pane: line series + fill-between + zones + ref lines. */}
|
|
255
|
-
<Group left={
|
|
345
|
+
<Group left={margin.left} top={margin.top}>
|
|
256
346
|
<GridRows scale={topYScale} width={xMax} stroke={VX.grid} numTicks={4} />
|
|
257
347
|
|
|
258
348
|
<ZoneRects zones={topZones} width={xMax} leftScale={topYScale} />
|
|
@@ -284,7 +374,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
284
374
|
/>
|
|
285
375
|
))}
|
|
286
376
|
|
|
287
|
-
{
|
|
377
|
+
{visibleSeries.map((s) => {
|
|
288
378
|
const valid = lineValid.get(s.key) ?? []
|
|
289
379
|
if (valid.length === 0) return null
|
|
290
380
|
return (
|
|
@@ -302,10 +392,10 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
302
392
|
})}
|
|
303
393
|
|
|
304
394
|
{/* Dots only — the crosshair LINE is the single continuous span drawn below. */}
|
|
305
|
-
{
|
|
395
|
+
{point !== null && (
|
|
306
396
|
<>
|
|
307
|
-
{
|
|
308
|
-
const v = s.getValue(
|
|
397
|
+
{visibleSeries.map((s) => {
|
|
398
|
+
const v = s.getValue(point)
|
|
309
399
|
if (v === null || v === undefined || Number.isNaN(v)) return null
|
|
310
400
|
return (
|
|
311
401
|
<SeriesDot key={`top-dot-${s.key}`} cx={sx} cy={topYScale(v)} color={s.color} />
|
|
@@ -314,20 +404,28 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
314
404
|
</>
|
|
315
405
|
)}
|
|
316
406
|
|
|
407
|
+
{/* theme-allow basalt/hand-rolled-plot: TWO panes over one x scale is not a single
|
|
408
|
+
cartesian plot, so `CartesianChart` (one plot rect, one or two y axes) cannot express
|
|
409
|
+
it. This file therefore assembles the plot itself — but from the SAME parts every
|
|
410
|
+
other chart gets: `ChartFrame`, `autoMargin` + `probeAxisLabels`, `useChartCursor`,
|
|
411
|
+
`ChartTooltipFloat`. If a second multi-pane kind ever appears, promote the shared
|
|
412
|
+
choreography rather than copying this file. */}
|
|
317
413
|
<AxisLeftNumeric scale={topYScale} numTicks={4} tickFormat={formatTop} />
|
|
318
|
-
{/* One HoverOverlay per pane, both driving the SAME
|
|
414
|
+
{/* One HoverOverlay per pane, both driving the SAME shared cursor — snap-to-nearest is
|
|
319
415
|
x-only, so either overlay yields the same point. The top overlay extends over the gutter
|
|
320
416
|
(height = topH + PANE_GAP) so there's no dead zone between the panes. */}
|
|
321
417
|
<HoverOverlay
|
|
322
418
|
width={xMax}
|
|
323
419
|
height={topH + PANE_GAP}
|
|
324
|
-
onMove={
|
|
325
|
-
onLeave={
|
|
420
|
+
onMove={cursor.onPointerMove}
|
|
421
|
+
onLeave={cursor.onPointerLeave}
|
|
422
|
+
onKeyDown={cursor.onKeyDown}
|
|
423
|
+
onBlur={cursor.onBlur}
|
|
326
424
|
/>
|
|
327
425
|
</Group>
|
|
328
426
|
|
|
329
427
|
{/* Bottom pane: signed histogram around a zero baseline. */}
|
|
330
|
-
<Group left={
|
|
428
|
+
<Group left={margin.left} top={margin.top + bottomTop}>
|
|
331
429
|
<line
|
|
332
430
|
x1={0}
|
|
333
431
|
x2={xMax}
|
|
@@ -337,80 +435,77 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
337
435
|
strokeWidth={1}
|
|
338
436
|
/>
|
|
339
437
|
|
|
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
|
-
})}
|
|
438
|
+
{histogramBars.map((b) => (
|
|
439
|
+
<Bar
|
|
440
|
+
key={b.key}
|
|
441
|
+
x={b.x}
|
|
442
|
+
y={b.y}
|
|
443
|
+
width={b.width}
|
|
444
|
+
height={b.height}
|
|
445
|
+
fill={b.fill}
|
|
446
|
+
fillOpacity={0.7}
|
|
447
|
+
rx={1.4}
|
|
448
|
+
/>
|
|
449
|
+
))}
|
|
359
450
|
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
451
|
+
{barVisible &&
|
|
452
|
+
point !== null &&
|
|
453
|
+
syncedBar !== null &&
|
|
454
|
+
syncedBar !== undefined &&
|
|
455
|
+
!Number.isNaN(syncedBar) && (
|
|
456
|
+
<SeriesDot
|
|
457
|
+
cx={sx}
|
|
458
|
+
cy={bottomYScale(syncedBar)}
|
|
459
|
+
color={syncedBar >= 0 ? barColorPositive : barColorNegative}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
371
462
|
|
|
372
463
|
<AxisLeftNumeric scale={bottomYScale} numTicks={3} tickFormat={formatBottom} />
|
|
373
464
|
<AxisBottomDate top={bottomH} scale={xScale} tickValues={tickValues} />
|
|
465
|
+
{/* Pointer only — deliberately NO `onKeyDown`, which is what makes an overlay focusable.
|
|
466
|
+
Both panes scrub the same single x cursor, so a second tab stop would be a second
|
|
467
|
+
control for one logical axis: a keyboard user would tab twice through one chart and
|
|
468
|
+
find the arrow keys doing the same thing. The top overlay owns the keyboard. */}
|
|
374
469
|
<HoverOverlay
|
|
375
470
|
width={xMax}
|
|
376
|
-
height={bottomH +
|
|
377
|
-
onMove={
|
|
378
|
-
onLeave={
|
|
471
|
+
height={bottomH + margin.bottom}
|
|
472
|
+
onMove={cursor.onPointerMove}
|
|
473
|
+
onLeave={cursor.onPointerLeave}
|
|
379
474
|
/>
|
|
380
475
|
</Group>
|
|
381
476
|
|
|
382
477
|
{/* Continuous crosshair spanning the gutter between panes. */}
|
|
383
|
-
{
|
|
384
|
-
<Group left={
|
|
478
|
+
{point !== null && (
|
|
479
|
+
<Group left={margin.left} top={margin.top}>
|
|
385
480
|
<Crosshair x={sx} top={0} bottom={innerH} />
|
|
386
481
|
</Group>
|
|
387
482
|
)}
|
|
388
483
|
</svg>
|
|
389
484
|
|
|
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(
|
|
485
|
+
{cursor.isSource && point !== null && (
|
|
486
|
+
<ChartTooltipFloat anchor={cursor.anchor}>
|
|
487
|
+
<TooltipHeader
|
|
488
|
+
date={getX(point)}
|
|
489
|
+
{...(() => {
|
|
490
|
+
const lbl = tooltipLabel?.(point) ?? null
|
|
491
|
+
return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
|
|
492
|
+
})()}
|
|
493
|
+
/>
|
|
494
|
+
<TooltipBody>
|
|
495
|
+
{deriveTooltipRows(visibleSeries, point, formatTop).map((row) => (
|
|
496
|
+
<TooltipRow
|
|
497
|
+
key={row.key}
|
|
498
|
+
color={row.color}
|
|
499
|
+
label={row.label}
|
|
500
|
+
value={row.value}
|
|
501
|
+
shape={row.shape}
|
|
502
|
+
dashed={row.dashed}
|
|
503
|
+
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
504
|
+
/>
|
|
505
|
+
))}
|
|
506
|
+
{barVisible &&
|
|
507
|
+
(() => {
|
|
508
|
+
const v = getBar(point)
|
|
414
509
|
if (v === null || v === undefined || Number.isNaN(v)) return null
|
|
415
510
|
return (
|
|
416
511
|
<TooltipRow
|
|
@@ -421,10 +516,9 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
|
|
|
421
516
|
/>
|
|
422
517
|
)
|
|
423
518
|
})()}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
</ChartTooltip>
|
|
519
|
+
</TooltipBody>
|
|
520
|
+
</ChartTooltipFloat>
|
|
521
|
+
)}
|
|
428
522
|
</>
|
|
429
523
|
)
|
|
430
524
|
}
|