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.
Files changed (176) 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 +279 -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/agent/templates/CLAUDE-block.md.tpl +9 -6
  9. package/configs/oxlint-plugin.js +152 -0
  10. package/configs/oxlint-plugin.test.ts +107 -0
  11. package/configs/oxlint.json +2 -0
  12. package/dist/charts/cursor/resolve.d.ts +33 -0
  13. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  14. package/dist/charts/cursor/resolve.js +61 -0
  15. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  16. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  17. package/dist/charts/cursor/scope.d.ts +17 -0
  18. package/dist/charts/cursor/scope.d.ts.map +1 -0
  19. package/dist/charts/cursor/scope.js +20 -0
  20. package/dist/charts/cursor/store.d.ts +31 -0
  21. package/dist/charts/cursor/store.d.ts.map +1 -0
  22. package/dist/charts/cursor/store.js +25 -0
  23. package/dist/charts/cursor/store.test.d.ts +2 -0
  24. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  26. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  27. package/dist/charts/hooks/useChartCursor.js +104 -0
  28. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  29. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  30. package/dist/charts/index.d.ts +16 -13
  31. package/dist/charts/index.d.ts.map +1 -1
  32. package/dist/charts/index.js +38 -19
  33. package/dist/charts/kinds/Bars.d.ts +45 -54
  34. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  35. package/dist/charts/kinds/Bars.js +235 -392
  36. package/dist/charts/kinds/Bars.test.d.ts +2 -0
  37. package/dist/charts/kinds/Bars.test.d.ts.map +1 -0
  38. package/dist/charts/kinds/Donut.d.ts +5 -4
  39. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  40. package/dist/charts/kinds/Donut.js +17 -14
  41. package/dist/charts/kinds/DualPanel.d.ts +13 -0
  42. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  43. package/dist/charts/kinds/DualPanel.js +185 -92
  44. package/dist/charts/kinds/DualPanel.test.d.ts +2 -0
  45. package/dist/charts/kinds/DualPanel.test.d.ts.map +1 -0
  46. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  47. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  48. package/dist/charts/kinds/Heatmap.js +38 -22
  49. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  50. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/MultiLine.js +122 -235
  52. package/dist/charts/kinds/MultiLine.test.d.ts +2 -0
  53. package/dist/charts/kinds/MultiLine.test.d.ts.map +1 -0
  54. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  55. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  56. package/dist/charts/kinds/StackedArea.js +74 -132
  57. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  58. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  59. package/dist/charts/kinds/ZonedLine.js +104 -184
  60. package/dist/charts/kinds/ZonedLine.test.d.ts +2 -0
  61. package/dist/charts/kinds/ZonedLine.test.d.ts.map +1 -0
  62. package/dist/charts/layout/auto-margin.d.ts +60 -0
  63. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  64. package/dist/charts/layout/auto-margin.js +37 -0
  65. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  66. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  67. package/dist/charts/primitives/CartesianChart.d.ts +161 -0
  68. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  69. package/dist/charts/primitives/CartesianChart.js +359 -0
  70. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  71. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  72. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  73. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  74. package/dist/charts/primitives/ChartFrame.js +17 -2
  75. package/dist/charts/primitives/ChartLegend.d.ts +15 -1
  76. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  77. package/dist/charts/primitives/ChartLegend.js +25 -3
  78. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  79. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  80. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  81. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  82. package/dist/charts/primitives/ChartTooltip.js +45 -19
  83. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  84. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  85. package/dist/charts/primitives/HoverOverlay.js +22 -1
  86. package/dist/charts/primitives/XZoneRects.d.ts +54 -0
  87. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  88. package/dist/charts/primitives/XZoneRects.js +65 -0
  89. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  90. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  91. package/dist/charts/series.d.ts +28 -3
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/series.js +5 -2
  94. package/dist/charts/series.test.d.ts +2 -0
  95. package/dist/charts/series.test.d.ts.map +1 -0
  96. package/dist/charts/utils/measure-text.d.ts +25 -0
  97. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  98. package/dist/charts/utils/measure-text.js +64 -0
  99. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  100. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts.map +1 -1
  102. package/dist/surfaces.d.ts +1 -1
  103. package/dist/surfaces.d.ts.map +1 -1
  104. package/dist/surfaces.js +1 -1
  105. package/dist/tokens/chart-margin.test.d.ts +2 -0
  106. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  107. package/dist/tokens/index.d.ts +29 -0
  108. package/dist/tokens/index.d.ts.map +1 -1
  109. package/dist/tokens/index.js +10 -0
  110. package/dist/tokens/palette.d.ts +4 -0
  111. package/dist/tokens/palette.d.ts.map +1 -1
  112. package/dist/tokens/palette.js +3 -0
  113. package/llms.txt +3 -3
  114. package/package.json +1 -1
  115. package/src/charts/cursor/resolve.test.ts +61 -0
  116. package/src/charts/cursor/resolve.ts +102 -0
  117. package/src/charts/cursor/scope.tsx +32 -0
  118. package/src/charts/cursor/store.test.ts +46 -0
  119. package/src/charts/cursor/store.ts +53 -0
  120. package/src/charts/hooks/useChartCursor.ts +163 -0
  121. package/src/charts/hooks/useChartSize.ts +8 -4
  122. package/src/charts/index.ts +35 -14
  123. package/src/charts/kinds/Bars.test.tsx +98 -0
  124. package/src/charts/kinds/Bars.tsx +350 -467
  125. package/src/charts/kinds/Donut.tsx +31 -23
  126. package/src/charts/kinds/DualPanel.test.tsx +131 -0
  127. package/src/charts/kinds/DualPanel.tsx +301 -135
  128. package/src/charts/kinds/Heatmap.tsx +72 -47
  129. package/src/charts/kinds/MultiLine.test.tsx +37 -0
  130. package/src/charts/kinds/MultiLine.tsx +171 -290
  131. package/src/charts/kinds/StackedArea.tsx +116 -175
  132. package/src/charts/kinds/ZonedLine.test.tsx +37 -0
  133. package/src/charts/kinds/ZonedLine.tsx +149 -255
  134. package/src/charts/layout/auto-margin.test.ts +42 -0
  135. package/src/charts/layout/auto-margin.ts +110 -0
  136. package/src/charts/primitives/CartesianChart.test.tsx +160 -0
  137. package/src/charts/primitives/CartesianChart.tsx +598 -0
  138. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  139. package/src/charts/primitives/ChartFrame.tsx +40 -4
  140. package/src/charts/primitives/ChartLegend.test.tsx +78 -0
  141. package/src/charts/primitives/ChartLegend.tsx +52 -2
  142. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  143. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  144. package/src/charts/primitives/XZoneRects.test.tsx +123 -0
  145. package/src/charts/primitives/XZoneRects.tsx +132 -0
  146. package/src/charts/series.test.ts +112 -0
  147. package/src/charts/series.ts +33 -5
  148. package/src/charts/utils/measure-text.test.ts +36 -0
  149. package/src/charts/utils/measure-text.ts +109 -0
  150. package/src/content/fence-block.tsx +2 -3
  151. package/src/surfaces.ts +2 -1
  152. package/src/theme/spacing.test.ts +2 -0
  153. package/src/tokens/chart-margin.test.ts +39 -0
  154. package/src/tokens/density.test.ts +1 -0
  155. package/src/tokens/index.ts +36 -0
  156. package/src/tokens/palette.ts +3 -0
  157. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  158. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  159. package/dist/charts/hooks/useChartTooltip.js +0 -26
  160. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  161. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  162. package/dist/charts/hooks/useHoverSync.js +0 -75
  163. package/dist/charts/hover-context.d.ts +0 -21
  164. package/dist/charts/hover-context.d.ts.map +0 -1
  165. package/dist/charts/hover-context.js +0 -12
  166. package/dist/charts/hover-sync.d.ts +0 -14
  167. package/dist/charts/hover-sync.d.ts.map +0 -1
  168. package/dist/charts/hover-sync.js +0 -17
  169. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  170. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  171. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  172. package/src/charts/hooks/useChartTooltip.ts +0 -31
  173. package/src/charts/hooks/useHoverSync.ts +0 -152
  174. package/src/charts/hover-context.ts +0 -28
  175. package/src/charts/hover-sync.tsx +0 -37
  176. 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
