basalt-ui 1.14.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 +8 -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/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/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,4 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { useCallback, useState } from 'react'
|
|
2
3
|
import { useChartSize } from '../hooks/useChartSize'
|
|
3
4
|
import { deriveLegend } from '../series'
|
|
4
5
|
import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
|
|
@@ -20,6 +21,13 @@ export type ChartFrameLegend = {
|
|
|
20
21
|
/** Hover-dim wiring lifted from the kind — optional. */
|
|
21
22
|
highlighted?: string | null
|
|
22
23
|
onHighlight?: (key: string | null) => void
|
|
24
|
+
/**
|
|
25
|
+
* Clicking a legend entry hides that series. Defaults to true whenever there is more than one
|
|
26
|
+
* entry to toggle between — hiding the only series a chart draws is never useful. The hidden
|
|
27
|
+
* key set reaches the marks through the `children` render prop, so a hidden series leaves the
|
|
28
|
+
* plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
|
|
29
|
+
*/
|
|
30
|
+
toggle?: boolean
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export type ChartFrameProps = {
|
|
@@ -50,8 +58,16 @@ export type ChartFrameProps = {
|
|
|
50
58
|
* kind composing `ChartFrame` should accept and forward this from its own props.
|
|
51
59
|
*/
|
|
52
60
|
ariaLabel?: string
|
|
53
|
-
/** Draw the SVG marks given the plot rect that already excludes the legend band
|
|
54
|
-
|
|
61
|
+
/** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
|
|
62
|
+
* of series keys the legend has toggled off. */
|
|
63
|
+
children: (plot: PlotRect) => ReactNode
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What `ChartFrame` hands its child: the resolved plot rect plus legend-toggle state. */
|
|
67
|
+
export type PlotRect = {
|
|
68
|
+
width: number
|
|
69
|
+
height: number
|
|
70
|
+
hidden: ReadonlySet<string>
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
|
|
@@ -89,6 +105,7 @@ export function resolveLegend(
|
|
|
89
105
|
placement: config?.placement ?? 'bottom',
|
|
90
106
|
...(config?.groups !== undefined && { groups: config.groups }),
|
|
91
107
|
...(config?.maxRows !== undefined && { maxRows: config.maxRows }),
|
|
108
|
+
...(config?.toggle !== undefined && { toggle: config.toggle }),
|
|
92
109
|
...(hover !== undefined && { highlighted: hover.highlighted, onHighlight: hover.onHighlight }),
|
|
93
110
|
}
|
|
94
111
|
}
|
|
@@ -119,6 +136,16 @@ export function ChartFrame({
|
|
|
119
136
|
}: ChartFrameProps): ReactNode {
|
|
120
137
|
const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
|
|
121
138
|
const { ref: legendRef, width: legendW, height: legendH } = useChartSize()
|
|
139
|
+
const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
|
|
140
|
+
|
|
141
|
+
const toggleKey = useCallback((key: string) => {
|
|
142
|
+
setHidden((prev) => {
|
|
143
|
+
const next = new Set(prev)
|
|
144
|
+
if (next.has(key)) next.delete(key)
|
|
145
|
+
else next.add(key)
|
|
146
|
+
return next
|
|
147
|
+
})
|
|
148
|
+
}, [])
|
|
122
149
|
|
|
123
150
|
const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
|
|
124
151
|
const vertical = placement === 'left' || placement === 'right'
|
|
@@ -138,12 +165,17 @@ export function ChartFrame({
|
|
|
138
165
|
height: resolvedHeight - topBottomLegendHeight,
|
|
139
166
|
}
|
|
140
167
|
|
|
168
|
+
const legendItems = legend === false ? [] : deriveLegend(series)
|
|
169
|
+
const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
|
|
170
|
+
|
|
141
171
|
const legendNode =
|
|
142
172
|
legend === false || isPending ? null : (
|
|
143
173
|
<div ref={legendRef} style={legendWrapperStyle(vertical)}>
|
|
144
174
|
<ChartLegend
|
|
145
|
-
items={
|
|
175
|
+
items={legendItems}
|
|
146
176
|
placement={placement}
|
|
177
|
+
hidden={hidden}
|
|
178
|
+
{...(togglable && { onToggle: toggleKey })}
|
|
147
179
|
{...(chartId !== undefined && { chartId })}
|
|
148
180
|
{...(legend.groups !== undefined && { groups: legend.groups })}
|
|
149
181
|
{...(legend.maxRows !== undefined && { maxRows: legend.maxRows })}
|
|
@@ -163,7 +195,11 @@ export function ChartFrame({
|
|
|
163
195
|
{legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
|
|
164
196
|
{plot.width > 0 &&
|
|
165
197
|
plot.height > 0 &&
|
|
166
|
-
(isPending ?
|
|
198
|
+
(isPending ? (
|
|
199
|
+
<ChartPending width={plot.width} height={plot.height} />
|
|
200
|
+
) : (
|
|
201
|
+
children({ ...plot, hidden })
|
|
202
|
+
))}
|
|
167
203
|
{legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
|
|
168
204
|
</div>
|
|
169
205
|
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChartLegend` — the `note` qualifier. Same SSR harness as `ChartFrame.test.tsx`:
|
|
3
|
+
* `src/charts/**` is Mantine-free, so `renderToStaticMarkup` needs no provider wrapper.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
import { ChartLegend } from './ChartLegend'
|
|
8
|
+
import type { LegendEntry } from './ChartLegend'
|
|
9
|
+
|
|
10
|
+
const BASE: LegendEntry = { key: 'cloud-low', label: 'Low cloud', color: 'var(--vx-fill-1)' }
|
|
11
|
+
|
|
12
|
+
const render = (items: LegendEntry[]): string => renderToStaticMarkup(<ChartLegend items={items} />)
|
|
13
|
+
|
|
14
|
+
describe('ChartLegend note', () => {
|
|
15
|
+
test('renders after the label when set', () => {
|
|
16
|
+
const markup = render([{ ...BASE, note: '0% all night' }])
|
|
17
|
+
expect(markup).toContain('Low cloud')
|
|
18
|
+
expect(markup).toContain('0% all night')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('is absent when unset', () => {
|
|
22
|
+
expect(render([BASE])).not.toContain('<span style="opacity:0.75">')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('an empty note renders no span (truthiness, not !== undefined)', () => {
|
|
26
|
+
expect(render([{ ...BASE, note: '' }])).not.toContain('<span style="opacity:0.75">')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('reaches the accessible name — the explicit aria-label would otherwise replace it', () => {
|
|
30
|
+
const markup = render([{ ...BASE, note: '0% all night' }])
|
|
31
|
+
expect(markup).toContain('aria-label="Low cloud — 0% all night"')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('aria-label stays the bare label when there is no note', () => {
|
|
35
|
+
expect(render([BASE])).toContain('aria-label="Low cloud"')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('a folded companion carries its own note', () => {
|
|
39
|
+
const markup = render([
|
|
40
|
+
{
|
|
41
|
+
...BASE,
|
|
42
|
+
children: [{ key: 'ma', label: '7d MA', color: 'var(--vx-fill-2)', note: 'est.' }],
|
|
43
|
+
},
|
|
44
|
+
])
|
|
45
|
+
expect(markup).toContain('7d MA')
|
|
46
|
+
expect(markup).toContain('est.')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -18,6 +18,9 @@ export type LegendEntry = {
|
|
|
18
18
|
/** Companions folded under this entry via `parent` (`deriveLegend`) — rendered as compact,
|
|
19
19
|
* subordinate sub-entries beside the parent's label instead of vanishing. */
|
|
20
20
|
children?: LegendEntry[]
|
|
21
|
+
/** Short qualifier rendered after the label in muted text — e.g. a flat-at-zero series that is
|
|
22
|
+
* invisible in the plot. */
|
|
23
|
+
note?: string
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const LEGEND_ITEM_BASE: CSSProperties = {
|
|
@@ -44,6 +47,9 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
|
|
|
44
47
|
|
|
45
48
|
const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
|
|
46
49
|
|
|
50
|
+
/** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
|
|
51
|
+
const NO_HIDDEN: ReadonlySet<string> = new Set()
|
|
52
|
+
|
|
47
53
|
const roleOf = (item: LegendEntry): SeriesRole => item.role ?? 'series'
|
|
48
54
|
|
|
49
55
|
/** Order entries series → overlay → reference and record where a divider belongs. */
|
|
@@ -174,6 +180,13 @@ function LegendSwatch({ item, idPrefix }: { item: LegendEntry; idPrefix: string
|
|
|
174
180
|
)
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
// Same dimming as `LEGEND_CHILD_STYLE` below — the legend wrapper already sets the base text color
|
|
184
|
+
// to `VX.muted` (`wrapperStyle`), so subordinate text is just that color at reduced opacity, never
|
|
185
|
+
// a second color token.
|
|
186
|
+
const LEGEND_NOTE_STYLE: CSSProperties = {
|
|
187
|
+
opacity: 0.75,
|
|
188
|
+
}
|
|
189
|
+
|
|
177
190
|
const LEGEND_CHILD_STYLE: CSSProperties = {
|
|
178
191
|
display: 'flex',
|
|
179
192
|
alignItems: 'center',
|
|
@@ -221,6 +234,9 @@ function LegendChild({ item }: { item: LegendEntry }) {
|
|
|
221
234
|
<span style={LEGEND_CHILD_STYLE}>
|
|
222
235
|
<LegendChildSwatch item={item} />
|
|
223
236
|
<span>{item.label}</span>
|
|
237
|
+
{/* No LEGEND_NOTE_STYLE here — LEGEND_CHILD_STYLE already dims the whole child block, and
|
|
238
|
+
compounding the two opacities would push a folded companion's note near-invisible. */}
|
|
239
|
+
{item.note ? <span>{item.note}</span> : null}
|
|
224
240
|
</span>
|
|
225
241
|
)
|
|
226
242
|
}
|
|
@@ -232,11 +248,17 @@ function LegendChild({ item }: { item: LegendEntry }) {
|
|
|
232
248
|
* reference bands instead of one flat smear. `maxRows` caps the number of rendered entries and
|
|
233
249
|
* rolls the remainder into a `+N more` chip (row-accurate wrap measurement is `ChartFrame`'s job —
|
|
234
250
|
* this is an entry-count cap, a deliberate simplification until that primitive lands).
|
|
251
|
+
*
|
|
252
|
+
* `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
|
|
253
|
+
* and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
|
|
254
|
+
* §5). Omit it for a static legend; a hidden entry is announced via `aria-pressed`.
|
|
235
255
|
*/
|
|
236
256
|
export function ChartLegend({
|
|
237
257
|
items,
|
|
238
258
|
highlighted = null,
|
|
239
259
|
onHighlight,
|
|
260
|
+
hidden = NO_HIDDEN,
|
|
261
|
+
onToggle,
|
|
240
262
|
chartId = '',
|
|
241
263
|
placement = 'bottom',
|
|
242
264
|
groups = false,
|
|
@@ -245,6 +267,10 @@ export function ChartLegend({
|
|
|
245
267
|
items: LegendEntry[]
|
|
246
268
|
highlighted?: string | null
|
|
247
269
|
onHighlight?: (key: string | null) => void
|
|
270
|
+
/** Keys currently toggled off. */
|
|
271
|
+
hidden?: ReadonlySet<string>
|
|
272
|
+
/** Present = entries are clickable toggles. */
|
|
273
|
+
onToggle?: (key: string) => void
|
|
248
274
|
chartId?: string
|
|
249
275
|
placement?: LegendPlacement
|
|
250
276
|
groups?: boolean
|
|
@@ -255,6 +281,10 @@ export function ChartLegend({
|
|
|
255
281
|
if (key !== undefined) onHighlight?.(key)
|
|
256
282
|
}
|
|
257
283
|
const handleLeave = () => onHighlight?.(null)
|
|
284
|
+
const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
285
|
+
const key = e.currentTarget.dataset['legendKey']
|
|
286
|
+
if (key !== undefined) onToggle?.(key)
|
|
287
|
+
}
|
|
258
288
|
|
|
259
289
|
const idPrefix = chartId ? `${chartId}-` : ''
|
|
260
290
|
const vertical = placement === 'left' || placement === 'right'
|
|
@@ -270,11 +300,23 @@ export function ChartLegend({
|
|
|
270
300
|
key={item.key}
|
|
271
301
|
type="button"
|
|
272
302
|
data-legend-key={item.key}
|
|
273
|
-
|
|
303
|
+
// The note is the whole point for a series that is invisible in the plot, so it has to
|
|
304
|
+
// reach a screen reader too — an explicit aria-label would otherwise replace it.
|
|
305
|
+
aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
|
|
306
|
+
{...(onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
|
|
274
307
|
style={{
|
|
275
308
|
...LEGEND_ITEM_BUTTON,
|
|
276
|
-
|
|
309
|
+
cursor: onToggle === undefined ? 'default' : 'pointer',
|
|
310
|
+
// Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
|
|
311
|
+
// it is the one being hovered.
|
|
312
|
+
opacity: hidden.has(item.key)
|
|
313
|
+
? 0.35
|
|
314
|
+
: highlighted === null || highlighted === item.key
|
|
315
|
+
? 1
|
|
316
|
+
: 0.3,
|
|
317
|
+
textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
|
|
277
318
|
}}
|
|
319
|
+
{...(onToggle !== undefined && { onClick: handleToggle })}
|
|
278
320
|
onMouseEnter={handleEnter}
|
|
279
321
|
onMouseLeave={handleLeave}
|
|
280
322
|
onFocus={handleEnter}
|
|
@@ -282,6 +324,7 @@ export function ChartLegend({
|
|
|
282
324
|
>
|
|
283
325
|
<LegendSwatch item={item} idPrefix={idPrefix} />
|
|
284
326
|
<span>{item.label}</span>
|
|
327
|
+
{item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
|
|
285
328
|
{item.children?.map((child) => (
|
|
286
329
|
<LegendChild key={child.key} item={child} />
|
|
287
330
|
))}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { CSSProperties, ReactNode
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
3
|
import { createPortal } from 'react-dom'
|
|
3
4
|
import { VX } from '../../tokens'
|
|
4
5
|
import { fmtTooltipDate } from '../utils/format'
|
|
@@ -20,42 +21,6 @@ const TOOLTIP_STYLES: CSSProperties = {
|
|
|
20
21
|
minWidth: 140,
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/** Theme-aware tooltip container styles — use with useChartTooltip(). */
|
|
24
|
-
export function useTooltipStyles(): CSSProperties {
|
|
25
|
-
return TOOLTIP_STYLES
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Outer tooltip shell. Renders nothing when tip is null. Renders to `document.body` via a
|
|
30
|
-
* portal, so it may be authored anywhere in the tree, including inside `<svg>`.
|
|
31
|
-
*/
|
|
32
|
-
export function ChartTooltip({
|
|
33
|
-
tip,
|
|
34
|
-
tooltipRef,
|
|
35
|
-
styles,
|
|
36
|
-
children,
|
|
37
|
-
}: {
|
|
38
|
-
tip: { x: number; y: number } | null
|
|
39
|
-
tooltipRef?: RefObject<HTMLDivElement | null>
|
|
40
|
-
styles: CSSProperties
|
|
41
|
-
children: ReactNode
|
|
42
|
-
}) {
|
|
43
|
-
if (!tip) return null
|
|
44
|
-
// SSR guard: react-dom/server (renderToStaticMarkup, used by this repo's tests — see
|
|
45
|
-
// dashboard/stat-card.test.tsx) has no jsdom and does not support portals.
|
|
46
|
-
if (typeof document === 'undefined') return null
|
|
47
|
-
// Portal to document.body for two reasons: (1) authoring this inside an <svg> tree would
|
|
48
|
-
// otherwise create the div in the SVG namespace, where it mounts, accepts every prop, and
|
|
49
|
-
// is never painted; (2) TOOLTIP_STYLES' position: 'fixed' breaks inside any transformed
|
|
50
|
-
// ancestor unless the node is portalled out to the body.
|
|
51
|
-
return createPortal(
|
|
52
|
-
<div ref={tooltipRef} style={{ ...styles, left: tip.x, top: tip.y }}>
|
|
53
|
-
{children}
|
|
54
|
-
</div>,
|
|
55
|
-
document.body,
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
24
|
/** Tooltip header — shows formatted date + optional right-aligned label with color. */
|
|
60
25
|
export function TooltipHeader({
|
|
61
26
|
date,
|
|
@@ -168,3 +133,68 @@ export function TooltipRow({
|
|
|
168
133
|
export function TooltipBody({ children }: { children: ReactNode }) {
|
|
169
134
|
return <div style={{ padding: '5px 0' }}>{children}</div>
|
|
170
135
|
}
|
|
136
|
+
|
|
137
|
+
/** Gap between the anchor point and the tooltip box, and the minimum distance kept from the
|
|
138
|
+
* viewport edge. */
|
|
139
|
+
const TOOLTIP_GAP = 12
|
|
140
|
+
const VIEWPORT_MARGIN = 8
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Floating tooltip positioned against a viewport-space anchor, with flip + clamp handled once for
|
|
144
|
+
* every chart instead of per call site (this used to be recomputed inside each chart's own `useChartTooltip`, and only for
|
|
145
|
+
* charts that remembered to use it).
|
|
146
|
+
*
|
|
147
|
+
* The box is measured after mount via `useLayoutEffect`, so the first paint of a given tooltip is
|
|
148
|
+
* hidden rather than misplaced — a tooltip that flashes at the wrong corner reads as a bug.
|
|
149
|
+
*/
|
|
150
|
+
export function ChartTooltipFloat({
|
|
151
|
+
anchor,
|
|
152
|
+
children,
|
|
153
|
+
}: {
|
|
154
|
+
anchor: { x: number; y: number } | null
|
|
155
|
+
children: ReactNode
|
|
156
|
+
}) {
|
|
157
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
158
|
+
const [box, setBox] = useState<{ width: number; height: number } | null>(null)
|
|
159
|
+
|
|
160
|
+
useLayoutEffect(() => {
|
|
161
|
+
const el = ref.current
|
|
162
|
+
if (el === null) return
|
|
163
|
+
const { offsetWidth, offsetHeight } = el
|
|
164
|
+
setBox((prev) =>
|
|
165
|
+
prev !== null && prev.width === offsetWidth && prev.height === offsetHeight
|
|
166
|
+
? prev
|
|
167
|
+
: { width: offsetWidth, height: offsetHeight },
|
|
168
|
+
)
|
|
169
|
+
}, [children, anchor])
|
|
170
|
+
|
|
171
|
+
if (anchor === null) return null
|
|
172
|
+
// SSR guard: react-dom/server has no jsdom and does not support portals.
|
|
173
|
+
if (typeof document === 'undefined') return null
|
|
174
|
+
|
|
175
|
+
const width = box?.width ?? 0
|
|
176
|
+
const height = box?.height ?? 0
|
|
177
|
+
const vw = window.innerWidth
|
|
178
|
+
const vh = window.innerHeight
|
|
179
|
+
|
|
180
|
+
const flipsLeft = anchor.x + TOOLTIP_GAP + width + VIEWPORT_MARGIN > vw
|
|
181
|
+
const left = flipsLeft
|
|
182
|
+
? Math.max(VIEWPORT_MARGIN, anchor.x - TOOLTIP_GAP - width)
|
|
183
|
+
: anchor.x + TOOLTIP_GAP
|
|
184
|
+
const top = Math.min(
|
|
185
|
+
Math.max(VIEWPORT_MARGIN, anchor.y - TOOLTIP_GAP),
|
|
186
|
+
Math.max(VIEWPORT_MARGIN, vh - height - VIEWPORT_MARGIN),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return createPortal(
|
|
190
|
+
<div
|
|
191
|
+
ref={ref}
|
|
192
|
+
role="tooltip"
|
|
193
|
+
aria-live="polite"
|
|
194
|
+
style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
|
|
195
|
+
>
|
|
196
|
+
{children}
|
|
197
|
+
</div>,
|
|
198
|
+
document.body,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -1,23 +1,59 @@
|
|
|
1
|
-
import type { PointerEventHandler } from 'react'
|
|
1
|
+
import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
3
|
+
/**
|
|
4
|
+
* Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
|
|
5
|
+
* sync.
|
|
6
|
+
*
|
|
7
|
+
* With `onKeyDown` it also becomes the chart's keyboard affordance: the rect takes focus and
|
|
8
|
+
* ←/→ scrub the cursor, so a chart's values are reachable without a pointer.
|
|
9
|
+
*/
|
|
5
10
|
export function HoverOverlay({
|
|
6
11
|
width,
|
|
7
12
|
height,
|
|
8
13
|
onMove,
|
|
9
14
|
onLeave,
|
|
15
|
+
onKeyDown,
|
|
16
|
+
onBlur,
|
|
17
|
+
ariaLabel,
|
|
18
|
+
valueNow,
|
|
19
|
+
valueMax,
|
|
20
|
+
valueText,
|
|
10
21
|
}: {
|
|
11
22
|
width: number
|
|
12
23
|
height: number
|
|
13
24
|
onMove: PointerEventHandler<SVGRectElement>
|
|
14
25
|
onLeave: PointerEventHandler<SVGRectElement>
|
|
26
|
+
/** Present = the overlay is focusable and scrubs on ←/→. */
|
|
27
|
+
onKeyDown?: KeyboardEventHandler<SVGRectElement>
|
|
28
|
+
onBlur?: FocusEventHandler<SVGRectElement>
|
|
29
|
+
ariaLabel?: string
|
|
30
|
+
/** Index of the focused point — announced as the slider position. */
|
|
31
|
+
valueNow?: number
|
|
32
|
+
/** Last index of the domain. */
|
|
33
|
+
valueMax?: number
|
|
34
|
+
/** Human-readable label for the focused point (the formatted x key). */
|
|
35
|
+
valueText?: string
|
|
15
36
|
}) {
|
|
16
37
|
return (
|
|
17
38
|
<rect
|
|
18
39
|
width={width}
|
|
19
40
|
height={height}
|
|
20
41
|
fill="transparent"
|
|
42
|
+
{...(onKeyDown !== undefined && {
|
|
43
|
+
tabIndex: 0,
|
|
44
|
+
// `slider`, not `application`: `application` would drop this node out of the screen
|
|
45
|
+
// reader's normal browse mode entirely, which is a far bigger hammer than an arrow-key
|
|
46
|
+
// scrub needs. A slider announces both the affordance and the focused point.
|
|
47
|
+
role: 'slider',
|
|
48
|
+
'aria-label': ariaLabel ?? 'Chart data — use arrow keys to scrub',
|
|
49
|
+
'aria-orientation': 'horizontal' as const,
|
|
50
|
+
'aria-valuemin': 0,
|
|
51
|
+
...(valueMax !== undefined && { 'aria-valuemax': valueMax }),
|
|
52
|
+
...(valueNow !== undefined && { 'aria-valuenow': valueNow }),
|
|
53
|
+
...(valueText !== undefined && { 'aria-valuetext': valueText }),
|
|
54
|
+
onKeyDown,
|
|
55
|
+
...(onBlur !== undefined && { onBlur }),
|
|
56
|
+
})}
|
|
21
57
|
// `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
|
|
22
58
|
// phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
|
|
23
59
|
// reaches this overlay to scrub the chart.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `XZoneRects` — the x-analog of `ZoneRects`. Same SSR harness as `ChartFrame.test.tsx`:
|
|
3
|
+
* `src/charts/**` is Mantine-free, so `renderToStaticMarkup` needs no provider wrapper.
|
|
4
|
+
*/
|
|
5
|
+
import { scalePoint } from '@visx/scale'
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
8
|
+
import { XZoneRects } from './XZoneRects'
|
|
9
|
+
import type { XZoneSpec } from './XZoneRects'
|
|
10
|
+
|
|
11
|
+
const DOMAIN = ['a', 'b', 'c', 'd']
|
|
12
|
+
const xScale = scalePoint<string>({ domain: DOMAIN, range: [0, 100], padding: 0.3 })
|
|
13
|
+
|
|
14
|
+
function render(zones: XZoneSpec[]): string {
|
|
15
|
+
return renderToStaticMarkup(<XZoneRects zones={zones} height={50} xScale={xScale} />)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('XZoneRects', () => {
|
|
19
|
+
test('both bounds present -> rect spans center-to-center', () => {
|
|
20
|
+
const from = xScale('b') as number
|
|
21
|
+
const to = xScale('c') as number
|
|
22
|
+
const markup = render([{ from: 'b', to: 'c', fill: 'red' }])
|
|
23
|
+
expect(markup).toContain(`x="${from}"`)
|
|
24
|
+
expect(markup).toContain(`width="${to - from}"`)
|
|
25
|
+
expect(markup).toContain('height="50"')
|
|
26
|
+
expect(markup).toContain('fill="red"')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('omitted from -> resolves to the plot left edge (0)', () => {
|
|
30
|
+
const to = xScale('b') as number
|
|
31
|
+
const [rangeStart] = xScale.range() as [number, number]
|
|
32
|
+
const markup = render([{ to: 'b', fill: 'red' }])
|
|
33
|
+
expect(markup).toContain(`x="${rangeStart}"`)
|
|
34
|
+
expect(markup).toContain(`width="${to - rangeStart}"`)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('omitted to -> resolves to the plot right edge (xScale.range()[1])', () => {
|
|
38
|
+
const from = xScale('c') as number
|
|
39
|
+
const [, rangeEnd] = xScale.range() as [number, number]
|
|
40
|
+
const markup = render([{ from: 'c', fill: 'red' }])
|
|
41
|
+
expect(markup).toContain(`x="${from}"`)
|
|
42
|
+
expect(markup).toContain(`width="${rangeEnd - from}"`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('an unknown key skips the band entirely — no rect, no clamping to a plot edge', () => {
|
|
46
|
+
const markup = render([{ from: 'not-in-domain', to: 'c', fill: 'red' }])
|
|
47
|
+
expect(markup).not.toContain('<rect')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('an inverted range (to before from) renders no rect', () => {
|
|
51
|
+
const markup = render([{ from: 'c', to: 'a', fill: 'red' }])
|
|
52
|
+
expect(markup).not.toContain('<rect')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('a degenerate zero-width range (from === to) renders no rect', () => {
|
|
56
|
+
const markup = render([{ from: 'b', to: 'b', fill: 'red' }])
|
|
57
|
+
expect(markup).not.toContain('<rect')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('an empty zones array renders no rects', () => {
|
|
61
|
+
expect(render([])).not.toContain('<rect')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { scalePoint } from '@visx/scale'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The point-scale instance type, derived from `@visx/scale`'s own `scalePoint` return type.
|
|
5
|
+
* Equivalent to d3's `ScalePoint<string>` but resolved through `@visx/scale` (an installed dep) so
|
|
6
|
+
* it needs no `@types/d3-scale` and stays inside the visx-only-in-charts boundary.
|
|
7
|
+
*/
|
|
8
|
+
type PointScale = ReturnType<typeof scalePoint<string>>
|
|
9
|
+
|
|
10
|
+
export type XZoneSpec = {
|
|
11
|
+
/** Left edge — a domain key produced by the kind's `getX`. Omit for "start of the plot area". */
|
|
12
|
+
from?: string
|
|
13
|
+
/** Right edge — a domain key produced by the kind's `getX`. Omit for "end of the plot area". */
|
|
14
|
+
to?: string
|
|
15
|
+
fill: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Vertical zone backgrounds (x-range overlays — twilight bands, a shaded time window) — one
|
|
20
|
+
* <rect> per zone, spanning y=[0, height] and x=[scale(from), scale(to)]. The x analog of
|
|
21
|
+
* `ZoneRects`, for a `scalePoint` x-axis instead of a `scaleLinear` y-axis.
|
|
22
|
+
*
|
|
23
|
+
* An omitted `from` resolves to `0` (start of the plot area); an omitted `to` resolves to
|
|
24
|
+
* `xScale.range()[1]` (the plot's right edge). A present key resolves to `xScale(key)` — the
|
|
25
|
+
* POINT CENTER, so a band reads as "from this sample to that sample", not edge-to-edge.
|
|
26
|
+
*
|
|
27
|
+
* A key that is not in the scale's domain skips that band entirely (renders nothing for it) —
|
|
28
|
+
* it is NEVER clamped to a plot edge. Clamping would silently paint a band across the whole
|
|
29
|
+
* chart for a typo'd or stale key, which is the exact class of quiet lie the repo's "nothing to
|
|
30
|
+
* draw is two different states" doctrine rejects: an unknown key must read as "not drawn", not
|
|
31
|
+
* as "the whole plot".
|
|
32
|
+
*
|
|
33
|
+
* A degenerate resolved range (right edge <= left edge, e.g. an inverted `from`/`to` pair) is
|
|
34
|
+
* also skipped — no negative-width rect.
|
|
35
|
+
*/
|
|
36
|
+
export function XZoneRects({
|
|
37
|
+
zones,
|
|
38
|
+
height,
|
|
39
|
+
xScale,
|
|
40
|
+
}: {
|
|
41
|
+
zones: XZoneSpec[]
|
|
42
|
+
/** Plot-area height (yMax); rects span from 0 to this. */
|
|
43
|
+
height: number
|
|
44
|
+
xScale: PointScale
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
{zones.map((z, i) => {
|
|
49
|
+
const [rangeStart, rangeEnd] = xScale.range() as [number, number]
|
|
50
|
+
const xFrom = z.from === undefined ? rangeStart : xScale(z.from)
|
|
51
|
+
const xTo = z.to === undefined ? rangeEnd : xScale(z.to)
|
|
52
|
+
if (xFrom === undefined || xTo === undefined) return null
|
|
53
|
+
if (xTo <= xFrom) return null
|
|
54
|
+
return (
|
|
55
|
+
<rect
|
|
56
|
+
key={`xzone-${i}`}
|
|
57
|
+
x={xFrom}
|
|
58
|
+
y={0}
|
|
59
|
+
width={xTo - xFrom}
|
|
60
|
+
height={height}
|
|
61
|
+
fill={z.fill}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { deriveLegend, deriveTooltipRows } from './series'
|
|
3
|
+
import type { SeriesStyle } from './series'
|
|
4
|
+
|
|
5
|
+
const baseSeries: SeriesStyle = {
|
|
6
|
+
key: 'low-cloud',
|
|
7
|
+
label: 'Low cloud',
|
|
8
|
+
color: '#000',
|
|
9
|
+
mark: 'line',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('deriveLegend note passthrough', () => {
|
|
13
|
+
test('carries note through when set', () => {
|
|
14
|
+
const [entry] = deriveLegend([{ ...baseSeries, note: '0% all night' }])
|
|
15
|
+
expect(entry?.note).toBe('0% all night')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('omits the note key entirely when unset', () => {
|
|
19
|
+
const [entry] = deriveLegend([baseSeries])
|
|
20
|
+
expect(entry).not.toHaveProperty('note')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('deriveTooltipRows', () => {
|
|
25
|
+
type Row = { v: number }
|
|
26
|
+
const base = { color: '#000', mark: 'bar' as const, getValue: (d: Row) => d.v }
|
|
27
|
+
const fmt = (v: number) => String(v)
|
|
28
|
+
|
|
29
|
+
test('a `tooltip: false` series is drawn and legended but produces no row', () => {
|
|
30
|
+
const rows = deriveTooltipRows(
|
|
31
|
+
[
|
|
32
|
+
{ ...base, key: 'shown', label: 'Shown' },
|
|
33
|
+
{ ...base, key: 'quiet', label: 'Quiet', tooltip: false },
|
|
34
|
+
],
|
|
35
|
+
{ v: 1 },
|
|
36
|
+
fmt,
|
|
37
|
+
)
|
|
38
|
+
expect(rows.map((r) => r.key)).toEqual(['shown'])
|
|
39
|
+
expect(deriveLegend([{ ...base, key: 'quiet', label: 'Quiet', tooltip: false }])).toHaveLength(
|
|
40
|
+
1,
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a null value still skips its row', () => {
|
|
45
|
+
const rows = deriveTooltipRows(
|
|
46
|
+
[{ ...base, key: 'gap', label: 'Gap', getValue: () => null }],
|
|
47
|
+
{ v: 1 },
|
|
48
|
+
fmt,
|
|
49
|
+
)
|
|
50
|
+
expect(rows).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a per-series formatValue wins over the shared fallback', () => {
|
|
54
|
+
const rows = deriveTooltipRows(
|
|
55
|
+
[{ ...base, key: 'a', label: 'A', formatValue: (v) => `${v}%` }],
|
|
56
|
+
{ v: 42 },
|
|
57
|
+
fmt,
|
|
58
|
+
)
|
|
59
|
+
expect(rows[0]?.value).toBe('42%')
|
|
60
|
+
})
|
|
61
|
+
})
|