basalt-ui 2.3.0 → 2.4.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 +18 -0
- package/MIGRATING.md +57 -12
- package/agent/rules/basalt-state.md +1 -1
- package/configs/lefthook.yml +4 -1
- package/dist/charts/CartesianChart.d.ts.map +1 -1
- package/dist/charts/CartesianChart.js +6 -2
- package/dist/charts/DualPanel.d.ts +5 -1
- package/dist/charts/DualPanel.d.ts.map +1 -1
- package/dist/charts/DualPanel.js +32 -14
- package/dist/charts/Heatmap.js +2 -2
- package/dist/charts/Lines.js +2 -2
- package/dist/charts/MirroredBars.d.ts.map +1 -1
- package/dist/charts/MirroredBars.js +8 -7
- package/dist/charts/compile.d.ts +15 -2
- package/dist/charts/compile.d.ts.map +1 -1
- package/dist/charts/compile.js +153 -32
- package/dist/charts/marks.d.ts +8 -0
- package/dist/charts/marks.d.ts.map +1 -1
- package/dist/charts/marks.js +22 -1
- package/dist/charts/panel.js +2 -2
- package/dist/charts/types.d.ts +39 -1
- package/dist/charts/types.d.ts.map +1 -1
- package/dist/state/fields.d.ts +19 -3
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +14 -8
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/CartesianChart.tsx +7 -2
- package/src/charts/DualPanel.tsx +37 -15
- package/src/charts/Heatmap.tsx +2 -2
- package/src/charts/Lines.tsx +2 -2
- package/src/charts/MirroredBars.tsx +8 -7
- package/src/charts/compile.ts +203 -35
- package/src/charts/marks.ts +35 -1
- package/src/charts/panel.tsx +2 -2
- package/src/charts/types.ts +37 -1
- package/src/state/fields.ts +55 -19
package/dist/state/fields.js
CHANGED
|
@@ -205,14 +205,14 @@ function assignDefined(into, from) {
|
|
|
205
205
|
if (value !== void 0) into[key] = value;
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
-
function resolveField(entry, search, record) {
|
|
208
|
+
function resolveField(entry, search, record, fallback) {
|
|
209
209
|
const fromUrl = search === null ? null : entry.codec.fromSearch(search);
|
|
210
210
|
if (fromUrl !== null) return fromUrl;
|
|
211
211
|
if (entry.codec.lane.persist) {
|
|
212
212
|
const stored = entry.codec.decode(record[entry.name]);
|
|
213
213
|
if (stored !== null) return stored;
|
|
214
214
|
}
|
|
215
|
-
return entry.codec.fallback;
|
|
215
|
+
return fallback === void 0 ? entry.codec.fallback : fallback;
|
|
216
216
|
}
|
|
217
217
|
function createMemoryLane() {
|
|
218
218
|
const values = /* @__PURE__ */ new Map();
|
|
@@ -232,9 +232,10 @@ function createMemoryLane() {
|
|
|
232
232
|
getVersion: () => version,
|
|
233
233
|
// `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
|
|
234
234
|
// as a legal value, so it can never collide with a written one.
|
|
235
|
-
resolve: (entry) => {
|
|
235
|
+
resolve: (entry, fallback) => {
|
|
236
236
|
const stored = values.get(entry.name);
|
|
237
|
-
|
|
237
|
+
if (stored !== void 0) return stored;
|
|
238
|
+
return fallback === void 0 ? entry.codec.fallback : fallback;
|
|
238
239
|
},
|
|
239
240
|
write: (name, value) => {
|
|
240
241
|
values.set(name, value);
|
|
@@ -342,9 +343,12 @@ function createStoreCore(o) {
|
|
|
342
343
|
},
|
|
343
344
|
readRecord,
|
|
344
345
|
memoryUse(entry) {
|
|
345
|
-
return () => {
|
|
346
|
+
return (read) => {
|
|
346
347
|
useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion);
|
|
347
|
-
return [
|
|
348
|
+
return [
|
|
349
|
+
memory.resolve(entry, read?.fallback),
|
|
350
|
+
(next) => memory.write(entry.name, next)
|
|
351
|
+
];
|
|
348
352
|
};
|
|
349
353
|
},
|
|
350
354
|
readMemoryValue: (entry) => memory.resolve(entry),
|
|
@@ -374,10 +378,12 @@ function createStoreCore(o) {
|
|
|
374
378
|
}
|
|
375
379
|
function createLocalStore(o) {
|
|
376
380
|
const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version });
|
|
377
|
-
const persistedUse = (entry) => () => {
|
|
381
|
+
const persistedUse = (entry) => (read) => {
|
|
378
382
|
const [record, setRecord] = core.usePersistedRecord();
|
|
379
383
|
return [
|
|
380
|
-
|
|
384
|
+
// The call-site fallback is applied here, in render, after the store read — never inside
|
|
385
|
+
// the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
|
|
386
|
+
core.resolve(entry, null, record, read?.fallback),
|
|
381
387
|
(next) => core.writeField(setRecord, entry.name, next)
|
|
382
388
|
];
|
|
383
389
|
};
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# basalt-ui v2.
|
|
1
|
+
# basalt-ui v2.4.0
|
|
2
2
|
# Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets
|
|
3
3
|
#
|
|
4
4
|
# Entry points: use the exact import specifier below; do not guess.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# A subpath's `Install:` line is its required peers; `OptionalPeers:` are import()-only, install on use.
|
|
14
14
|
|
|
15
15
|
Package: basalt-ui
|
|
16
|
-
Version: 2.
|
|
16
|
+
Version: 2.4.0
|
|
17
17
|
|
|
18
18
|
## basalt-ui
|
|
19
19
|
Import: import { ... } from 'basalt-ui'
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useCallback, useId, useMemo, useState } from 'react'
|
|
10
10
|
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
|
11
11
|
import { useInChartCard } from './card-context'
|
|
12
|
-
import { compileCartesian } from './compile'
|
|
12
|
+
import { compileCartesian, refLegendSeries } from './compile'
|
|
13
13
|
import type { CartesianDefinition } from './compile'
|
|
14
14
|
import { ChartEngine, idPrefixOf } from './host'
|
|
15
15
|
import { ChartLegend, legendEntries } from './legend'
|
|
@@ -170,9 +170,14 @@ export function CartesianRung<T, X extends ChartX>(
|
|
|
170
170
|
(ctx: ShellContext) => compileCartesian({ ...stable, ...ctx }),
|
|
171
171
|
[stable],
|
|
172
172
|
)
|
|
173
|
+
// Reference lines that name themselves join the legend after the series (role `reference`).
|
|
174
|
+
const legendSeries = useMemo(
|
|
175
|
+
() => [...stable.series, ...refLegendSeries<T>(stable.refLines)],
|
|
176
|
+
[stable.series, stable.refLines],
|
|
177
|
+
)
|
|
173
178
|
return (
|
|
174
179
|
<ChartShell
|
|
175
|
-
series={
|
|
180
|
+
series={legendSeries}
|
|
176
181
|
compile={compile}
|
|
177
182
|
ariaLabel={ariaLabel}
|
|
178
183
|
{...(legend !== undefined && { legend })}
|
package/src/charts/DualPanel.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { useCallback, useMemo } from 'react'
|
|
|
14
14
|
import type { ReactElement } from 'react'
|
|
15
15
|
import { VX, alpha } from '../tokens'
|
|
16
16
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
17
|
-
import { defaultFormatX, defaultNumberFormat, visibleSeries } from './compile'
|
|
17
|
+
import { defaultFormatX, defaultNumberFormat, engineRow, seriesRow, visibleSeries } from './compile'
|
|
18
18
|
import { barMark, emphasis, lineMark, markersOf, thinFill } from './marks'
|
|
19
19
|
import { PanelRung, paneX, paneY, rowsOf } from './panel'
|
|
20
20
|
import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
|
|
@@ -61,8 +61,10 @@ export type DualPanelBar<T> = {
|
|
|
61
61
|
positive: string
|
|
62
62
|
/** The fill below zero. */
|
|
63
63
|
negative: string
|
|
64
|
-
/** Tick and tooltip
|
|
64
|
+
/** Tick formatter, and the tooltip's too unless `tooltipFormat` is given. */
|
|
65
65
|
format: (v: number) => string
|
|
66
|
+
/** The tooltip row's formatter. Default: `format`. */
|
|
67
|
+
tooltipFormat?: ((v: number) => string) | undefined
|
|
66
68
|
/** The tooltip row's value, for a unit or a decimal `format` cannot carry. */
|
|
67
69
|
formatValue?: ((v: number, d: T) => string) | undefined
|
|
68
70
|
/** Default `'auto'`: a symmetric `[-maxAbs, maxAbs]` from the data, rounded to nice ticks. A tuple
|
|
@@ -95,6 +97,8 @@ export type DualPanelProps<T, X extends Date | number = Date | number> = Pick<
|
|
|
95
97
|
| {
|
|
96
98
|
domain?: readonly [number, number] | 'auto' | undefined
|
|
97
99
|
format?: ((v: number) => string) | undefined
|
|
100
|
+
/** The lines' tooltip formatter. Default: `format`. */
|
|
101
|
+
tooltipFormat?: ((v: number) => string) | undefined
|
|
98
102
|
}
|
|
99
103
|
| undefined
|
|
100
104
|
/**
|
|
@@ -157,6 +161,8 @@ export function DualPanel<T, X extends Date | number = Date | number>(
|
|
|
157
161
|
)
|
|
158
162
|
const all = useMemo(() => [...lines, barSeries], [lines, barSeries])
|
|
159
163
|
const topFormat = top?.format ?? defaultNumberFormat
|
|
164
|
+
const topTooltipFormat = top?.tooltipFormat ?? topFormat
|
|
165
|
+
const barTooltipFormat = bar.tooltipFormat ?? bar.format
|
|
160
166
|
|
|
161
167
|
const plan = useCallback(
|
|
162
168
|
({ hidden, highlighted }: PanelInput): PanelPlan<T> => {
|
|
@@ -307,26 +313,42 @@ export function DualPanel<T, X extends Date | number = Date | number>(
|
|
|
307
313
|
for (const s of visible) {
|
|
308
314
|
const v = s.getValue(d)
|
|
309
315
|
if (s.tooltip === false || v === null || !Number.isFinite(v)) continue
|
|
310
|
-
out.push(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
+
out.push(
|
|
317
|
+
seriesRow(
|
|
318
|
+
s,
|
|
319
|
+
s.formatValue ? s.formatValue(v, d) : topTooltipFormat(v),
|
|
320
|
+
highlighted === s.key,
|
|
321
|
+
),
|
|
322
|
+
)
|
|
316
323
|
}
|
|
317
324
|
const v = barOn ? bar.getValue(d) : null
|
|
318
325
|
if (v !== null && Number.isFinite(v))
|
|
319
|
-
out.push(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
out.push(
|
|
327
|
+
engineRow({
|
|
328
|
+
label: bar.label,
|
|
329
|
+
value: bar.formatValue ? bar.formatValue(v, d) : barTooltipFormat(v),
|
|
330
|
+
color: v >= 0 ? bar.positive : bar.negative,
|
|
331
|
+
active: highlighted === BAR_KEY,
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
325
334
|
return out
|
|
326
335
|
},
|
|
327
336
|
}
|
|
328
337
|
},
|
|
329
|
-
[
|
|
338
|
+
[
|
|
339
|
+
data,
|
|
340
|
+
x,
|
|
341
|
+
lines,
|
|
342
|
+
barSeries,
|
|
343
|
+
top,
|
|
344
|
+
fillBetween,
|
|
345
|
+
bar,
|
|
346
|
+
topFraction,
|
|
347
|
+
topFormat,
|
|
348
|
+
topTooltipFormat,
|
|
349
|
+
barTooltipFormat,
|
|
350
|
+
formatX,
|
|
351
|
+
],
|
|
330
352
|
)
|
|
331
353
|
|
|
332
354
|
return <PanelRung {...rung} series={all} plan={plan} />
|
package/src/charts/Heatmap.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import type { CSSProperties, ReactElement } from 'react'
|
|
|
18
18
|
import type { BasaltProps } from '../common/props'
|
|
19
19
|
import { alpha, VX } from '../tokens'
|
|
20
20
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
21
|
-
import { strideTicks } from './compile'
|
|
21
|
+
import { engineRow, strideTicks } from './compile'
|
|
22
22
|
import type { CartesianDefinition } from './compile'
|
|
23
23
|
import { ChartEngine, idPrefixOf } from './host'
|
|
24
24
|
import { useCoarsePointer } from '../common/use-media-query'
|
|
@@ -244,7 +244,7 @@ export function Heatmap<T>(props: HeatmapProps<T>): ReactElement {
|
|
|
244
244
|
...(point.color && { color: point.color }),
|
|
245
245
|
},
|
|
246
246
|
]
|
|
247
|
-
return { title: header(c), rows: [...value, ...extra] }
|
|
247
|
+
return { title: header(c), rows: [...value, ...extra].map(engineRow) }
|
|
248
248
|
},
|
|
249
249
|
}
|
|
250
250
|
: false,
|
package/src/charts/Lines.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { VX } from '../tokens'
|
|
|
17
17
|
import { CartesianRung } from './CartesianChart'
|
|
18
18
|
import { areaGradientId } from './compile'
|
|
19
19
|
import { chartCurve } from './curves'
|
|
20
|
-
import { lineMark, markersOf, yScaleOf } from './marks'
|
|
20
|
+
import { drawnRows, lineMark, markersOf, yScaleOf } from './marks'
|
|
21
21
|
import { rowX } from './types'
|
|
22
22
|
import type {
|
|
23
23
|
CartesianChartProps,
|
|
@@ -120,7 +120,7 @@ export function Lines<T, X extends ChartX = ChartX>(props: LinesProps<T, X>): Re
|
|
|
120
120
|
if (!s.area) continue
|
|
121
121
|
out.push(
|
|
122
122
|
decorative(
|
|
123
|
-
areaY(plot
|
|
123
|
+
areaY(drawnRows(plot, s), {
|
|
124
124
|
id: `${s.key}-area`,
|
|
125
125
|
x: rowX,
|
|
126
126
|
y2: 'y',
|
|
@@ -21,7 +21,7 @@ import { useCallback, useMemo } from 'react'
|
|
|
21
21
|
import type { ReactElement } from 'react'
|
|
22
22
|
import { VX, alpha } from '../tokens'
|
|
23
23
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
24
|
-
import { defaultFormatX, defaultNumberFormat, visibleSeries } from './compile'
|
|
24
|
+
import { defaultFormatX, defaultNumberFormat, seriesRow, visibleSeries } from './compile'
|
|
25
25
|
import { barMark, emphasis } from './marks'
|
|
26
26
|
import { PanelRung, paneX, paneY, rowsOf } from './panel'
|
|
27
27
|
import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
|
|
@@ -277,12 +277,13 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
|
|
|
277
277
|
// Each pane's row falls back to ITS OWN formatter: the panes are in different units.
|
|
278
278
|
const fallback =
|
|
279
279
|
s.key === up.key ? up.format : s.key === down.key ? down.format : defaultNumberFormat
|
|
280
|
-
out.push(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
280
|
+
out.push(
|
|
281
|
+
seriesRow(
|
|
282
|
+
s,
|
|
283
|
+
s.formatValue ? s.formatValue(v, d) : fallback(v),
|
|
284
|
+
highlighted === s.key,
|
|
285
|
+
),
|
|
286
|
+
)
|
|
286
287
|
}
|
|
287
288
|
return out
|
|
288
289
|
},
|
package/src/charts/compile.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { crosshair } from '@tanstack/charts/crosshair'
|
|
9
9
|
import { decorative } from '@tanstack/charts/mark/decorative'
|
|
10
10
|
import { rect } from '@tanstack/charts/rect'
|
|
11
|
-
import { ruleY } from '@tanstack/charts/rule'
|
|
11
|
+
import { ruleX, ruleY } from '@tanstack/charts/rule'
|
|
12
12
|
import { scaleLinear } from '@tanstack/charts/scales/linear'
|
|
13
13
|
import { scaleUtc } from 'd3-scale'
|
|
14
14
|
import { tooltip as tooltipHost } from '@tanstack/charts/tooltip'
|
|
@@ -27,6 +27,7 @@ import { fmtCompact } from '../format/number'
|
|
|
27
27
|
import { VX } from '../tokens'
|
|
28
28
|
import type { ContainerClass } from '../tokens/size-classes'
|
|
29
29
|
import { cursorBinding, sourceOnlyFocus } from './cursor'
|
|
30
|
+
import { cursorDots } from './marks'
|
|
30
31
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
31
32
|
import { resolveYDomain, xScaleFor, xZoneFractions, yScaleFor } from './scales'
|
|
32
33
|
import type { XScaleModel } from './scales'
|
|
@@ -39,6 +40,8 @@ import type {
|
|
|
39
40
|
ChartTooltipRow,
|
|
40
41
|
ChartX,
|
|
41
42
|
PlotRow,
|
|
43
|
+
RefLine,
|
|
44
|
+
ZoneEdge,
|
|
42
45
|
ZoneSpec,
|
|
43
46
|
} from './types'
|
|
44
47
|
|
|
@@ -136,6 +139,67 @@ export function defaultFormatX(v: ChartValue): string {
|
|
|
136
139
|
const clampTo = (v: number, [lo, hi]: readonly [number, number]): number =>
|
|
137
140
|
Math.min(Math.max(v, Math.min(lo, hi)), Math.max(lo, hi))
|
|
138
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A tooltip swatch is the engine's `background` shorthand, so a dashed one is a gradient: a 2px
|
|
144
|
+
* dashed line across the swatch's middle, in the row color.
|
|
145
|
+
*/
|
|
146
|
+
export const dashedSwatch = (color: string): string =>
|
|
147
|
+
`repeating-linear-gradient(90deg, ${color} 0 2px, transparent 2px 4px) center / 100% 2px no-repeat`
|
|
148
|
+
|
|
149
|
+
/** A basalt tooltip row as the engine's: the swatch shape folded into the row color. */
|
|
150
|
+
export function engineRow(
|
|
151
|
+
row: ChartTooltipRow & { active?: boolean | undefined },
|
|
152
|
+
): EngineTooltipRow {
|
|
153
|
+
const { label, value, color, swatch, active } = row
|
|
154
|
+
return {
|
|
155
|
+
label,
|
|
156
|
+
value,
|
|
157
|
+
...(color !== undefined && { color: swatch === 'dashed' ? dashedSwatch(color) : color }),
|
|
158
|
+
...(active && { active }),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A series row of a tooltip: the label with its note, the swatch following the series' dash. */
|
|
163
|
+
export function seriesRow(
|
|
164
|
+
s: Pick<ChartSeries<never>, 'label' | 'note' | 'color' | 'dash'>,
|
|
165
|
+
value: string,
|
|
166
|
+
active: boolean,
|
|
167
|
+
): EngineTooltipRow {
|
|
168
|
+
return engineRow({
|
|
169
|
+
label: s.note === undefined ? s.label : `${s.label} · ${s.note}`,
|
|
170
|
+
value,
|
|
171
|
+
color: s.color,
|
|
172
|
+
swatch: s.dash === 'dashed' ? 'dashed' : 'solid',
|
|
173
|
+
active,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Keys the compiler reserves for the legend entries it derives from `refLines`. */
|
|
178
|
+
const REF_KEY_PREFIX = '__ref-'
|
|
179
|
+
|
|
180
|
+
/** The legend key of a reference line that names itself (`RefLine.legend`). */
|
|
181
|
+
const refLegendKey = (index: number): string => `${REF_KEY_PREFIX}${index}`
|
|
182
|
+
|
|
183
|
+
/** Legend-only entries for the reference lines that name themselves, in line order. */
|
|
184
|
+
export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): ChartSeries<T>[] {
|
|
185
|
+
return (refLines ?? []).flatMap((r, i) =>
|
|
186
|
+
r.legend === undefined
|
|
187
|
+
? []
|
|
188
|
+
: [
|
|
189
|
+
{
|
|
190
|
+
key: refLegendKey(i),
|
|
191
|
+
label: r.legend.label,
|
|
192
|
+
color: r.color,
|
|
193
|
+
role: 'reference' as const,
|
|
194
|
+
tooltip: false,
|
|
195
|
+
getValue: () => null,
|
|
196
|
+
...(r.legend.mark !== undefined && { mark: r.legend.mark }),
|
|
197
|
+
...(r.dashed && { dash: 'dashed' as const }),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
139
203
|
/** The visible series: not toggled off, and not a companion whose parent is. */
|
|
140
204
|
export function visibleSeries<T>(
|
|
141
205
|
series: readonly ChartSeries<T>[],
|
|
@@ -435,32 +499,93 @@ function resolveAxes<T, X extends ChartX>(
|
|
|
435
499
|
}
|
|
436
500
|
}
|
|
437
501
|
|
|
438
|
-
/**
|
|
502
|
+
/** The sides a `ZoneEdge` names, in a fixed order: the lower bound first. */
|
|
503
|
+
const edgeSides = <A extends string, B extends string>(
|
|
504
|
+
edge: ZoneEdge<A | B> | undefined,
|
|
505
|
+
lower: A,
|
|
506
|
+
upper: B,
|
|
507
|
+
): { lower: boolean; upper: boolean } => ({
|
|
508
|
+
lower: edge !== undefined && (edge.side === lower || edge.side === 'both'),
|
|
509
|
+
upper: edge !== undefined && (edge.side === upper || edge.side === 'both'),
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
const edgePaint = (edge: ZoneEdge<string>) => ({
|
|
513
|
+
stroke: edge.color,
|
|
514
|
+
strokeOpacity: 1,
|
|
515
|
+
strokeWidth: edge.width ?? 1,
|
|
516
|
+
...(edge.dashed && { strokeDasharray: DASH }),
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* An edge rule at `position` when it lies strictly inside `(lo, hi)`: a bound at or past the plot
|
|
521
|
+
* edge (infinite, outside the domain, exactly on it) has no rule, for y- and x-zones alike.
|
|
522
|
+
*/
|
|
523
|
+
const edgeRule = (
|
|
524
|
+
position: number | null,
|
|
525
|
+
[lo, hi]: readonly [number, number],
|
|
526
|
+
make: (at: number) => ChartMarks[number],
|
|
527
|
+
): ChartMarks => (position !== null && position > lo && position < hi ? [make(position)] : [])
|
|
528
|
+
|
|
529
|
+
/** Fixed paint under the kind's marks: zones, then x-zones, each with its edge rules. */
|
|
439
530
|
function underlayMarks<T, X extends ChartX>(
|
|
440
531
|
input: CartesianInput<T, X>,
|
|
441
532
|
model: XScaleModel<X>,
|
|
442
533
|
axes: Axes<T>,
|
|
443
534
|
): ChartMarks {
|
|
444
535
|
const fractionRows = [{ f0: 0, f1: 1 }]
|
|
445
|
-
const zones = (input.zones ?? []).
|
|
536
|
+
const zones = (input.zones ?? []).flatMap((z, i) => {
|
|
446
537
|
const domain = axes.axisOf(z.axisSide).domain
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
538
|
+
const [lo, hi] = [Math.min(...domain), Math.max(...domain)]
|
|
539
|
+
const rule = (value: number, name: string) =>
|
|
540
|
+
edgeRule(value, [lo, hi], (at) =>
|
|
541
|
+
decorative(
|
|
542
|
+
ruleY([at], {
|
|
543
|
+
id: `zone-${i}-${name}`,
|
|
544
|
+
yScale: axes.scaleId(z.axisSide),
|
|
545
|
+
...(z.edge && edgePaint(z.edge)),
|
|
546
|
+
}),
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
const sides = edgeSides(z.edge, 'bottom', 'top')
|
|
550
|
+
return [
|
|
551
|
+
decorative(
|
|
552
|
+
rect(fractionRows, {
|
|
553
|
+
id: `zone-${i}`,
|
|
554
|
+
x1: 'f0',
|
|
555
|
+
x2: 'f1',
|
|
556
|
+
y1: () => clampTo(z.from, domain),
|
|
557
|
+
y2: () => clampTo(z.to, domain),
|
|
558
|
+
xScale: 'zx',
|
|
559
|
+
yScale: axes.scaleId(z.axisSide),
|
|
560
|
+
fill: z.fill,
|
|
561
|
+
inset: 0,
|
|
562
|
+
}),
|
|
563
|
+
),
|
|
564
|
+
...(sides.lower ? rule(Math.min(z.from, z.to), 'bottom') : []),
|
|
565
|
+
...(sides.upper ? rule(Math.max(z.from, z.to), 'top') : []),
|
|
566
|
+
]
|
|
460
567
|
})
|
|
461
568
|
const xZones = (input.xZones ?? []).flatMap((z, i) => {
|
|
462
569
|
const f = xZoneFractions(z, model)
|
|
463
570
|
if (!f) return []
|
|
571
|
+
const sides = edgeSides(z.edge, 'start', 'end')
|
|
572
|
+
// The bound's own plot fraction (widened like the band), unclamped: `f` is clamped to the plot.
|
|
573
|
+
const widen = z.align === 'edge' ? model.halfStep : 0
|
|
574
|
+
const bound = (value: X | undefined, sign: 1 | -1): number | null => {
|
|
575
|
+
const fraction = value === undefined ? null : model.fraction(value)
|
|
576
|
+
return fraction === null ? null : fraction + sign * widen
|
|
577
|
+
}
|
|
578
|
+
const rule = (value: X | undefined, sign: 1 | -1, name: string) =>
|
|
579
|
+
edgeRule(bound(value, sign), [0, 1], (at) =>
|
|
580
|
+
decorative(
|
|
581
|
+
ruleX([at], {
|
|
582
|
+
id: `xzone-${i}-${name}`,
|
|
583
|
+
x: (v: number) => v,
|
|
584
|
+
xScale: 'zx',
|
|
585
|
+
...(z.edge && edgePaint(z.edge)),
|
|
586
|
+
}),
|
|
587
|
+
),
|
|
588
|
+
)
|
|
464
589
|
return [
|
|
465
590
|
decorative(
|
|
466
591
|
rect([{ f0: f[0], f1: f[1] }], {
|
|
@@ -474,6 +599,9 @@ function underlayMarks<T, X extends ChartX>(
|
|
|
474
599
|
inset: 0,
|
|
475
600
|
}),
|
|
476
601
|
),
|
|
602
|
+
// An omitted bound is the plot edge: nothing to draw a rule on.
|
|
603
|
+
...(sides.lower ? rule(z.from, -1, 'start') : []),
|
|
604
|
+
...(sides.upper ? rule(z.to, 1, 'end') : []),
|
|
477
605
|
]
|
|
478
606
|
})
|
|
479
607
|
return [...zones, ...xZones]
|
|
@@ -484,15 +612,20 @@ const refLineMarks = <T, X extends ChartX>(
|
|
|
484
612
|
input: CartesianInput<T, X>,
|
|
485
613
|
axes: Axes<T>,
|
|
486
614
|
): ChartMarks =>
|
|
487
|
-
(input.refLines ?? []).
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
615
|
+
(input.refLines ?? []).flatMap((r, i) =>
|
|
616
|
+
// A line that names itself in the legend can be toggled off there.
|
|
617
|
+
input.hidden?.has(refLegendKey(i))
|
|
618
|
+
? []
|
|
619
|
+
: [
|
|
620
|
+
ruleY([r.value], {
|
|
621
|
+
id: `ref-${i}`,
|
|
622
|
+
stroke: r.color,
|
|
623
|
+
strokeOpacity: 1,
|
|
624
|
+
strokeWidth: 1,
|
|
625
|
+
yScale: axes.scaleId(r.axis),
|
|
626
|
+
...(r.dashed && { strokeDasharray: DASH }),
|
|
627
|
+
}),
|
|
628
|
+
],
|
|
496
629
|
)
|
|
497
630
|
|
|
498
631
|
function buildScales<T, X extends ChartX>(opts: {
|
|
@@ -568,6 +701,25 @@ function rowsOf<T, X extends ChartX>(
|
|
|
568
701
|
}
|
|
569
702
|
}
|
|
570
703
|
|
|
704
|
+
/** The cursor dots of the visible series that opt in with `cursorValue`. */
|
|
705
|
+
function cursorDotMarks<T, X extends ChartX>(
|
|
706
|
+
input: CartesianInput<T, X>,
|
|
707
|
+
visible: readonly ChartSeries<T>[],
|
|
708
|
+
axes: Axes<T>,
|
|
709
|
+
): ChartMarks {
|
|
710
|
+
return visible.flatMap((s) => {
|
|
711
|
+
const { cursorValue } = s
|
|
712
|
+
if (!cursorValue) return []
|
|
713
|
+
const rows = input.data.flatMap((d): PlotRow<T, X>[] => {
|
|
714
|
+
const y = cursorValue(d)
|
|
715
|
+
return y === null || !Number.isFinite(y)
|
|
716
|
+
? []
|
|
717
|
+
: [{ x: input.x(d), y, series: s.key, datum: d }]
|
|
718
|
+
})
|
|
719
|
+
return [cursorDots(s, rows, axes.onRight(s) ? 'y2' : undefined)]
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
|
|
571
723
|
function tooltipSpec<T, X extends ChartX>(opts: {
|
|
572
724
|
input: CartesianInput<T, X>
|
|
573
725
|
axes: Axes<T>
|
|
@@ -599,25 +751,40 @@ function tooltipSpec<T, X extends ChartX>(opts: {
|
|
|
599
751
|
for (const s of rowSeries) {
|
|
600
752
|
const v = s.getValue(d)
|
|
601
753
|
if (v === null || !Number.isFinite(v)) continue
|
|
602
|
-
rows.push(
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
754
|
+
rows.push(
|
|
755
|
+
seriesRow(
|
|
756
|
+
s,
|
|
757
|
+
s.formatValue
|
|
758
|
+
? s.formatValue(v, d)
|
|
759
|
+
: axes.axisOf(axes.onRight(s) ? 'right' : 'left').format(v),
|
|
760
|
+
input.highlighted === s.key,
|
|
761
|
+
),
|
|
762
|
+
)
|
|
610
763
|
}
|
|
611
764
|
const extra: readonly ChartTooltipRow[] =
|
|
612
765
|
(input.tooltip || undefined)?.rows?.(d, { visible, hidden }) ?? []
|
|
613
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
title: tooltipHeader(input, formatX, x, d),
|
|
768
|
+
rows: [...rows, ...extra.map(engineRow)],
|
|
769
|
+
}
|
|
614
770
|
},
|
|
615
771
|
}
|
|
616
772
|
}
|
|
617
773
|
|
|
774
|
+
/** A consumer key in the legend's reserved `__ref-` namespace would collide with a reference line. */
|
|
775
|
+
function guardReservedKeys<T>(series: readonly ChartSeries<T>[]): void {
|
|
776
|
+
if (process.env.NODE_ENV === 'production') return
|
|
777
|
+
const clash = series.find((s) => s.key.startsWith(REF_KEY_PREFIX))
|
|
778
|
+
if (clash)
|
|
779
|
+
throw new Error(
|
|
780
|
+
`[basalt] CartesianChart: the series key "${clash.key}" is reserved (\`${REF_KEY_PREFIX}\` prefix names reference-line legend entries)`,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
618
784
|
export function compileCartesian<T, X extends ChartX>(
|
|
619
785
|
input: CartesianInput<T, X>,
|
|
620
786
|
): CartesianDefinition {
|
|
787
|
+
guardReservedKeys(input.series)
|
|
621
788
|
const visible = visibleSeries(input.series, input.hidden ?? new Set<string>())
|
|
622
789
|
const visibleKeys = new Set(visible.map((s) => s.key))
|
|
623
790
|
const keys = new Set(input.series.map((s) => s.key))
|
|
@@ -625,6 +792,7 @@ export function compileCartesian<T, X extends ChartX>(
|
|
|
625
792
|
const axes = resolveAxes(input, visible)
|
|
626
793
|
const under = underlayMarks(input, model, axes)
|
|
627
794
|
const over = refLineMarks(input, axes)
|
|
795
|
+
const dots = cursorDotMarks(input, visible, axes)
|
|
628
796
|
const rows = rowsOf(input, visibleKeys)
|
|
629
797
|
const formatX = (v: ChartValue): string =>
|
|
630
798
|
input.formatX ? input.formatX(v as X) : defaultFormatX(v)
|
|
@@ -647,7 +815,7 @@ export function compileCartesian<T, X extends ChartX>(
|
|
|
647
815
|
y2Domain: axes.right?.domain,
|
|
648
816
|
})
|
|
649
817
|
return {
|
|
650
|
-
marks: [...under, ...kindMarks, ...over, crosshair({ x: CROSSHAIR, y: false })],
|
|
818
|
+
marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
|
|
651
819
|
scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
|
|
652
820
|
theme: CHART_THEME,
|
|
653
821
|
...(gradients.length > 0 && { gradients }),
|
package/src/charts/marks.ts
CHANGED
|
@@ -30,12 +30,21 @@ export function emphasis<T>(
|
|
|
30
30
|
return highlighted === null || highlighted === s.key || highlighted === s.parent ? 1 : DIMMED
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The rows a series' line and area are drawn through: all of them, or only the non-null ones. */
|
|
34
|
+
export function drawnRows<T, X extends ChartX>(
|
|
35
|
+
plot: PlotContext<T, X>,
|
|
36
|
+
s: Pick<ChartSeries<T>, 'key' | 'gaps'>,
|
|
37
|
+
): PlotRow<T, X>[] {
|
|
38
|
+
const rows = plot.rows(s.key)
|
|
39
|
+
return s.gaps === 'connect' ? rows.filter((r) => r.y !== null) : rows
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export function lineMark<T, X extends ChartX>(
|
|
34
43
|
plot: PlotContext<T, X>,
|
|
35
44
|
s: ChartSeries<T>,
|
|
36
45
|
): ChartMarks[number] {
|
|
37
46
|
const yScale = yScaleOf(plot, s)
|
|
38
|
-
return lineY(plot
|
|
47
|
+
return lineY(drawnRows(plot, s), {
|
|
39
48
|
id: s.key,
|
|
40
49
|
x: rowX,
|
|
41
50
|
y: 'y',
|
|
@@ -96,6 +105,31 @@ export function markersOf<T, X extends ChartX>(
|
|
|
96
105
|
)
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The cursor dot of a series the consumer draws itself (`ChartSeries.cursorValue`): a dot per datum,
|
|
110
|
+
* invisible until the cursor is on that datum's x. It is NOT decorative, so the engine counts it as
|
|
111
|
+
* a focus point and the shared cursor, the focus group and the tooltip reach the series.
|
|
112
|
+
*/
|
|
113
|
+
export function cursorDots<T, X extends ChartX>(
|
|
114
|
+
s: ChartSeries<T>,
|
|
115
|
+
rows: readonly PlotRow<T, X>[],
|
|
116
|
+
yScale: 'y2' | undefined,
|
|
117
|
+
): ChartMarks[number] {
|
|
118
|
+
return dot(rows, {
|
|
119
|
+
id: `${s.key}-cursor`,
|
|
120
|
+
x: rowX,
|
|
121
|
+
y: 'y',
|
|
122
|
+
r: VX.dotR,
|
|
123
|
+
fill: s.color,
|
|
124
|
+
fillOpacity: 0,
|
|
125
|
+
stroke: VX.surface.panel,
|
|
126
|
+
strokeWidth: 1.5,
|
|
127
|
+
strokeOpacity: 0,
|
|
128
|
+
states: [{ when: { focus: 'x' }, style: { fillOpacity: 1, strokeOpacity: 1 } }],
|
|
129
|
+
...(yScale !== undefined && { yScale }),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
99
133
|
/**
|
|
100
134
|
* A single-series `barY`. The engine stacks every `barY` that has no explicit extent, and a row with
|
|
101
135
|
* no `z`/`color` channel is its own series: an N-by-N matrix (a 5k-row `DualPanel` took 10 s). So
|