basalt-ui 2.2.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 +32 -0
- package/MIGRATING.md +78 -12
- package/agent/rules/basalt-state.md +1 -1
- package/configs/lefthook.yml +4 -1
- package/dist/charts/BandStrip.d.ts +7 -7
- package/dist/charts/BandStrip.d.ts.map +1 -1
- package/dist/charts/Bars.d.ts +4 -4
- package/dist/charts/Bars.d.ts.map +1 -1
- package/dist/charts/CartesianChart.d.ts.map +1 -1
- package/dist/charts/CartesianChart.js +6 -2
- package/dist/charts/ChartCard.d.ts +10 -10
- package/dist/charts/ChartCard.d.ts.map +1 -1
- package/dist/charts/ChartCard.js +2 -1
- package/dist/charts/Donut.d.ts +5 -5
- package/dist/charts/Donut.d.ts.map +1 -1
- package/dist/charts/DualPanel.d.ts +14 -10
- package/dist/charts/DualPanel.d.ts.map +1 -1
- package/dist/charts/DualPanel.js +32 -14
- package/dist/charts/Heatmap.d.ts +15 -15
- package/dist/charts/Heatmap.d.ts.map +1 -1
- package/dist/charts/Heatmap.js +2 -2
- package/dist/charts/Lines.js +2 -2
- package/dist/charts/MirroredBars.d.ts +9 -9
- package/dist/charts/MirroredBars.d.ts.map +1 -1
- package/dist/charts/MirroredBars.js +8 -7
- package/dist/charts/Sparkline.d.ts +6 -6
- package/dist/charts/Sparkline.d.ts.map +1 -1
- package/dist/charts/StackedArea.d.ts +2 -2
- package/dist/charts/StackedArea.d.ts.map +1 -1
- package/dist/charts/compile.d.ts +25 -2
- package/dist/charts/compile.d.ts.map +1 -1
- package/dist/charts/compile.js +204 -34
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +3 -1
- 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 +101 -61
- 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/BandStrip.tsx +12 -10
- package/src/charts/Bars.tsx +4 -4
- package/src/charts/CartesianChart.tsx +7 -2
- package/src/charts/ChartCard.tsx +17 -11
- package/src/charts/Donut.tsx +5 -5
- package/src/charts/DualPanel.tsx +50 -21
- package/src/charts/Heatmap.tsx +14 -14
- package/src/charts/Lines.tsx +2 -2
- package/src/charts/MirroredBars.tsx +16 -15
- package/src/charts/Sparkline.tsx +6 -6
- package/src/charts/StackedArea.tsx +2 -2
- package/src/charts/compile.ts +285 -36
- package/src/charts/index.ts +1 -0
- package/src/charts/marks.ts +35 -1
- package/src/charts/panel.tsx +2 -2
- package/src/charts/types.ts +105 -57
- package/src/state/fields.ts +55 -19
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,9 @@ import type {
|
|
|
39
40
|
ChartTooltipRow,
|
|
40
41
|
ChartX,
|
|
41
42
|
PlotRow,
|
|
43
|
+
RefLine,
|
|
44
|
+
ZoneEdge,
|
|
45
|
+
ZoneSpec,
|
|
42
46
|
} from './types'
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -135,6 +139,67 @@ export function defaultFormatX(v: ChartValue): string {
|
|
|
135
139
|
const clampTo = (v: number, [lo, hi]: readonly [number, number]): number =>
|
|
136
140
|
Math.min(Math.max(v, Math.min(lo, hi)), Math.max(lo, hi))
|
|
137
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
|
+
|
|
138
203
|
/** The visible series: not toggled off, and not a companion whose parent is. */
|
|
139
204
|
export function visibleSeries<T>(
|
|
140
205
|
series: readonly ChartSeries<T>[],
|
|
@@ -249,6 +314,86 @@ export function areaGradient(index: number, color: string): ChartGradient {
|
|
|
249
314
|
}
|
|
250
315
|
}
|
|
251
316
|
|
|
317
|
+
const areaTint = (color: string): string =>
|
|
318
|
+
`color-mix(in srgb, ${color} var(--vx-area-top), transparent)`
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A series area filled by value zone: a vertical gradient whose stops sit at the zone boundaries.
|
|
322
|
+
* The engine writes `<linearGradient>` in the default `objectBoundingBox` units, so offsets are
|
|
323
|
+
* fractions of the AREA's own box, not of the plot: from the larger of the series' maximum and 0 down
|
|
324
|
+
* to the smaller of its minimum and 0, the baseline the area closes to even when the y domain
|
|
325
|
+
* excludes 0. A boundary at value `v` sits at `(ceiling - v) / (ceiling - floor)`. Hard edges:
|
|
326
|
+
* neighbouring zones meet at one offset; overlapping zones never run backwards. `null` when no zone
|
|
327
|
+
* reaches the area (the caller keeps the plain series-colour fade). Linear axes only.
|
|
328
|
+
*/
|
|
329
|
+
export function zoneAreaGradient(
|
|
330
|
+
index: number,
|
|
331
|
+
zones: readonly ZoneSpec[],
|
|
332
|
+
values: readonly (number | null)[],
|
|
333
|
+
): ChartGradient | null {
|
|
334
|
+
let floor = 0
|
|
335
|
+
let ceiling = 0
|
|
336
|
+
for (const v of values) {
|
|
337
|
+
if (v === null || !Number.isFinite(v)) continue
|
|
338
|
+
if (v < floor) floor = v
|
|
339
|
+
if (v > ceiling) ceiling = v
|
|
340
|
+
}
|
|
341
|
+
const span = ceiling - floor
|
|
342
|
+
if (span <= 0) return null
|
|
343
|
+
const offsetOf = (v: number): number => (ceiling - Math.min(ceiling, Math.max(floor, v))) / span
|
|
344
|
+
const reaching = zones
|
|
345
|
+
.filter((z) => !Number.isNaN(z.from) && !Number.isNaN(z.to) && z.to > floor && z.from < ceiling)
|
|
346
|
+
.toSorted((p, q) => q.to - p.to)
|
|
347
|
+
let previous = 0
|
|
348
|
+
const stops = reaching.flatMap((z) =>
|
|
349
|
+
[offsetOf(z.to), offsetOf(z.from)].map((raw) => {
|
|
350
|
+
previous = Math.max(previous, raw)
|
|
351
|
+
return { offset: previous, color: areaTint(z.fill) }
|
|
352
|
+
}),
|
|
353
|
+
)
|
|
354
|
+
if (stops.length === 0) return null
|
|
355
|
+
return { id: areaGradientId(index), x1: 0, y1: 0, x2: 0, y2: 1, stops }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let warnedLogZones = false
|
|
359
|
+
|
|
360
|
+
/** Zone stops are placed linearly in value; a log axis would put them in the wrong place (V2-SPEC
|
|
361
|
+
* § 9.9 precedent): dev throws, production logs once and keeps the series-colour fade. */
|
|
362
|
+
function guardLogZones(onLog: boolean): boolean {
|
|
363
|
+
if (!onLog) return false
|
|
364
|
+
const message = "area: 'zones' cannot use a log axis; use area: true"
|
|
365
|
+
if (process.env.NODE_ENV !== 'production') throw new Error(message)
|
|
366
|
+
// oxlint-disable-next-line no-console -- the production leg of a dev-only throw
|
|
367
|
+
if (!warnedLogZones) console.error(message)
|
|
368
|
+
warnedLogZones = true
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The gradient resource for a series' `area` fill: zones, a colour ref, or the series colour. */
|
|
373
|
+
function seriesAreaGradient<T, X extends ChartX>(
|
|
374
|
+
s: ChartSeries<T>,
|
|
375
|
+
index: number,
|
|
376
|
+
input: CartesianInput<T, X>,
|
|
377
|
+
axes: Axes<T>,
|
|
378
|
+
): ChartGradient {
|
|
379
|
+
const plain = areaGradient(
|
|
380
|
+
index,
|
|
381
|
+
typeof s.area === 'string' && s.area !== 'zones' ? s.area : s.color,
|
|
382
|
+
)
|
|
383
|
+
if (s.area !== 'zones') return plain
|
|
384
|
+
const side = axes.onRight(s) ? 'right' : 'left'
|
|
385
|
+
const axisConfig = side === 'right' ? input.y2 : input.y
|
|
386
|
+
if (guardLogZones(axisConfig?.scale === 'log')) return plain
|
|
387
|
+
const zones = (input.zones ?? []).filter((z) => axes.scaleId(z.axisSide) === axes.scaleId(side))
|
|
388
|
+
return (
|
|
389
|
+
zoneAreaGradient(
|
|
390
|
+
index,
|
|
391
|
+
zones,
|
|
392
|
+
input.data.map((d) => s.getValue(d)),
|
|
393
|
+
) ?? plain
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
252
397
|
const isBar = <T>(s: ChartSeries<T>): boolean => s.mark === 'bar'
|
|
253
398
|
|
|
254
399
|
/** The two synthetic totals standing in for a stack's bars in its AUTO domain only: the positive
|
|
@@ -354,32 +499,93 @@ function resolveAxes<T, X extends ChartX>(
|
|
|
354
499
|
}
|
|
355
500
|
}
|
|
356
501
|
|
|
357
|
-
/**
|
|
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. */
|
|
358
530
|
function underlayMarks<T, X extends ChartX>(
|
|
359
531
|
input: CartesianInput<T, X>,
|
|
360
532
|
model: XScaleModel<X>,
|
|
361
533
|
axes: Axes<T>,
|
|
362
534
|
): ChartMarks {
|
|
363
535
|
const fractionRows = [{ f0: 0, f1: 1 }]
|
|
364
|
-
const zones = (input.zones ?? []).
|
|
536
|
+
const zones = (input.zones ?? []).flatMap((z, i) => {
|
|
365
537
|
const domain = axes.axisOf(z.axisSide).domain
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
]
|
|
379
567
|
})
|
|
380
568
|
const xZones = (input.xZones ?? []).flatMap((z, i) => {
|
|
381
569
|
const f = xZoneFractions(z, model)
|
|
382
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
|
+
)
|
|
383
589
|
return [
|
|
384
590
|
decorative(
|
|
385
591
|
rect([{ f0: f[0], f1: f[1] }], {
|
|
@@ -393,6 +599,9 @@ function underlayMarks<T, X extends ChartX>(
|
|
|
393
599
|
inset: 0,
|
|
394
600
|
}),
|
|
395
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') : []),
|
|
396
605
|
]
|
|
397
606
|
})
|
|
398
607
|
return [...zones, ...xZones]
|
|
@@ -403,15 +612,20 @@ const refLineMarks = <T, X extends ChartX>(
|
|
|
403
612
|
input: CartesianInput<T, X>,
|
|
404
613
|
axes: Axes<T>,
|
|
405
614
|
): ChartMarks =>
|
|
406
|
-
(input.refLines ?? []).
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
+
],
|
|
415
629
|
)
|
|
416
630
|
|
|
417
631
|
function buildScales<T, X extends ChartX>(opts: {
|
|
@@ -487,6 +701,25 @@ function rowsOf<T, X extends ChartX>(
|
|
|
487
701
|
}
|
|
488
702
|
}
|
|
489
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
|
+
|
|
490
723
|
function tooltipSpec<T, X extends ChartX>(opts: {
|
|
491
724
|
input: CartesianInput<T, X>
|
|
492
725
|
axes: Axes<T>
|
|
@@ -518,25 +751,40 @@ function tooltipSpec<T, X extends ChartX>(opts: {
|
|
|
518
751
|
for (const s of rowSeries) {
|
|
519
752
|
const v = s.getValue(d)
|
|
520
753
|
if (v === null || !Number.isFinite(v)) continue
|
|
521
|
-
rows.push(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
)
|
|
529
763
|
}
|
|
530
764
|
const extra: readonly ChartTooltipRow[] =
|
|
531
765
|
(input.tooltip || undefined)?.rows?.(d, { visible, hidden }) ?? []
|
|
532
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
title: tooltipHeader(input, formatX, x, d),
|
|
768
|
+
rows: [...rows, ...extra.map(engineRow)],
|
|
769
|
+
}
|
|
533
770
|
},
|
|
534
771
|
}
|
|
535
772
|
}
|
|
536
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
|
+
|
|
537
784
|
export function compileCartesian<T, X extends ChartX>(
|
|
538
785
|
input: CartesianInput<T, X>,
|
|
539
786
|
): CartesianDefinition {
|
|
787
|
+
guardReservedKeys(input.series)
|
|
540
788
|
const visible = visibleSeries(input.series, input.hidden ?? new Set<string>())
|
|
541
789
|
const visibleKeys = new Set(visible.map((s) => s.key))
|
|
542
790
|
const keys = new Set(input.series.map((s) => s.key))
|
|
@@ -544,11 +792,12 @@ export function compileCartesian<T, X extends ChartX>(
|
|
|
544
792
|
const axes = resolveAxes(input, visible)
|
|
545
793
|
const under = underlayMarks(input, model, axes)
|
|
546
794
|
const over = refLineMarks(input, axes)
|
|
795
|
+
const dots = cursorDotMarks(input, visible, axes)
|
|
547
796
|
const rows = rowsOf(input, visibleKeys)
|
|
548
797
|
const formatX = (v: ChartValue): string =>
|
|
549
798
|
input.formatX ? input.formatX(v as X) : defaultFormatX(v)
|
|
550
799
|
const gradients = input.series.flatMap((s, i) =>
|
|
551
|
-
s.area ? [
|
|
800
|
+
s.area ? [seriesAreaGradient(s, i, input, axes)] : [],
|
|
552
801
|
)
|
|
553
802
|
|
|
554
803
|
return {
|
|
@@ -566,7 +815,7 @@ export function compileCartesian<T, X extends ChartX>(
|
|
|
566
815
|
y2Domain: axes.right?.domain,
|
|
567
816
|
})
|
|
568
817
|
return {
|
|
569
|
-
marks: [...under, ...kindMarks, ...over, crosshair({ x: CROSSHAIR, y: false })],
|
|
818
|
+
marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
|
|
570
819
|
scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
|
|
571
820
|
theme: CHART_THEME,
|
|
572
821
|
...(gradients.length > 0 && { gradients }),
|
package/src/charts/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
} from './MirroredBars'
|
|
29
29
|
export { ChartCard, type ChartCardProps, type ChartCardSlot } from './ChartCard'
|
|
30
30
|
export { chartCursor } from './cursor'
|
|
31
|
+
export { chartCurve, type CurveName } from './curves'
|
|
31
32
|
export type {
|
|
32
33
|
AxisConfig,
|
|
33
34
|
CartesianChartProps,
|
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
|
package/src/charts/panel.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import { useCallback } from 'react'
|
|
|
21
21
|
import type { ReactElement } from 'react'
|
|
22
22
|
import { ChartShell } from './CartesianChart'
|
|
23
23
|
import type { ChartShellProps, ShellContext } from './CartesianChart'
|
|
24
|
-
import { defaultFormatX, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
|
|
24
|
+
import { defaultFormatX, engineRow, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
|
|
25
25
|
import type { CartesianDefinition } from './compile'
|
|
26
26
|
import { cursorBinding, sourceOnlyFocus } from './cursor'
|
|
27
27
|
import type { XScaleModel } from './scales'
|
|
@@ -205,7 +205,7 @@ function panelDefinition<T, X extends ChartX>(
|
|
|
205
205
|
const { x, d } = sourceOf<T>(primary, owned)
|
|
206
206
|
const extra: readonly ChartTooltipRow[] =
|
|
207
207
|
tooltip?.rows?.(d, { visible, hidden: hiddenSeries }) ?? []
|
|
208
|
-
return { title: header(x, d), rows: [...plan.rows(d), ...extra] }
|
|
208
|
+
return { title: header(x, d), rows: [...plan.rows(d), ...extra.map(engineRow)] }
|
|
209
209
|
},
|
|
210
210
|
},
|
|
211
211
|
}
|