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.
Files changed (180) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/shell/app-sidebar.d.ts +19 -1
  93. package/dist/shell/app-sidebar.d.ts.map +1 -1
  94. package/dist/shell/app-sidebar.js +30 -26
  95. package/dist/shell/app-sidebar.module.css +11 -0
  96. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  97. package/dist/shell/app-sidebar.test.d.ts +2 -0
  98. package/dist/shell/app-sidebar.test.d.ts.map +1 -0
  99. package/dist/shell/index.d.ts +10 -1
  100. package/dist/shell/index.d.ts.map +1 -1
  101. package/dist/shell/index.js +2 -0
  102. package/dist/shell/index.test.d.ts +2 -0
  103. package/dist/shell/index.test.d.ts.map +1 -0
  104. package/dist/surfaces.d.ts +1 -1
  105. package/dist/surfaces.d.ts.map +1 -1
  106. package/dist/surfaces.js +1 -1
  107. package/dist/tokens/chart-margin.test.d.ts +2 -0
  108. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  109. package/dist/tokens/index.d.ts +29 -0
  110. package/dist/tokens/index.d.ts.map +1 -1
  111. package/dist/tokens/index.js +10 -0
  112. package/dist/tokens/palette.d.ts +4 -0
  113. package/dist/tokens/palette.d.ts.map +1 -1
  114. package/dist/tokens/palette.js +3 -0
  115. package/llms.txt +3 -3
  116. package/package.json +1 -1
  117. package/src/charts/cursor/resolve.test.ts +61 -0
  118. package/src/charts/cursor/resolve.ts +102 -0
  119. package/src/charts/cursor/scope.tsx +32 -0
  120. package/src/charts/cursor/store.test.ts +46 -0
  121. package/src/charts/cursor/store.ts +53 -0
  122. package/src/charts/hooks/useChartCursor.ts +163 -0
  123. package/src/charts/hooks/useChartSize.ts +8 -4
  124. package/src/charts/index.ts +34 -14
  125. package/src/charts/kinds/Bars.tsx +327 -460
  126. package/src/charts/kinds/Donut.tsx +31 -23
  127. package/src/charts/kinds/DualPanel.tsx +228 -134
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.tsx +158 -294
  130. package/src/charts/kinds/StackedArea.tsx +116 -175
  131. package/src/charts/kinds/ZonedLine.tsx +148 -255
  132. package/src/charts/layout/auto-margin.test.ts +42 -0
  133. package/src/charts/layout/auto-margin.ts +110 -0
  134. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  135. package/src/charts/primitives/CartesianChart.tsx +566 -0
  136. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  137. package/src/charts/primitives/ChartFrame.tsx +40 -4
  138. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  139. package/src/charts/primitives/ChartLegend.tsx +45 -2
  140. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  141. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  142. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  143. package/src/charts/primitives/XZoneRects.tsx +67 -0
  144. package/src/charts/series.test.ts +61 -0
  145. package/src/charts/series.ts +18 -1
  146. package/src/charts/utils/measure-text.test.ts +36 -0
  147. package/src/charts/utils/measure-text.ts +109 -0
  148. package/src/content/fence-block.tsx +2 -3
  149. package/src/shell/app-sidebar.module.css +11 -0
  150. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  151. package/src/shell/app-sidebar.test.tsx +98 -0
  152. package/src/shell/app-sidebar.tsx +22 -0
  153. package/src/shell/index.test.tsx +35 -0
  154. package/src/shell/index.tsx +11 -0
  155. package/src/surfaces.ts +2 -1
  156. package/src/theme/spacing.test.ts +2 -0
  157. package/src/tokens/chart-margin.test.ts +39 -0
  158. package/src/tokens/density.test.ts +1 -0
  159. package/src/tokens/index.ts +36 -0
  160. package/src/tokens/palette.ts +3 -0
  161. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  162. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  163. package/dist/charts/hooks/useChartTooltip.js +0 -26
  164. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  165. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  166. package/dist/charts/hooks/useHoverSync.js +0 -75
  167. package/dist/charts/hover-context.d.ts +0 -21
  168. package/dist/charts/hover-context.d.ts.map +0 -1
  169. package/dist/charts/hover-context.js +0 -12
  170. package/dist/charts/hover-sync.d.ts +0 -14
  171. package/dist/charts/hover-sync.d.ts.map +0 -1
  172. package/dist/charts/hover-sync.js +0 -17
  173. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  174. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  175. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  176. package/src/charts/hooks/useChartTooltip.ts +0 -31
  177. package/src/charts/hooks/useHoverSync.ts +0 -152
  178. package/src/charts/hover-context.ts +0 -28
  179. package/src/charts/hover-sync.tsx +0 -37
  180. 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 { ChartTooltip, TooltipBody, TooltipRow, useTooltipStyles } from '../primitives/ChartTooltip'
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. No crosshair — meaningless for a radial layout.
64
- * Hover stays local to the pie (dimming siblings on hover) rather than joining the shared
65
- * `HoverContext`: a date-keyed cursor has no counterpart on a donut, and cross-kind category sync
66
- * (donut ↔ bar, via the generalized key) is a distinct, deliberately deferred feature.
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 tooltipStyles = useTooltipStyles()
114
+ const [tip, setTip] = useState<DonutTip<K> | null>(null)
115
+ const hoveredKey = tip?.key ?? null
112
116
 
