basalt-ui 1.7.0 → 1.9.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/CHANGELOG.md +24 -0
- package/agent/rules/basalt-charts.md +74 -5
- package/agent/skills/basalt-design/SKILL.md +5 -2
- package/dist/charts/hooks/useHoverSync.d.ts +18 -2
- package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.js +12 -7
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +6 -0
- package/dist/charts/kinds/Bars.d.ts +2 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +3 -1
- package/dist/charts/kinds/Donut.d.ts +2 -0
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +8 -4
- package/dist/charts/kinds/DualPanel.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +2 -1
- package/dist/charts/kinds/Heatmap.d.ts +8 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +7 -3
- package/dist/charts/kinds/MultiLine.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -1
- package/dist/charts/kinds/StackedArea.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -1
- package/dist/charts/kinds/ZonedLine.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -1
- package/dist/charts/primitives/Axes.d.ts +6 -1
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +3 -2
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +8 -4
- package/dist/charts/primitives/ChartPending.d.ts +34 -0
- package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
- package/dist/charts/primitives/ChartPending.js +23 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +4 -2
- package/dist/cli/index.d.ts +22 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +86 -5
- package/dist/dashboard/stat-card.d.ts +4 -2
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +6 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +13 -5
- package/llms.txt +2 -2
- package/package.json +3 -5
- package/src/charts/hooks/useHoverSync.ts +43 -7
- package/src/charts/index.ts +6 -0
- package/src/charts/kinds/Bars.tsx +4 -0
- package/src/charts/kinds/Donut.tsx +10 -4
- package/src/charts/kinds/DualPanel.tsx +5 -1
- package/src/charts/kinds/Heatmap.tsx +19 -2
- package/src/charts/kinds/MultiLine.tsx +4 -1
- package/src/charts/kinds/StackedArea.tsx +4 -1
- package/src/charts/kinds/ZonedLine.tsx +4 -1
- package/src/charts/primitives/Axes.tsx +7 -1
- package/src/charts/primitives/ChartFrame.test.tsx +64 -0
- package/src/charts/primitives/ChartFrame.tsx +17 -4
- package/src/charts/primitives/ChartPending.tsx +59 -0
- package/src/charts/primitives/ChartTooltip.tsx +15 -3
- package/src/charts/primitives/HoverOverlay.tsx +12 -6
- package/src/cli/index.ts +154 -5
- package/src/cli/placement-engine.test.ts +104 -0
- package/src/dashboard/stat-card.test.tsx +81 -0
- package/src/dashboard/stat-card.tsx +30 -4
- package/src/guard/check-source.test.ts +22 -1
- package/src/guard/index.ts +21 -7
- package/src/theme/contrast.test.ts +18 -1
|
@@ -24,12 +24,29 @@ export function useHoverSync<T>({
|
|
|
24
24
|
getKey,
|
|
25
25
|
xScale,
|
|
26
26
|
marginLeft,
|
|
27
|
+
resolveKey,
|
|
27
28
|
}: {
|
|
28
29
|
data: T[]
|
|
29
30
|
chartId: string
|
|
30
31
|
getKey: (d: T) => string
|
|
31
32
|
xScale: XScale
|
|
32
33
|
marginLeft: number
|
|
34
|
+
/**
|
|
35
|
+
* Overrides how a SIBLING's broadcast key resolves to one of THIS chart's own points. Only
|
|
36
|
+
* affects reading another chart's hover — this chart's own hover (via `getKey`/`xScale`) is
|
|
37
|
+
* untouched.
|
|
38
|
+
*
|
|
39
|
+
* Cross-chart sync is exact string match by default (`pointByKey.get(ctx.key)`), so two charts
|
|
40
|
+
* share a cursor only if they emit identical key strings. A chart that downsamples/folds its
|
|
41
|
+
* domain to fit a narrow viewport (e.g. ~288 buckets folded into ~97 columns at 390px) no longer
|
|
42
|
+
* owns most of the keys its unfolded siblings broadcast — the shared crosshair then appears on
|
|
43
|
+
* roughly one hover in three, with no rule a reader can infer, which is worse than no shared
|
|
44
|
+
* cursor at all. Pass `resolveKey` to map a foreign key onto whichever of this chart's own points
|
|
45
|
+
* swallowed it (e.g. the folded bucket the key falls into).
|
|
46
|
+
*
|
|
47
|
+
* Omit it and behaviour is byte-identical to today.
|
|
48
|
+
*/
|
|
49
|
+
resolveKey?: (key: string) => T | null
|
|
33
50
|
}) {
|
|
34
51
|
const ctx = useContext(HoverContext)
|
|
35
52
|
// Latest context in a ref so the mouse callbacks stay referentially stable: the provider's value
|
|
@@ -53,13 +70,26 @@ export function useHoverSync<T>({
|
|
|
53
70
|
|
|
54
71
|
const { tip, show, hide, tooltipRef, lastDateRef } = useChartTooltip<T>()
|
|
55
72
|
|
|
73
|
+
// Latest accessors in refs, same reasoning as `ctxRef` above: the natural call style is an
|
|
74
|
+
// inline arrow for `getKey`/`xScale`, which is fresh on every render, and this file already
|
|
75
|
+
// pays for that hazard once (see the comment above `ctxRef`) without applying the fix here — a
|
|
76
|
+
// several-hundred-entry `pointByKey` Map was rebuilding on every render of a page that
|
|
77
|
+
// re-renders on a heartbeat. Trade-off: because `getKey` is read through a ref, changing key
|
|
78
|
+
// SEMANTICS without changing `data` will NOT rebuild the map. That's the intended trade — a
|
|
79
|
+
// `getKey` that changes meaning for the same data is pathological; a `getKey` that changes
|
|
80
|
+
// identity every render is the normal case.
|
|
81
|
+
const getKeyRef = useRef(getKey)
|
|
82
|
+
getKeyRef.current = getKey
|
|
83
|
+
const xScaleRef = useRef(xScale)
|
|
84
|
+
xScaleRef.current = xScale
|
|
85
|
+
|
|
56
86
|
// O(1) lookup of a point by its key. Under a provider every sibling resolves the broadcast
|
|
57
87
|
// key each frame; an O(n) `data.find` per sibling per move is N×O(M). This makes it N×O(1).
|
|
58
88
|
const pointByKey = useMemo(() => {
|
|
59
89
|
const m = new Map<string, T>()
|
|
60
|
-
for (const d of data) m.set(
|
|
90
|
+
for (const d of data) m.set(getKeyRef.current(d), d)
|
|
61
91
|
return m
|
|
62
|
-
}, [data
|
|
92
|
+
}, [data])
|
|
63
93
|
|
|
64
94
|
const handleMouse = useCallback(
|
|
65
95
|
(event: React.MouseEvent<SVGRectElement>) => {
|
|
@@ -69,7 +99,7 @@ export function useHoverSync<T>({
|
|
|
69
99
|
let closest: T = data[0] as T
|
|
70
100
|
let minDist = Infinity
|
|
71
101
|
for (const d of data) {
|
|
72
|
-
const sx =
|
|
102
|
+
const sx = xScaleRef.current(getKeyRef.current(d)) ?? 0
|
|
73
103
|
const dist = Math.abs(sx - px)
|
|
74
104
|
if (dist < minDist) {
|
|
75
105
|
minDist = dist
|
|
@@ -77,13 +107,13 @@ export function useHoverSync<T>({
|
|
|
77
107
|
}
|
|
78
108
|
}
|
|
79
109
|
show(closest, event)
|
|
80
|
-
const key =
|
|
110
|
+
const key = getKeyRef.current(closest)
|
|
81
111
|
if (lastDateRef.current !== key) {
|
|
82
112
|
lastDateRef.current = key
|
|
83
113
|
ctxRef.current.setHover(key, chartId)
|
|
84
114
|
}
|
|
85
115
|
},
|
|
86
|
-
[data,
|
|
116
|
+
[data, chartId, marginLeft, show, lastDateRef],
|
|
87
117
|
)
|
|
88
118
|
|
|
89
119
|
const handleLeave = useCallback(() => {
|
|
@@ -97,8 +127,14 @@ export function useHoverSync<T>({
|
|
|
97
127
|
// The point the crosshair + synced dots track. Inside a <ChartHoverSync> this follows the
|
|
98
128
|
// broadcast key (so every sibling paints a ghost crosshair at the same x). WITHOUT a provider the
|
|
99
129
|
// chart is standalone, so it falls back to THIS chart's own hovered point (`tip.data`) — otherwise
|
|
100
|
-
// a chart outside a provider would never draw a crosshair/dots at all.
|
|
101
|
-
|
|
130
|
+
// a chart outside a provider would never draw a crosshair/dots at all. When `resolveKey` is
|
|
131
|
+
// supplied it replaces the default exact-match lookup for reading a SIBLING's broadcast key (see
|
|
132
|
+
// its doc comment above) — this chart's own hover is unaffected either way.
|
|
133
|
+
const syncedPoint = ctx.key
|
|
134
|
+
? resolveKey
|
|
135
|
+
? resolveKey(ctx.key)
|
|
136
|
+
: (pointByKey.get(ctx.key) ?? null)
|
|
137
|
+
: (tip?.data ?? null)
|
|
102
138
|
// Which chart owns the floating tooltip. Inside a provider the hovered chart is the one whose id
|
|
103
139
|
// matches the broadcast source (siblings show a ghost crosshair only). Standalone, its own local
|
|
104
140
|
// hover (`tip`) drives the tooltip.
|
package/src/charts/index.ts
CHANGED
|
@@ -46,6 +46,12 @@ export {
|
|
|
46
46
|
// ── Primitives ───────────────────────────────────────────────────────────
|
|
47
47
|
export { ResponsiveChart, type ResponsiveChartProps } from './primitives/ResponsiveChart'
|
|
48
48
|
export { ChartFrame, type ChartFrameProps, type ChartFrameLegend } from './primitives/ChartFrame'
|
|
49
|
+
export {
|
|
50
|
+
ChartCenter,
|
|
51
|
+
type ChartCenterProps,
|
|
52
|
+
ChartPending,
|
|
53
|
+
type ChartPendingProps,
|
|
54
|
+
} from './primitives/ChartPending'
|
|
49
55
|
export { Crosshair, SeriesDot } from './primitives/Crosshair'
|
|
50
56
|
export { ChartCard } from './primitives/ChartCard'
|
|
51
57
|
export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
|
|
@@ -139,6 +139,8 @@ export type BarsProps<T> = {
|
|
|
139
139
|
legend?: ChartLegendConfig | false
|
|
140
140
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
141
141
|
ariaLabel?: string
|
|
142
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
143
|
+
isPending?: boolean
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
/**
|
|
@@ -164,6 +166,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
164
166
|
height,
|
|
165
167
|
legend,
|
|
166
168
|
ariaLabel,
|
|
169
|
+
isPending,
|
|
167
170
|
} = props
|
|
168
171
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
169
172
|
|
|
@@ -207,6 +210,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
207
210
|
chartId={chartId}
|
|
208
211
|
{...(height !== undefined && { height })}
|
|
209
212
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
213
|
+
{...(isPending !== undefined && { isPending })}
|
|
210
214
|
legend={resolveLegend(legend, {
|
|
211
215
|
highlighted: highlightedKey,
|
|
212
216
|
onHighlight: setHighlightedKey,
|
|
@@ -53,6 +53,8 @@ export type DonutProps<K extends string = SeriesKey> = {
|
|
|
53
53
|
padAngle?: number
|
|
54
54
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
55
55
|
ariaLabel?: string
|
|
56
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
57
|
+
isPending?: boolean
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -64,7 +66,7 @@ export type DonutProps<K extends string = SeriesKey> = {
|
|
|
64
66
|
* (donut ↔ bar, via the generalized key) is a distinct, deliberately deferred feature.
|
|
65
67
|
*/
|
|
66
68
|
function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
67
|
-
const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel } = props
|
|
69
|
+
const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel, isPending } = props
|
|
68
70
|
|
|
69
71
|
const series: SeriesStyle[] = data.map((d) => ({
|
|
70
72
|
key: d.key,
|
|
@@ -78,6 +80,7 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
|
78
80
|
series={series}
|
|
79
81
|
{...(height !== undefined && { height })}
|
|
80
82
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
83
|
+
{...(isPending !== undefined && { isPending })}
|
|
81
84
|
>
|
|
82
85
|
{(plot) => <DonutPlot {...props} plot={plot} />}
|
|
83
86
|
</ChartFrame>
|
|
@@ -136,13 +139,16 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
|
|
|
136
139
|
return (
|
|
137
140
|
<g
|
|
138
141
|
key={key}
|
|
139
|
-
|
|
142
|
+
onPointerEnter={(event) => {
|
|
140
143
|
show(arc.data, event)
|
|
141
144
|
}}
|
|
142
|
-
|
|
145
|
+
onPointerMove={(event) => {
|
|
143
146
|
show(arc.data, event)
|
|
144
147
|
}}
|
|
145
|
-
|
|
148
|
+
onPointerLeave={() => {
|
|
149
|
+
hide()
|
|
150
|
+
}}
|
|
151
|
+
onPointerCancel={() => {
|
|
146
152
|
hide()
|
|
147
153
|
}}
|
|
148
154
|
style={{ cursor: 'pointer' }}
|
|
@@ -57,6 +57,8 @@ export type DualPanelProps<T> = {
|
|
|
57
57
|
legend?: ChartLegendConfig | false
|
|
58
58
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
59
59
|
ariaLabel?: string
|
|
60
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
61
|
+
isPending?: boolean
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const PANE_GAP = 12
|
|
@@ -74,7 +76,8 @@ const PANE_GAP = 12
|
|
|
74
76
|
* when a series has nulls; lines/bars skip null points (visual gaps).
|
|
75
77
|
*/
|
|
76
78
|
function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
77
|
-
const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel } =
|
|
79
|
+
const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel, isPending } =
|
|
80
|
+
props
|
|
78
81
|
|
|
79
82
|
// Default the top-pane line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) —
|
|
80
83
|
// applied once here so the plotted line, the derived legend swatch, and the derived tooltip row
|
|
@@ -101,6 +104,7 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
|
101
104
|
chartId={chartId}
|
|
102
105
|
{...(height !== undefined && { height })}
|
|
103
106
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
107
|
+
{...(isPending !== undefined && { isPending })}
|
|
104
108
|
legend={resolveLegend(legend)}
|
|
105
109
|
>
|
|
106
110
|
{(plot) => <DualPanelPlot {...props} series={styledSeries} plot={plot} />}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TooltipRow,
|
|
9
9
|
useTooltipStyles,
|
|
10
10
|
} from '../primitives/ChartTooltip'
|
|
11
|
+
import { ChartPending } from '../primitives/ChartPending'
|
|
11
12
|
import { useChartTooltip } from '../hooks/useChartTooltip'
|
|
12
13
|
import { VX, alpha } from '../../tokens'
|
|
13
14
|
|
|
@@ -47,6 +48,14 @@ export type HeatmapProps<T> = {
|
|
|
47
48
|
renderTooltip?: (cell: HeatmapCell) => ReactNode
|
|
48
49
|
/** Optional gradient legend strip below the grid (faint → solid color). */
|
|
49
50
|
legend?: { min: string; max: string }
|
|
51
|
+
/**
|
|
52
|
+
* The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
|
|
53
|
+
* three-state rationale) over the full `width` × `height` box in place of the grid, suppressing
|
|
54
|
+
* the legend strip and every cell/label/tooltip along with it. Unlike the other six kinds,
|
|
55
|
+
* `Heatmap` doesn't compose `ChartFrame` (it takes `width`/`height` directly rather than
|
|
56
|
+
* measuring), so this is handled locally instead of forwarded.
|
|
57
|
+
*/
|
|
58
|
+
isPending?: boolean
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
/** Build a first-seen-ordered list of unique keys from data via an accessor. */
|
|
@@ -106,6 +115,7 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
106
115
|
colLabel = (c) => c,
|
|
107
116
|
renderTooltip,
|
|
108
117
|
legend,
|
|
118
|
+
isPending,
|
|
109
119
|
} = props
|
|
110
120
|
|
|
111
121
|
const tooltipStyles = useTooltipStyles()
|
|
@@ -126,6 +136,12 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
126
136
|
return { lookup: map, max: m }
|
|
127
137
|
}, [data, getRow, getCol, getValue])
|
|
128
138
|
|
|
139
|
+
// Every hook above must still run on a pending render (Rules of Hooks) — the early return comes
|
|
140
|
+
// after them. No `ChartFrame` to forward to here (Heatmap measures via required `width`/`height`
|
|
141
|
+
// props, not `useChartSize`), so the placeholder is rendered directly in its place, same footprint,
|
|
142
|
+
// legend strip and all other content omitted along with it.
|
|
143
|
+
if (isPending) return <ChartPending width={width} height={height} />
|
|
144
|
+
|
|
129
145
|
const legendH = legend ? LEGEND_H + LEGEND_LABEL_H : 0
|
|
130
146
|
const gridW = Math.max(0, width - PAD_LEFT)
|
|
131
147
|
const gridH = Math.max(0, height - PAD_TOP - PAD_BOTTOM - legendH)
|
|
@@ -152,8 +168,9 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
152
168
|
rx={cellRadius}
|
|
153
169
|
fill={fill}
|
|
154
170
|
style={{ cursor: has ? 'pointer' : 'default' }}
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
onPointerMove={(e) => has && show({ row, col, value }, e)}
|
|
172
|
+
onPointerLeave={hide}
|
|
173
|
+
onPointerCancel={hide}
|
|
157
174
|
/>
|
|
158
175
|
)
|
|
159
176
|
}),
|
|
@@ -67,6 +67,8 @@ export type MultiLineProps<T> = {
|
|
|
67
67
|
legend?: ChartLegendConfig | false
|
|
68
68
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
69
69
|
ariaLabel?: string
|
|
70
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
71
|
+
isPending?: boolean
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
const STAR_R = 6
|
|
@@ -96,7 +98,7 @@ function starPath(cx: number, cy: number, r: number): string {
|
|
|
96
98
|
* has nulls; each series line skips null points (creating visual gaps).
|
|
97
99
|
*/
|
|
98
100
|
function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
99
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
101
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
100
102
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
101
103
|
|
|
102
104
|
// Default line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied once
|
|
@@ -112,6 +114,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
|
112
114
|
chartId={chartId}
|
|
113
115
|
{...(height !== undefined && { height })}
|
|
114
116
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
117
|
+
{...(isPending !== undefined && { isPending })}
|
|
115
118
|
legend={resolveLegend(legend, {
|
|
116
119
|
highlighted: highlightedKey,
|
|
117
120
|
onHighlight: setHighlightedKey,
|
|
@@ -40,6 +40,8 @@ export type StackedAreaProps<T> = {
|
|
|
40
40
|
legend?: ChartLegendConfig | false
|
|
41
41
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
42
42
|
ariaLabel?: string
|
|
43
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
44
|
+
isPending?: boolean
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -48,7 +50,7 @@ export type StackedAreaProps<T> = {
|
|
|
48
50
|
* The tooltip renders rows in reversed (top-to-bottom) stack order so it matches the visual stack.
|
|
49
51
|
*/
|
|
50
52
|
function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
51
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
53
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
52
54
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
53
55
|
|
|
54
56
|
return (
|
|
@@ -57,6 +59,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
|
57
59
|
chartId={chartId}
|
|
58
60
|
{...(height !== undefined && { height })}
|
|
59
61
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
62
|
+
{...(isPending !== undefined && { isPending })}
|
|
60
63
|
legend={resolveLegend(legend, {
|
|
61
64
|
highlighted: highlightedKey,
|
|
62
65
|
onHighlight: setHighlightedKey,
|
|
@@ -101,6 +101,8 @@ export type ZonedLineProps<T> = {
|
|
|
101
101
|
legend?: ChartLegendConfig | false
|
|
102
102
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
103
103
|
ariaLabel?: string
|
|
104
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
105
|
+
isPending?: boolean
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -116,7 +118,7 @@ export type ZonedLineProps<T> = {
|
|
|
116
118
|
* visual gaps).
|
|
117
119
|
*/
|
|
118
120
|
function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
119
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
121
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
120
122
|
|
|
121
123
|
// Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
|
|
122
124
|
// once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
|
|
@@ -131,6 +133,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
|
131
133
|
chartId={chartId}
|
|
132
134
|
{...(height !== undefined && { height })}
|
|
133
135
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
136
|
+
{...(isPending !== undefined && { isPending })}
|
|
134
137
|
legend={resolveLegend(legend)}
|
|
135
138
|
>
|
|
136
139
|
{(plot) => <ZonedLinePlot {...props} series={styledSeries} plot={plot} />}
|
|
@@ -73,17 +73,23 @@ export function AxisBottomDate({
|
|
|
73
73
|
scale,
|
|
74
74
|
top,
|
|
75
75
|
tickValues,
|
|
76
|
+
tickFormat = fmtAxisDate,
|
|
76
77
|
}: {
|
|
77
78
|
scale: AxisScale
|
|
78
79
|
top: number
|
|
79
80
|
tickValues: string[]
|
|
81
|
+
/**
|
|
82
|
+
* Defaults to `fmtAxisDate` (DD.MM). Override for a sub-day window, where DD.MM collapses every
|
|
83
|
+
* tick to the same label.
|
|
84
|
+
*/
|
|
85
|
+
tickFormat?: TickFormatter<string>
|
|
80
86
|
}) {
|
|
81
87
|
return (
|
|
82
88
|
<AxisBottom
|
|
83
89
|
top={top}
|
|
84
90
|
scale={scale}
|
|
85
91
|
tickValues={tickValues}
|
|
86
|
-
tickFormat={
|
|
92
|
+
tickFormat={tickFormat}
|
|
87
93
|
tickLabelProps={{
|
|
88
94
|
fill: VX.faint,
|
|
89
95
|
fontFamily: TICK_FONT_FAMILY,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChartFrame`'s `isPending` invariant — the one behavior here that regresses silently. A pending
|
|
3
|
+
* chart that quietly fell back to rendering its legend + body would put a densified "not measured"
|
|
4
|
+
* shape back on screen exactly where `ChartPending` is supposed to reserve a static, contentless
|
|
5
|
+
* placeholder (see `ChartPending`'s JSDoc for the three-state "nothing to draw" rationale).
|
|
6
|
+
*
|
|
7
|
+
* No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
|
|
8
|
+
* `renderToStaticMarkup` is enough here: `ChartFrame`/`ChartPending` live in `src/charts/**`, which
|
|
9
|
+
* is Mantine-free, so no `MantineProvider` wrapper is needed either. `useChartSize` never measures
|
|
10
|
+
* under SSR (no `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed
|
|
11
|
+
* height — non-zero, which is what lets `children`/`ChartPending` render at all in this harness.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test'
|
|
14
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
15
|
+
import { ChartFrame } from './ChartFrame'
|
|
16
|
+
import type { SeriesStyle } from '../series'
|
|
17
|
+
|
|
18
|
+
const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
|
|
19
|
+
|
|
20
|
+
const CHART_BODY_MARKER = 'CHART_BODY_MARKER'
|
|
21
|
+
|
|
22
|
+
function renderFrame(isPending: boolean): string {
|
|
23
|
+
return renderToStaticMarkup(
|
|
24
|
+
<ChartFrame series={series} legend={{}} isPending={isPending}>
|
|
25
|
+
{() => <svg>{CHART_BODY_MARKER}</svg>}
|
|
26
|
+
</ChartFrame>,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('a pending ChartFrame renders neither the legend nor the chart body', () => {
|
|
31
|
+
const markup = renderFrame(true)
|
|
32
|
+
|
|
33
|
+
test('the legend is absent — no series label anywhere in the markup', () => {
|
|
34
|
+
expect(markup).not.toContain('Series A')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('the chart body (children) never runs', () => {
|
|
38
|
+
expect(markup).not.toContain(CHART_BODY_MARKER)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('the outer container is marked aria-busy', () => {
|
|
42
|
+
expect(markup).toContain('aria-busy="true"')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('the placeholder label renders in its place', () => {
|
|
46
|
+
expect(markup).toContain('Loading…')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('a non-pending ChartFrame is unaffected — the legend and body both render', () => {
|
|
51
|
+
const markup = renderFrame(false)
|
|
52
|
+
|
|
53
|
+
test('the legend renders the series label', () => {
|
|
54
|
+
expect(markup).toContain('Series A')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('the chart body renders', () => {
|
|
58
|
+
expect(markup).toContain(CHART_BODY_MARKER)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('no aria-busy attribute is present', () => {
|
|
62
|
+
expect(markup).not.toContain('aria-busy')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -3,6 +3,7 @@ import { useChartSize } from '../hooks/useChartSize'
|
|
|
3
3
|
import { deriveLegend } from '../series'
|
|
4
4
|
import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
|
|
5
5
|
import { ChartLegend } from './ChartLegend'
|
|
6
|
+
import { ChartPending } from './ChartPending'
|
|
6
7
|
|
|
7
8
|
const DEFAULT_HEIGHT = 240
|
|
8
9
|
const DEFAULT_MIN_WIDTH = 200
|
|
@@ -36,6 +37,13 @@ export type ChartFrameProps = {
|
|
|
36
37
|
chartId?: string
|
|
37
38
|
/** `false` only for the sparkline exemption — every other chart gets a legend by default. */
|
|
38
39
|
legend?: ChartFrameLegend | false
|
|
40
|
+
/**
|
|
41
|
+
* The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
|
|
42
|
+
* three-state "nothing to draw" rationale) over the full plot rect in place of `children`,
|
|
43
|
+
* suppresses the legend entirely (a legend naming a series with nothing yet to point at is its
|
|
44
|
+
* own small lie), and marks the outer container `aria-busy="true"`.
|
|
45
|
+
*/
|
|
46
|
+
isPending?: boolean
|
|
39
47
|
/**
|
|
40
48
|
* Accessible text alternative for the chart, applied as `aria-label` (+ `role="img"`) on the
|
|
41
49
|
* outer container so screen readers announce something other than an unlabeled graphic. Every
|
|
@@ -106,6 +114,7 @@ export function ChartFrame({
|
|
|
106
114
|
chartId,
|
|
107
115
|
legend = {},
|
|
108
116
|
ariaLabel,
|
|
117
|
+
isPending = false,
|
|
109
118
|
children,
|
|
110
119
|
}: ChartFrameProps): ReactNode {
|
|
111
120
|
const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
|
|
@@ -113,6 +122,7 @@ export function ChartFrame({
|
|
|
113
122
|
|
|
114
123
|
const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
|
|
115
124
|
const vertical = placement === 'left' || placement === 'right'
|
|
125
|
+
const legendVisible = legend !== false && !isPending
|
|
116
126
|
|
|
117
127
|
const resolvedHeight = fill
|
|
118
128
|
? containerH
|
|
@@ -120,8 +130,8 @@ export function ChartFrame({
|
|
|
120
130
|
? Math.round(containerW / aspectRatio)
|
|
121
131
|
: (height ?? DEFAULT_HEIGHT)
|
|
122
132
|
|
|
123
|
-
const sideLegendWidth =
|
|
124
|
-
const topBottomLegendHeight =
|
|
133
|
+
const sideLegendWidth = legendVisible && vertical ? legendW : 0
|
|
134
|
+
const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
|
|
125
135
|
|
|
126
136
|
const plot = {
|
|
127
137
|
width: Math.max(containerW - sideLegendWidth, minWidth),
|
|
@@ -129,7 +139,7 @@ export function ChartFrame({
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
const legendNode =
|
|
132
|
-
legend === false ? null : (
|
|
142
|
+
legend === false || isPending ? null : (
|
|
133
143
|
<div ref={legendRef} style={legendWrapperStyle(vertical)}>
|
|
134
144
|
<ChartLegend
|
|
135
145
|
items={deriveLegend(series)}
|
|
@@ -148,9 +158,12 @@ export function ChartFrame({
|
|
|
148
158
|
ref={containerRef}
|
|
149
159
|
style={outerStyle(fill, vertical)}
|
|
150
160
|
{...(ariaLabel !== undefined && { role: 'img', 'aria-label': ariaLabel })}
|
|
161
|
+
{...(isPending && { 'aria-busy': 'true' })}
|
|
151
162
|
>
|
|
152
163
|
{legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
|
|
153
|
-
{plot.width > 0 &&
|
|
164
|
+
{plot.width > 0 &&
|
|
165
|
+
plot.height > 0 &&
|
|
166
|
+
(isPending ? <ChartPending width={plot.width} height={plot.height} /> : children(plot))}
|
|
154
167
|
{legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
|
|
155
168
|
</div>
|
|
156
169
|
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { VX } from '../../tokens'
|
|
3
|
+
|
|
4
|
+
export type ChartCenterProps = {
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
children: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const centerStyle = (width: number, height: number): CSSProperties => ({
|
|
11
|
+
width,
|
|
12
|
+
height,
|
|
13
|
+
display: 'flex',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
justifyContent: 'center',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal layout primitive: centers `children` inside a `width` × `height` box. Exists because
|
|
20
|
+
* `src/charts/**` cannot import Mantine's `Flex`/`Group`/`Center` (the Mantine-free boundary), so
|
|
21
|
+
* there was previously nothing to reach for when a chart file needed to center something. Not a
|
|
22
|
+
* general layout system — width, height, children, nothing else.
|
|
23
|
+
*/
|
|
24
|
+
export function ChartCenter({ width, height, children }: ChartCenterProps): ReactNode {
|
|
25
|
+
return <div style={centerStyle(width, height)}>{children}</div>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ChartPendingProps = {
|
|
29
|
+
width: number
|
|
30
|
+
height: number
|
|
31
|
+
/** Default `'Loading…'`. */
|
|
32
|
+
label?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const labelStyle: CSSProperties = {
|
|
36
|
+
color: VX.faint,
|
|
37
|
+
fontSize: VX.text.sm,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The placeholder for a chart whose data hasn't arrived yet — the third "nothing to draw" state
|
|
42
|
+
* alongside measured-and-empty and measured-and-absent (a real gap in coverage). Collapsing an
|
|
43
|
+
* in-flight query into either of those (the `data ?? []` idiom) densifies it into a fully-hatched
|
|
44
|
+
* "not measured" window: a positive claim that the series WAS watched and carried nothing, when in
|
|
45
|
+
* fact it was never asked. `ChartPending` makes "not asked yet" its own rendered state instead of
|
|
46
|
+
* borrowing one of the other two.
|
|
47
|
+
*
|
|
48
|
+
* Reserves exactly the plot's footprint and draws NOTHING that could be mistaken for a
|
|
49
|
+
* measurement — no axes, no gridlines, no hatching, no data marks — just a faint, static, centered
|
|
50
|
+
* label. No animation: the package's motion doctrine bans looping/pulsing idle motion, so a static
|
|
51
|
+
* reserved box is the correct answer here, not a compromise.
|
|
52
|
+
*/
|
|
53
|
+
export function ChartPending({ width, height, label = 'Loading…' }: ChartPendingProps): ReactNode {
|
|
54
|
+
return (
|
|
55
|
+
<ChartCenter width={width} height={height}>
|
|
56
|
+
<span style={labelStyle}>{label}</span>
|
|
57
|
+
</ChartCenter>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode, RefObject } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { VX } from '../../tokens'
|
|
3
4
|
import { fmtTooltipDate } from '../utils/format'
|
|
4
5
|
|
|
@@ -24,7 +25,10 @@ export function useTooltipStyles(): CSSProperties {
|
|
|
24
25
|
return TOOLTIP_STYLES
|
|
25
26
|
}
|
|
26
27
|
|
|
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
|
+
*/
|
|
28
32
|
export function ChartTooltip({
|
|
29
33
|
tip,
|
|
30
34
|
tooltipRef,
|
|
@@ -37,10 +41,18 @@ export function ChartTooltip({
|
|
|
37
41
|
children: ReactNode
|
|
38
42
|
}) {
|
|
39
43
|
if (!tip) return null
|
|
40
|
-
|
|
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(
|
|
41
52
|
<div ref={tooltipRef} style={{ ...styles, left: tip.x, top: tip.y }}>
|
|
42
53
|
{children}
|
|
43
|
-
</div
|
|
54
|
+
</div>,
|
|
55
|
+
document.body,
|
|
44
56
|
)
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PointerEventHandler } from 'react'
|
|
2
2
|
|
|
3
|
-
/** Transparent <rect> that captures
|
|
3
|
+
/** Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
|
|
4
|
+
* sync. */
|
|
4
5
|
export function HoverOverlay({
|
|
5
6
|
width,
|
|
6
7
|
height,
|
|
@@ -9,16 +10,21 @@ export function HoverOverlay({
|
|
|
9
10
|
}: {
|
|
10
11
|
width: number
|
|
11
12
|
height: number
|
|
12
|
-
onMove:
|
|
13
|
-
onLeave:
|
|
13
|
+
onMove: PointerEventHandler<SVGRectElement>
|
|
14
|
+
onLeave: PointerEventHandler<SVGRectElement>
|
|
14
15
|
}) {
|
|
15
16
|
return (
|
|
16
17
|
<rect
|
|
17
18
|
width={width}
|
|
18
19
|
height={height}
|
|
19
20
|
fill="transparent"
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
|
|
22
|
+
// phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
|
|
23
|
+
// reaches this overlay to scrub the chart.
|
|
24
|
+
style={{ touchAction: 'pan-y' }}
|
|
25
|
+
onPointerMove={onMove}
|
|
26
|
+
onPointerLeave={onLeave}
|
|
27
|
+
onPointerCancel={onLeave}
|
|
22
28
|
/>
|
|
23
29
|
)
|
|
24
30
|
}
|