- ChartTooltip,
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 { useHoverSync } from '../hooks/useHoverSync'
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/hover-sync hooks only run
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 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
- }
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 series) {
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 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
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 > maxAbs) maxAbs = a
214
+ if (a > m) m = a
191
215
  }
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
- },
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 = series.find((s) => s.key === fillBetween.from)
219
- const toLine = series.find((s) => s.key === fillBetween.to)
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, series])
352
+ }, [data, fillBetween, visibleSeries])
231
353
 
232
354
  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).
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 series) {
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, series])
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 sx = syncedPoint ? (xScale(getX(syncedPoint)) ?? 0) : 0
249
- const syncedBar = syncedPoint ? getBar(syncedPoint) : null
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={MARGIN.left} top={MARGIN.top}>
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
- {series.map((s) => {
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
- {syncedPoint && (
465
+ {point !== null && (
306
466
  <>
307
- {series.map((s) => {
308
- const v = s.getValue(syncedPoint)
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 useHoverSync — snap-to-nearest is
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={handleMouse}
325
- onLeave={handleLeave}
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={MARGIN.left} top={MARGIN.top + bottomTop}>
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
- {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
- })}
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
- {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
- )}
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 + MARGIN.bottom}
377
- onMove={handleMouse}
378
- onLeave={handleLeave}
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
- {syncedPoint && (
384
- <Group left={MARGIN.left} top={MARGIN.top}>
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
- <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)
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={formatBottom(v)}
586
+ value={value}
420
587
  shape="bar"
421
588
  />
422
589
  )
423
590
  })()}
424
- </TooltipBody>
425
- </>
426
- )}
427
- </ChartTooltip>
591
+ </TooltipBody>
592
+ </ChartTooltipFloat>
593
+ )}
428
594
  </>
429
595
  )
430
596
  }