113
- const { tip, show, hide, tooltipRef } = useChartTooltip<DonutDatum<K>>()
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(() => data.reduce((sum, d) => sum + d.value, 0), [data])
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={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
- <ChartTooltip tip={tip} tooltipRef={tooltipRef} styles={tooltipStyles}>
214
- {tip && (
221
+ {tip !== null && (
222
+ <ChartTooltipFloat anchor={tip.anchor}>
215
223
  <TooltipBody>
216
224
  <TooltipRow
217
- color={colorForKey(tip.data.key)}
218
- label={seriesLabel(tip.data.key)}
219
- value={formatValue(tip.data.value)}
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.data.value / total) * 100) : 0}%`}
233
+ value={`${total > 0 ? Math.round((tip.value / total) * 100) : 0}%`}
226
234
  shape="bar"
227
235
  />
228
236
  </TooltipBody>
229
- )}
230
- </ChartTooltip>
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
- ChartTooltip,
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 { useHoverSync } from '../hooks/useHoverSync'
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/hover-sync hooks only run
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 MARGIN = VX.margin
143
- const xMax = plot.width - MARGIN.left - MARGIN.right
144
- // Inner plot height shared by both panes (excludes top/bottom margin + gutter).
145
- const plotH = plot.height - MARGIN.top - MARGIN.bottom - PANE_GAP
146
- const topH = Math.max(Math.round(plotH * topFraction), 1)
147
- const bottomH = Math.max(plotH - topH, 1)
148
- const bottomTop = topH + PANE_GAP
149
- // Full inner span — the synced crosshair line covers BOTH panes.
150
- const innerH = topH + PANE_GAP + bottomH
151
-
152
- const xScale = useMemo(
153
- () => scalePoint<string>({ domain: data.map(getX), range: [0, xMax], padding: 0.3 }),
154
- [data, xMax, getX],
155
- )
156
-
157
- const topYScale = useMemo(() => {
158
- if (topYDomain !== 'auto') {
159
- return scaleLinear<number>({ domain: topYDomain, range: [topH, 0] })
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 series) {
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 scaleLinear<number>({
177
- domain: [lo - span * 0.08, hi + span * 0.08],
178
- range: [topH, 0],
179
- nice: true,
180
- })
181
- }, [data, series, topYDomain, topH])
182
-
183
- // Bottom pane: symmetric signed scale around zero.
184
- const bottomYScale = useMemo(() => {
185
- let maxAbs = 0
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 > maxAbs) maxAbs = a
194
+ if (a > m) m = a
191
195
  }
192
- if (maxAbs === 0) maxAbs = 1
193
- return scaleLinear<number>({
194
- domain: [-maxAbs, maxAbs],
195
- range: [bottomH, 0],
196
- nice: true,
197
- })
198
- }, [data, getBar, bottomH])
199
-
200
- const tooltipStyles = useTooltipStyles()
201
- const { tip, tooltipRef, syncedPoint, isDirectHover, handleMouse, handleLeave } = useHoverSync<T>(
202
- {
203
- data,
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 = series.find((s) => s.key === fillBetween.from)
219
- const toLine = series.find((s) => s.key === fillBetween.to)
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, series])
319
+ }, [data, fillBetween, visibleSeries])
231
320
 
232
321
  type LinePt = { __d: T; __y: number }
233
- // Per-line valid points, computed once per (data, series) — not re-walked inside the render map
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 series) {
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, series])
335
+ }, [data, visibleSeries])
247
336
 
248
- const sx = syncedPoint ? (xScale(getX(syncedPoint)) ?? 0) : 0
249
- const syncedBar = syncedPoint ? getBar(syncedPoint) : null
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={MARGIN.left} top={MARGIN.top}>
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
- {series.map((s) => {
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
- {syncedPoint && (
395
+ {point !== null && (
306
396
  <>
307
- {series.map((s) => {
308
- const v = s.getValue(syncedPoint)
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 useHoverSync — snap-to-nearest is
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={handleMouse}
325
- onLeave={handleLeave}
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={MARGIN.left} top={MARGIN.top + bottomTop}>
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
- {data.map((d) => {
341
- const v = getBar(d)
342
- if (v === null || v === undefined || Number.isNaN(v)) return null
343
- const cx = xScale(getX(d)) ?? 0
344
- const y0 = bottomYScale(0)
345
- const yVal = bottomYScale(v)
346
- return (
347
- <Bar
348
- key={`bar-${getX(d)}`}
349
- x={cx - barWidth / 2}
350
- y={Math.min(y0, yVal)}
351
- width={barWidth}
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
- {syncedPoint && (
361
- <>
362
- {syncedBar !== null && syncedBar !== undefined && !Number.isNaN(syncedBar) && (
363
- <SeriesDot
364
- cx={sx}
365
- cy={bottomYScale(syncedBar)}
366
- color={syncedBar >= 0 ? barColorPositive : barColorNegative}
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 + MARGIN.bottom}
377
- onMove={handleMouse}
378
- onLeave={handleLeave}
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
- {syncedPoint && (
384
- <Group left={MARGIN.left} top={MARGIN.top}>
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
- <ChartTooltip tip={isDirectHover ? tip : null} tooltipRef={tooltipRef} styles={tooltipStyles}>
391
- {tip && isDirectHover && (
392
- <>
393
- <TooltipHeader
394
- date={getX(tip.data)}
395
- {...(() => {
396
- const lbl = tooltipLabel?.(tip.data) ?? null
397
- return lbl !== null ? { label: lbl.text, labelColor: lbl.color } : {}
398
- })()}
399
- />
400
- <TooltipBody>
401
- {deriveTooltipRows(series, tip.data, formatTop).map((row) => (
402
- <TooltipRow
403
- key={row.key}
404
- color={row.color}
405
- label={row.label}
406
- value={row.value}
407
- shape={row.shape}
408
- dashed={row.dashed}
409
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
410
- />
411
- ))}
412
- {(() => {
413
- const v = getBar(tip.data)
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
- </TooltipBody>
425
- </>
426
- )}
427
- </ChartTooltip>
519
+ </TooltipBody>
520
+ </ChartTooltipFloat>
521
+ )}
428
522
  </>
429
523
  )
430
524
  }