basalt-ui 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +38 -2
- package/agent/rules/basalt-charts.md +260 -108
- package/agent/rules/basalt-mantine.md +4 -2
- package/agent/skills/basalt-charts/SKILL.md +114 -64
- package/agent/skills/basalt-design/SKILL.md +4 -3
- package/configs/oxlint-plugin.js +152 -0
- package/configs/oxlint-plugin.test.ts +107 -0
- package/configs/oxlint.json +2 -0
- package/dist/charts/cursor/resolve.d.ts +33 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -0
- package/dist/charts/cursor/resolve.js +61 -0
- package/dist/charts/cursor/resolve.test.d.ts +2 -0
- package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
- package/dist/charts/cursor/scope.d.ts +17 -0
- package/dist/charts/cursor/scope.d.ts.map +1 -0
- package/dist/charts/cursor/scope.js +20 -0
- package/dist/charts/cursor/store.d.ts +31 -0
- package/dist/charts/cursor/store.d.ts.map +1 -0
- package/dist/charts/cursor/store.js +25 -0
- package/dist/charts/cursor/store.test.d.ts +2 -0
- package/dist/charts/cursor/store.test.d.ts.map +1 -0
- package/dist/charts/hooks/useChartCursor.d.ts +39 -0
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
- package/dist/charts/hooks/useChartCursor.js +104 -0
- package/dist/charts/hooks/useChartSize.d.ts +8 -4
- package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
- package/dist/charts/index.d.ts +16 -13
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +38 -19
- package/dist/charts/kinds/Bars.d.ts +25 -47
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +230 -390
- package/dist/charts/kinds/Donut.d.ts +5 -4
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +17 -14
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +136 -90
- package/dist/charts/kinds/Heatmap.d.ts +15 -11
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +38 -22
- package/dist/charts/kinds/MultiLine.d.ts +27 -35
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +114 -237
- package/dist/charts/kinds/StackedArea.d.ts +21 -10
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +74 -132
- package/dist/charts/kinds/ZonedLine.d.ts +24 -40
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +103 -184
- package/dist/charts/layout/auto-margin.d.ts +60 -0
- package/dist/charts/layout/auto-margin.d.ts.map +1 -0
- package/dist/charts/layout/auto-margin.js +37 -0
- package/dist/charts/layout/auto-margin.test.d.ts +2 -0
- package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.d.ts +143 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
- package/dist/charts/primitives/CartesianChart.js +349 -0
- package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
- package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.d.ts +16 -5
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +17 -2
- package/dist/charts/primitives/ChartLegend.d.ts +12 -1
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +21 -3
- package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
- package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +45 -19
- package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +22 -1
- package/dist/charts/primitives/XZoneRects.d.ts +40 -0
- package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
- package/dist/charts/primitives/XZoneRects.js +28 -0
- package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
- package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
- package/dist/charts/series.d.ts +16 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/series.js +3 -1
- package/dist/charts/series.test.d.ts +2 -0
- package/dist/charts/series.test.d.ts.map +1 -0
- package/dist/charts/utils/measure-text.d.ts +25 -0
- package/dist/charts/utils/measure-text.d.ts.map +1 -0
- package/dist/charts/utils/measure-text.js +64 -0
- package/dist/charts/utils/measure-text.test.d.ts +2 -0
- package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/shell/app-sidebar.d.ts +19 -1
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +30 -26
- package/dist/shell/app-sidebar.module.css +11 -0
- package/dist/shell/app-sidebar.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.test.d.ts +2 -0
- package/dist/shell/app-sidebar.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +10 -1
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +2 -0
- package/dist/shell/index.test.d.ts +2 -0
- package/dist/shell/index.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/chart-margin.test.d.ts +2 -0
- package/dist/tokens/chart-margin.test.d.ts.map +1 -0
- package/dist/tokens/index.d.ts +29 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +10 -0
- package/dist/tokens/palette.d.ts +4 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +3 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +61 -0
- package/src/charts/cursor/resolve.ts +102 -0
- package/src/charts/cursor/scope.tsx +32 -0
- package/src/charts/cursor/store.test.ts +46 -0
- package/src/charts/cursor/store.ts +53 -0
- package/src/charts/hooks/useChartCursor.ts +163 -0
- package/src/charts/hooks/useChartSize.ts +8 -4
- package/src/charts/index.ts +34 -14
- package/src/charts/kinds/Bars.tsx +327 -460
- package/src/charts/kinds/Donut.tsx +31 -23
- package/src/charts/kinds/DualPanel.tsx +228 -134
- package/src/charts/kinds/Heatmap.tsx +72 -47
- package/src/charts/kinds/MultiLine.tsx +158 -294
- package/src/charts/kinds/StackedArea.tsx +116 -175
- package/src/charts/kinds/ZonedLine.tsx +148 -255
- package/src/charts/layout/auto-margin.test.ts +42 -0
- package/src/charts/layout/auto-margin.ts +110 -0
- package/src/charts/primitives/CartesianChart.test.ts +99 -0
- package/src/charts/primitives/CartesianChart.tsx +566 -0
- package/src/charts/primitives/ChartFrame.test.tsx +33 -0
- package/src/charts/primitives/ChartFrame.tsx +40 -4
- package/src/charts/primitives/ChartLegend.test.tsx +48 -0
- package/src/charts/primitives/ChartLegend.tsx +45 -2
- package/src/charts/primitives/ChartTooltip.tsx +67 -37
- package/src/charts/primitives/HoverOverlay.tsx +39 -3
- package/src/charts/primitives/XZoneRects.test.tsx +63 -0
- package/src/charts/primitives/XZoneRects.tsx +67 -0
- package/src/charts/series.test.ts +61 -0
- package/src/charts/series.ts +18 -1
- package/src/charts/utils/measure-text.test.ts +36 -0
- package/src/charts/utils/measure-text.ts +109 -0
- package/src/content/fence-block.tsx +2 -3
- package/src/shell/app-sidebar.module.css +11 -0
- package/src/shell/app-sidebar.module.css.d.ts +1 -0
- package/src/shell/app-sidebar.test.tsx +98 -0
- package/src/shell/app-sidebar.tsx +22 -0
- package/src/shell/index.test.tsx +35 -0
- package/src/shell/index.tsx +11 -0
- package/src/surfaces.ts +2 -1
- package/src/theme/spacing.test.ts +2 -0
- package/src/tokens/chart-margin.test.ts +39 -0
- package/src/tokens/density.test.ts +1 -0
- package/src/tokens/index.ts +36 -0
- package/src/tokens/palette.ts +3 -0
- package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
- package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
- package/dist/charts/hooks/useChartTooltip.js +0 -26
- package/dist/charts/hooks/useHoverSync.d.ts +0 -46
- package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
- package/dist/charts/hooks/useHoverSync.js +0 -75
- package/dist/charts/hover-context.d.ts +0 -21
- package/dist/charts/hover-context.d.ts.map +0 -1
- package/dist/charts/hover-context.js +0 -12
- package/dist/charts/hover-sync.d.ts +0 -14
- package/dist/charts/hover-sync.d.ts.map +0 -1
- package/dist/charts/hover-sync.js +0 -17
- package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
- package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
- package/dist/charts/primitives/ResponsiveChart.js +0 -24
- package/src/charts/hooks/useChartTooltip.ts +0 -31
- package/src/charts/hooks/useHoverSync.ts +0 -152
- package/src/charts/hover-context.ts +0 -28
- package/src/charts/hover-sync.tsx +0 -37
- package/src/charts/primitives/ResponsiveChart.tsx +0 -64
|
@@ -1,31 +1,15 @@
|
|
|
1
1
|
import { curveMonotoneX } from '@visx/curve'
|
|
2
|
-
import { GridRows } from '@visx/grid'
|
|
3
|
-
import { Group } from '@visx/group'
|
|
4
|
-
import { scaleLinear, scalePoint } from '@visx/scale'
|
|
5
2
|
import { AreaClosed, LinePath } from '@visx/shape'
|
|
6
3
|
import { Threshold } from '@visx/threshold'
|
|
7
4
|
import { memo, useMemo } from 'react'
|
|
8
|
-
import type { ReactNode } from 'react'
|
|
9
5
|
import { AreaGradient, areaFillUrl } from '../primitives/AreaGradient'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
TooltipBody,
|
|
14
|
-
TooltipHeader,
|
|
15
|
-
TooltipRow,
|
|
16
|
-
useTooltipStyles,
|
|
17
|
-
} from '../primitives/ChartTooltip'
|
|
18
|
-
import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
|
|
19
|
-
import { Crosshair, SeriesDot } from '../primitives/Crosshair'
|
|
20
|
-
import { HoverOverlay } from '../primitives/HoverOverlay'
|
|
21
|
-
import { ZoneRects } from '../primitives/ZoneRects'
|
|
6
|
+
import type { CartesianTooltipConfig, AxisConfig, PlotContext } from '../primitives/CartesianChart'
|
|
7
|
+
import { CartesianChart } from '../primitives/CartesianChart'
|
|
8
|
+
import type { XZoneSpec } from '../primitives/XZoneRects'
|
|
22
9
|
import type { ZoneSpec } from '../primitives/ZoneRects'
|
|
23
|
-
import {
|
|
24
|
-
import { deriveTooltipRows, LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
10
|
+
import { LINE_OVERLAY_STROKE_WIDTH } from '../series'
|
|
25
11
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
26
12
|
import { VX } from '../../tokens'
|
|
27
|
-
import { padAutoLower } from '../utils/domain'
|
|
28
|
-
import { smartTicks, smartTicksEvery } from '../utils/ticks'
|
|
29
13
|
|
|
30
14
|
/** @deprecated Use ZoneSpec from primitives/ZoneRects. Kept as an alias for back-compat. */
|
|
31
15
|
export type ZonedLineZone = ZoneSpec
|
|
@@ -45,50 +29,31 @@ export type ZonedLineRefLine = {
|
|
|
45
29
|
dashed?: boolean
|
|
46
30
|
}
|
|
47
31
|
|
|
48
|
-
export type ZonedLineTooltipLabel = {
|
|
49
|
-
text: string
|
|
50
|
-
color: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
32
|
export type ZonedLineProps<T> = {
|
|
54
33
|
data: T[]
|
|
55
|
-
/** Fixed height in pixels, forwarded to
|
|
34
|
+
/** Fixed height in pixels, forwarded to `CartesianChart`. Default 240. */
|
|
56
35
|
height?: number
|
|
57
36
|
chartId: string
|
|
58
37
|
/** Extracts the x-axis category (date string) from a data point. */
|
|
59
38
|
getX: (d: T) => string
|
|
60
39
|
/** Single-series line — the sole source of truth for color, dash, legend, and tooltip row. Pass
|
|
61
|
-
* exactly one entry (kept as an array for parity with the other kinds and so `
|
|
40
|
+
* exactly one entry (kept as an array for parity with the other kinds and so `CartesianChart` /
|
|
62
41
|
* `deriveLegend` / `deriveTooltipRows` can consume it directly). */
|
|
63
42
|
series: ChartSeries<T>[]
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* When yDomain is 'auto': the upper bound is at least this value (caps data max).
|
|
68
|
-
* e.g. yAutoMaxFloor=2 guarantees the y-axis always reaches 2 even if data is smaller.
|
|
69
|
-
*/
|
|
70
|
-
yAutoMaxFloor?: number
|
|
71
|
-
/**
|
|
72
|
-
* When yDomain is 'auto': the lower bound is at most this value.
|
|
73
|
-
* Default 0 — always includes zero when data is all positive. Pass a negative
|
|
74
|
-
* number (or Infinity to disable) for metrics that can legitimately swing both ways.
|
|
75
|
-
*/
|
|
76
|
-
yAutoMinCeil?: number
|
|
77
|
-
/** Padding multiplier applied to auto-computed bounds. Default 1.1. The lower bound always pads
|
|
78
|
-
* DOWN from `min(dataMin, yAutoMinCeil)` (never up, which would clip data) — see
|
|
79
|
-
* {@link padAutoLower}. */
|
|
80
|
-
yAutoPad?: number
|
|
43
|
+
/** Y-axis. Collapses the old `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` /
|
|
44
|
+
* `numTicksY` / `formatYTick` prop soup into one object. */
|
|
45
|
+
y?: AxisConfig<T>
|
|
81
46
|
zones?: ZonedLineZone[]
|
|
47
|
+
/** Vertical x-range overlays (time windows), rendered behind the line. Bounds are `getX`
|
|
48
|
+
* domain keys. */
|
|
49
|
+
xZones?: XZoneSpec[]
|
|
82
50
|
thresholds?: ZonedLineThreshold[]
|
|
83
51
|
refLines?: ZonedLineRefLine[]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
formatValue: (v: number) => string
|
|
90
|
-
/** Optional extra tooltip rows (rendered after the main row). */
|
|
91
|
-
renderExtraTooltipRows?: (d: T) => ReactNode
|
|
52
|
+
/** Exact number of x ticks. Default: as many as fit. */
|
|
53
|
+
xTicks?: number
|
|
54
|
+
/** Tooltip config — `label` for a right-aligned header badge (e.g. zone name with zone color),
|
|
55
|
+
* `extraRows` for rows appended after the derived row. `false` disables the tooltip entirely. */
|
|
56
|
+
tooltip?: CartesianTooltipConfig<T> | false
|
|
92
57
|
/**
|
|
93
58
|
* Opt-in soft gradient fill under the line. Pass a color token to tint the area
|
|
94
59
|
* with that hue — the modern single-hue look. `true` falls back to the series color. Off by
|
|
@@ -96,83 +61,124 @@ export type ZonedLineProps<T> = {
|
|
|
96
61
|
* via `--vx-area-top` / `--vx-area-bottom` (tunable in the dev theme lab).
|
|
97
62
|
*/
|
|
98
63
|
areaFill?: string | boolean
|
|
99
|
-
/** Legend config forwarded to `
|
|
64
|
+
/** Legend config forwarded to `CartesianChart`; `false` disables the legend (sparkline escape).
|
|
100
65
|
* Default `{ placement: 'bottom' }`. */
|
|
101
66
|
legend?: ChartLegendConfig | false
|
|
102
|
-
/** Accessible text alternative, forwarded to `
|
|
67
|
+
/** Accessible text alternative, forwarded to `CartesianChart` as `aria-label` (+ `role="img"`). */
|
|
103
68
|
ariaLabel?: string
|
|
104
|
-
/** Forwarded to `
|
|
69
|
+
/** Forwarded to `CartesianChart` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
105
70
|
isPending?: boolean
|
|
106
71
|
}
|
|
107
72
|
|
|
73
|
+
type Valid<T> = T & { __y: number }
|
|
74
|
+
|
|
75
|
+
/** The primary (and only) series' valid points — null values are dropped, creating line gaps. */
|
|
76
|
+
function validPoints<T>(series: ChartSeries<T> | undefined, data: readonly T[]): Valid<T>[] {
|
|
77
|
+
if (!series) return []
|
|
78
|
+
const out: Valid<T>[] = []
|
|
79
|
+
for (const d of data) {
|
|
80
|
+
const y = series.getValue(d)
|
|
81
|
+
if (y !== null && y !== undefined && !Number.isNaN(y)) {
|
|
82
|
+
out.push(Object.assign({}, d, { __y: y }) as Valid<T>)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
108
88
|
/**
|
|
109
|
-
*
|
|
110
|
-
* shared-cursor tooltip. Covers the line-with-zones pattern. Does NOT
|
|
111
|
-
* dual-panel charts (keep those bespoke).
|
|
89
|
+
* Single-series line chart with zone backgrounds, threshold fills, an optional area gradient,
|
|
90
|
+
* reference lines, and a shared-cursor tooltip. Covers the line-with-zones pattern. Does NOT
|
|
91
|
+
* handle dual-panel charts (keep those bespoke).
|
|
112
92
|
*
|
|
113
|
-
* Composes `
|
|
114
|
-
*
|
|
93
|
+
* Composes `CartesianChart` for measuring, scales, grid, zones/x-zones, axes, the shared cursor,
|
|
94
|
+
* and the derived tooltip — this kind draws ONLY the marks (thresholds, area fill, and the line).
|
|
95
|
+
* Single-series, so the legend is optional in practice but present by default.
|
|
115
96
|
*
|
|
116
97
|
* X-axis is built from the full `data` array so the calendar is preserved even
|
|
117
98
|
* when the series has nulls; the line itself skips null points (creating
|
|
118
99
|
* visual gaps).
|
|
119
100
|
*/
|
|
120
101
|
function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
121
|
-
const {
|
|
102
|
+
const {
|
|
103
|
+
data,
|
|
104
|
+
chartId,
|
|
105
|
+
getX,
|
|
106
|
+
series,
|
|
107
|
+
y,
|
|
108
|
+
zones,
|
|
109
|
+
xZones,
|
|
110
|
+
thresholds,
|
|
111
|
+
refLines,
|
|
112
|
+
xTicks,
|
|
113
|
+
tooltip,
|
|
114
|
+
areaFill,
|
|
115
|
+
height,
|
|
116
|
+
legend,
|
|
117
|
+
ariaLabel,
|
|
118
|
+
isPending,
|
|
119
|
+
} = props
|
|
122
120
|
|
|
123
121
|
// Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
|
|
124
122
|
// once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
|
|
125
123
|
const styledSeries = useMemo<ChartSeries<T>[]>(
|
|
126
|
-
() =>
|
|
124
|
+
() =>
|
|
125
|
+
series.map((s) => ({
|
|
126
|
+
...s,
|
|
127
|
+
strokeWidth: s.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH,
|
|
128
|
+
})),
|
|
127
129
|
[series],
|
|
128
130
|
)
|
|
129
131
|
|
|
130
132
|
return (
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
+
<CartesianChart
|
|
134
|
+
data={data}
|
|
133
135
|
chartId={chartId}
|
|
136
|
+
getX={getX}
|
|
137
|
+
series={styledSeries}
|
|
138
|
+
{...(y !== undefined && { y })}
|
|
139
|
+
{...(zones !== undefined && { zones })}
|
|
140
|
+
{...(xZones !== undefined && { xZones })}
|
|
141
|
+
{...(refLines !== undefined && { refLines })}
|
|
142
|
+
{...(xTicks !== undefined && { xTicks })}
|
|
143
|
+
{...(tooltip !== undefined && { tooltip })}
|
|
134
144
|
{...(height !== undefined && { height })}
|
|
145
|
+
{...(legend !== undefined && { legend })}
|
|
135
146
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
136
147
|
{...(isPending !== undefined && { isPending })}
|
|
137
|
-
legend={resolveLegend(legend)}
|
|
138
148
|
>
|
|
139
|
-
{(
|
|
140
|
-
|
|
149
|
+
{(ctx: PlotContext<T>) => (
|
|
150
|
+
<ZonedLineMarks
|
|
151
|
+
getX={getX}
|
|
152
|
+
chartId={chartId}
|
|
153
|
+
thresholds={thresholds ?? []}
|
|
154
|
+
areaFill={areaFill}
|
|
155
|
+
ctx={ctx}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</CartesianChart>
|
|
141
159
|
)
|
|
142
160
|
}
|
|
143
161
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
numTicksX,
|
|
165
|
-
tooltipLabel,
|
|
166
|
-
formatValue,
|
|
167
|
-
renderExtraTooltipRows,
|
|
168
|
-
areaFill,
|
|
169
|
-
plot,
|
|
170
|
-
} = props
|
|
171
|
-
|
|
172
|
-
const primary = series[0]
|
|
173
|
-
const MARGIN = VX.margin
|
|
174
|
-
const xMax = plot.width - MARGIN.left - MARGIN.right
|
|
175
|
-
const yMax = plot.height - MARGIN.top - MARGIN.bottom
|
|
162
|
+
/** Draws the threshold fills, the optional area gradient, and the line for the (single) visible
|
|
163
|
+
* series. Hidden via the legend toggle draws nothing, same as every other kind. */
|
|
164
|
+
function ZonedLineMarks<T>({
|
|
165
|
+
getX,
|
|
166
|
+
chartId,
|
|
167
|
+
thresholds,
|
|
168
|
+
areaFill,
|
|
169
|
+
ctx,
|
|
170
|
+
}: {
|
|
171
|
+
getX: (d: T) => string
|
|
172
|
+
chartId: string
|
|
173
|
+
thresholds: ZonedLineThreshold[]
|
|
174
|
+
areaFill: string | boolean | undefined
|
|
175
|
+
ctx: PlotContext<T>
|
|
176
|
+
}) {
|
|
177
|
+
const { data, visible, xScale, yScale, yMax } = ctx
|
|
178
|
+
const primary = visible[0]
|
|
179
|
+
// Memoized: the shared cursor re-renders every chart on every pointer frame, and this both
|
|
180
|
+
// walks the full series and clones an object per point.
|
|
181
|
+
const valid = useMemo(() => validPoints(primary, data), [primary, data])
|
|
176
182
|
|
|
177
183
|
// Area is opt-in: pass a color token to get a cohesive single-hue fill under the line.
|
|
178
184
|
// (A neutral fill under the neutral line just reads as grey haze, so there is no default-on.)
|
|
@@ -180,164 +186,51 @@ function ZonedLinePlot<T>(props: ZonedLinePlotProps<T>) {
|
|
|
180
186
|
const areaColor = typeof areaFill === 'string' ? areaFill : primary?.color
|
|
181
187
|
const areaId = `${chartId}-area`
|
|
182
188
|
|
|
183
|
-
type Valid = T & { __y: number }
|
|
184
|
-
const valid = useMemo<Valid[]>(() => {
|
|
185
|
-
const out: Valid[] = []
|
|
186
|
-
for (const d of data) {
|
|
187
|
-
const y = primary?.getValue(d) ?? null
|
|
188
|
-
if (y !== null && y !== undefined && !Number.isNaN(y)) {
|
|
189
|
-
out.push(Object.assign({}, d, { __y: y }) as Valid)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return out
|
|
193
|
-
}, [data, primary])
|
|
194
|
-
|
|
195
|
-
const xScale = useMemo(
|
|
196
|
-
() =>
|
|
197
|
-
scalePoint<string>({
|
|
198
|
-
// Full calendar — axis does not compress across nulls.
|
|
199
|
-
domain: data.map(getX),
|
|
200
|
-
range: [0, xMax],
|
|
201
|
-
padding: 0.3,
|
|
202
|
-
}),
|
|
203
|
-
[data, xMax, getX],
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
const yScale = useMemo(() => {
|
|
207
|
-
if (yDomain === 'auto') {
|
|
208
|
-
const ys = valid.map((d) => d.__y)
|
|
209
|
-
const dataMax = ys.length ? Math.max(...ys) : 0
|
|
210
|
-
const dataMin = ys.length ? Math.min(...ys) : 0
|
|
211
|
-
const upper = Math.max(dataMax, yAutoMaxFloor ?? dataMax) * yAutoPad
|
|
212
|
-
const lower = padAutoLower(Math.min(dataMin, yAutoMinCeil), yAutoPad)
|
|
213
|
-
return scaleLinear<number>({ domain: [lower, upper], range: [yMax, 0], nice: true })
|
|
214
|
-
}
|
|
215
|
-
return scaleLinear<number>({ domain: yDomain, range: [yMax, 0] })
|
|
216
|
-
}, [valid, yDomain, yMax, yAutoPad, yAutoMaxFloor, yAutoMinCeil])
|
|
217
|
-
|
|
218
|
-
const tooltipStyles = useTooltipStyles()
|
|
219
|
-
const { tip, tooltipRef, syncedPoint, isDirectHover, handleMouse, handleLeave } =
|
|
220
|
-
useHoverSync<Valid>({
|
|
221
|
-
data: valid,
|
|
222
|
-
chartId,
|
|
223
|
-
getKey: getX,
|
|
224
|
-
xScale,
|
|
225
|
-
marginLeft: MARGIN.left,
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
const tickValues = useMemo(
|
|
229
|
-
() =>
|
|
230
|
-
numTicksX ? smartTicksEvery(data.map(getX), numTicksX) : smartTicks(data.map(getX), xMax),
|
|
231
|
-
[data, xMax, getX, numTicksX],
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
const tooltipLbl = tip ? (tooltipLabel?.(tip.data) ?? null) : null
|
|
235
|
-
|
|
236
189
|
return (
|
|
237
190
|
<>
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
/>
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
)}
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
y1={yScale(r.value)}
|
|
282
|
-
y2={yScale(r.value)}
|
|
283
|
-
stroke={r.color}
|
|
284
|
-
strokeDasharray={r.dashed ? VX.dashArray : undefined}
|
|
285
|
-
/>
|
|
286
|
-
))}
|
|
287
|
-
|
|
288
|
-
{primary && (
|
|
289
|
-
<LinePath<Valid>
|
|
290
|
-
data={valid}
|
|
291
|
-
x={(d) => xScale(getX(d)) ?? 0}
|
|
292
|
-
y={(d) => yScale(d.__y)}
|
|
293
|
-
stroke={primary.color}
|
|
294
|
-
strokeWidth={primary.strokeWidth ?? VX.lineWidth}
|
|
295
|
-
strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
|
|
296
|
-
curve={curveMonotoneX}
|
|
297
|
-
/>
|
|
298
|
-
)}
|
|
299
|
-
|
|
300
|
-
{syncedPoint && primary && (
|
|
301
|
-
<>
|
|
302
|
-
<Crosshair x={xScale(getX(syncedPoint)) ?? 0} top={0} bottom={yMax} />
|
|
303
|
-
<SeriesDot
|
|
304
|
-
cx={xScale(getX(syncedPoint)) ?? 0}
|
|
305
|
-
cy={yScale(syncedPoint.__y)}
|
|
306
|
-
color={primary.color}
|
|
307
|
-
/>
|
|
308
|
-
</>
|
|
309
|
-
)}
|
|
310
|
-
|
|
311
|
-
<AxisLeftNumeric scale={yScale} numTicks={numTicksY} />
|
|
312
|
-
<AxisBottomDate top={yMax} scale={xScale} tickValues={tickValues} />
|
|
313
|
-
|
|
314
|
-
<HoverOverlay width={xMax} height={yMax} onMove={handleMouse} onLeave={handleLeave} />
|
|
315
|
-
</Group>
|
|
316
|
-
</svg>
|
|
317
|
-
<ChartTooltip tip={isDirectHover ? tip : null} tooltipRef={tooltipRef} styles={tooltipStyles}>
|
|
318
|
-
{tip && isDirectHover && (
|
|
319
|
-
<>
|
|
320
|
-
<TooltipHeader
|
|
321
|
-
date={getX(tip.data)}
|
|
322
|
-
{...(tooltipLbl !== null && { label: tooltipLbl.text, labelColor: tooltipLbl.color })}
|
|
323
|
-
/>
|
|
324
|
-
<TooltipBody>
|
|
325
|
-
{deriveTooltipRows(series, tip.data, formatValue).map((row) => (
|
|
326
|
-
<TooltipRow
|
|
327
|
-
key={row.key}
|
|
328
|
-
color={row.color}
|
|
329
|
-
label={row.label}
|
|
330
|
-
value={row.value}
|
|
331
|
-
shape={row.shape}
|
|
332
|
-
dashed={row.dashed}
|
|
333
|
-
{...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
|
|
334
|
-
/>
|
|
335
|
-
))}
|
|
336
|
-
{renderExtraTooltipRows?.(tip.data)}
|
|
337
|
-
</TooltipBody>
|
|
338
|
-
</>
|
|
339
|
-
)}
|
|
340
|
-
</ChartTooltip>
|
|
191
|
+
{thresholds.map((t, i) => (
|
|
192
|
+
<Threshold<Valid<T>>
|
|
193
|
+
key={`thr-${i}`}
|
|
194
|
+
id={`${chartId}-thr-${i}`}
|
|
195
|
+
data={valid}
|
|
196
|
+
x={(d) => xScale(getX(d)) ?? 0}
|
|
197
|
+
y0={() => yScale(t.value)}
|
|
198
|
+
y1={(d) => yScale(d.__y)}
|
|
199
|
+
clipAboveTo={0}
|
|
200
|
+
clipBelowTo={yMax}
|
|
201
|
+
curve={curveMonotoneX}
|
|
202
|
+
belowAreaProps={{ fill: t.side === 'above' ? t.fill : 'transparent' }}
|
|
203
|
+
aboveAreaProps={{ fill: t.side === 'below' ? t.fill : 'transparent' }}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
|
|
207
|
+
{showArea && areaColor !== undefined && (
|
|
208
|
+
<>
|
|
209
|
+
<defs>
|
|
210
|
+
<AreaGradient id={areaId} color={areaColor} />
|
|
211
|
+
</defs>
|
|
212
|
+
<AreaClosed<Valid<T>>
|
|
213
|
+
data={valid}
|
|
214
|
+
x={(d) => xScale(getX(d)) ?? 0}
|
|
215
|
+
y={(d) => yScale(d.__y)}
|
|
216
|
+
yScale={yScale}
|
|
217
|
+
curve={curveMonotoneX}
|
|
218
|
+
fill={areaFillUrl(areaId)}
|
|
219
|
+
/>
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{primary && (
|
|
224
|
+
<LinePath<Valid<T>>
|
|
225
|
+
data={valid}
|
|
226
|
+
x={(d) => xScale(getX(d)) ?? 0}
|
|
227
|
+
y={(d) => yScale(d.__y)}
|
|
228
|
+
stroke={primary.color}
|
|
229
|
+
strokeWidth={primary.strokeWidth ?? LINE_OVERLAY_STROKE_WIDTH}
|
|
230
|
+
strokeDasharray={primary.dash === 'dashed' ? VX.dashArray : undefined}
|
|
231
|
+
curve={curveMonotoneX}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
341
234
|
</>
|
|
342
235
|
)
|
|
343
236
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { VX } from '../../tokens'
|
|
3
|
+
import { autoMargin } from './auto-margin'
|
|
4
|
+
|
|
5
|
+
describe('autoMargin', () => {
|
|
6
|
+
test('never goes below the VX.margin floor', () => {
|
|
7
|
+
const m = autoMargin()
|
|
8
|
+
expect(m.top).toBe(VX.margin.top)
|
|
9
|
+
expect(m.right).toBeGreaterThanOrEqual(VX.margin.right)
|
|
10
|
+
expect(m.bottom).toBeGreaterThanOrEqual(VX.margin.bottom)
|
|
11
|
+
expect(m.left).toBeGreaterThanOrEqual(VX.margin.left)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('a wide left label widens the left gutter', () => {
|
|
15
|
+
const narrow = autoMargin({ left: ['0', '5'] })
|
|
16
|
+
const wide = autoMargin({ left: ['0', '1,250,000'] })
|
|
17
|
+
expect(wide.left).toBeGreaterThan(narrow.left)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('right-axis labels widen the right gutter past the floor', () => {
|
|
21
|
+
const m = autoMargin({ right: ['$1,000.00'] })
|
|
22
|
+
expect(m.right).toBeGreaterThan(VX.margin.right)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('without a right axis, the right gutter still reserves half the widest x label', () => {
|
|
26
|
+
// The last x tick is centred on the plot's right edge, so half its label hangs past it.
|
|
27
|
+
const m = autoMargin({ bottom: ['2026-08-18T12:00'] })
|
|
28
|
+
expect(m.right).toBeGreaterThan(VX.margin.right)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('rotated x labels deepen the bottom gutter', () => {
|
|
32
|
+
const flat = autoMargin({ bottom: ['a long category label'] })
|
|
33
|
+
const rotated = autoMargin({ bottom: ['a long category label'], rotate: 45 })
|
|
34
|
+
expect(rotated.bottom).toBeGreaterThan(flat.bottom)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('an explicit override wins over measurement', () => {
|
|
38
|
+
const m = autoMargin({ left: ['1,250,000'], override: { left: 4, top: 0 } })
|
|
39
|
+
expect(m.left).toBe(4)
|
|
40
|
+
expect(m.top).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plot-rect margins derived from the labels that will actually be painted.
|
|
3
|
+
*
|
|
4
|
+
* `VX.margin` is a static token, so charts used to clip (or get hand-nudged) whenever a tick label
|
|
5
|
+
* outgrew it — the "I always push the charts around" failure mode. Here the token becomes a
|
|
6
|
+
* FLOOR: a measured side may only ever grow past it, never shrink below it, so no chart gets
|
|
7
|
+
* tighter than it was, and a wide label widens its own gutter automatically.
|
|
8
|
+
*
|
|
9
|
+
* See `docs/CHARTS-SPEC.md` §1.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { scaleLinear } from '@visx/scale'
|
|
13
|
+
import { VX } from '../../tokens'
|
|
14
|
+
import type { ChartMargin } from '../../tokens'
|
|
15
|
+
import { maxTextWidth } from '../utils/measure-text'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Distance from the plot edge to the near edge of a tick LABEL: visx's default `tickLength` (8)
|
|
19
|
+
* plus the ±4 `dx` the themed axes in `primitives/Axes.tsx` apply. Kept in one place so the
|
|
20
|
+
* measured gutter and the painted axis agree.
|
|
21
|
+
*/
|
|
22
|
+
const AXIS_TICK_GAP = 12
|
|
23
|
+
|
|
24
|
+
/** Line box of a single-line tick label at `fontPx`. */
|
|
25
|
+
const lineHeight = (fontPx: number): number => Math.ceil(fontPx * 1.35)
|
|
26
|
+
|
|
27
|
+
export type AutoMarginInput = {
|
|
28
|
+
/** Left-axis tick labels, already formatted. */
|
|
29
|
+
left?: readonly string[]
|
|
30
|
+
/** Right-axis tick labels, already formatted. Presence (not a flag) is what widens `right`. */
|
|
31
|
+
right?: readonly string[]
|
|
32
|
+
/** X-axis tick labels, already formatted. */
|
|
33
|
+
bottom?: readonly string[]
|
|
34
|
+
/** Degrees of counter-clockwise rotation applied to the x tick labels. Default 0. */
|
|
35
|
+
rotate?: number
|
|
36
|
+
/** Tick-label font size. Default `VX.axisFont`. */
|
|
37
|
+
fontPx?: number
|
|
38
|
+
/** Explicit per-side overrides — applied LAST, so an escape hatch always wins. */
|
|
39
|
+
override?: Partial<ChartMargin>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the four margins from measured labels.
|
|
44
|
+
*
|
|
45
|
+
* Law, per side:
|
|
46
|
+
* - `left` — widest left label + gap, floored at `VX.margin.left`.
|
|
47
|
+
* - `right` — with a right axis: widest right label + gap. Without one: half the widest x label,
|
|
48
|
+
* so the LAST x tick (centered on the plot's right edge) cannot clip. Both floored at
|
|
49
|
+
* `VX.margin.right`.
|
|
50
|
+
* - `bottom` — one label line (or the rotated bounding height) + gap, floored at
|
|
51
|
+
* `VX.margin.bottom`.
|
|
52
|
+
* - `top` — `VX.margin.top`. Nothing measures into it; a chart that draws into the top gutter
|
|
53
|
+
* passes an override.
|
|
54
|
+
*/
|
|
55
|
+
export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
|
|
56
|
+
const fontPx = input.fontPx ?? VX.axisFont
|
|
57
|
+
const { left = [], right = [], bottom = [], rotate = 0, override } = input
|
|
58
|
+
|
|
59
|
+
const leftWidth = maxTextWidth(left, fontPx)
|
|
60
|
+
const rightWidth = maxTextWidth(right, fontPx)
|
|
61
|
+
const bottomWidth = maxTextWidth(bottom, fontPx)
|
|
62
|
+
|
|
63
|
+
const radians = (rotate * Math.PI) / 180
|
|
64
|
+
const bottomExtent =
|
|
65
|
+
rotate === 0
|
|
66
|
+
? lineHeight(fontPx)
|
|
67
|
+
: Math.ceil(
|
|
68
|
+
Math.abs(bottomWidth * Math.sin(radians)) +
|
|
69
|
+
Math.abs(lineHeight(fontPx) * Math.cos(radians)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
top: override?.top ?? VX.margin.top,
|
|
74
|
+
right:
|
|
75
|
+
override?.right ??
|
|
76
|
+
Math.ceil(
|
|
77
|
+
Math.max(VX.margin.right, right.length > 0 ? rightWidth + AXIS_TICK_GAP : bottomWidth / 2),
|
|
78
|
+
),
|
|
79
|
+
bottom: override?.bottom ?? Math.ceil(Math.max(VX.margin.bottom, bottomExtent + AXIS_TICK_GAP)),
|
|
80
|
+
left:
|
|
81
|
+
override?.left ??
|
|
82
|
+
Math.ceil(Math.max(VX.margin.left, left.length > 0 ? leftWidth + AXIS_TICK_GAP : 0)),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The tick labels a linear axis WILL paint, derived before the plot rect exists.
|
|
88
|
+
*
|
|
89
|
+
* This is what breaks the margin/scale circularity: a linear scale's tick VALUES depend only on
|
|
90
|
+
* its domain, so a throwaway-range probe yields the exact label strings {@link autoMargin} needs
|
|
91
|
+
* to measure — and returning the resolved `format` alongside them guarantees the measured string
|
|
92
|
+
* and the painted string are the same one. Shared by `CartesianChart` and by hand-composed kinds
|
|
93
|
+
* (`DualPanel`) so the two can't drift.
|
|
94
|
+
*/
|
|
95
|
+
export function probeAxisLabels(opts: {
|
|
96
|
+
domain: [number, number]
|
|
97
|
+
ticks: number
|
|
98
|
+
/** Overrides d3's own tick format. */
|
|
99
|
+
format?: (v: number) => string
|
|
100
|
+
/** Must match the real scale's `nice` — niceing changes which ticks exist. */
|
|
101
|
+
nice?: boolean
|
|
102
|
+
}): { labels: string[]; format: (v: number) => string } {
|
|
103
|
+
const probe = scaleLinear<number>({
|
|
104
|
+
domain: opts.domain,
|
|
105
|
+
range: [1, 0],
|
|
106
|
+
...(opts.nice === true && { nice: true }),
|
|
107
|
+
})
|
|
108
|
+
const format = opts.format ?? probe.tickFormat(opts.ticks)
|
|
109
|
+
return { labels: probe.ticks(opts.ticks).map(format), format }
|
|
110
|
+
}
|