@rokkit/chart 1.3.6 → 1.3.8
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/dist/AnimatedPlot.svelte +26 -8
- package/dist/AnimatedPlot.svelte.d.ts +5 -0
- package/dist/Chart.svelte +1 -1
- package/dist/FacetPlot.svelte +1 -1
- package/dist/Plot/Axis.svelte +21 -10
- package/dist/Plot/Grid.svelte +32 -13
- package/dist/Plot/Grid.svelte.d.ts +6 -1
- package/dist/Plot/Root.svelte +64 -46
- package/dist/Plot/Root.svelte.d.ts +27 -2
- package/dist/Plot/Timeline.svelte +39 -13
- package/dist/Plot/index.d.ts +4 -4
- package/dist/Plot/index.js +4 -4
- package/dist/Plot.svelte +128 -164
- package/dist/Plot.svelte.d.ts +18 -2
- package/dist/PlotState.svelte.d.ts +23 -0
- package/dist/PlotState.svelte.js +155 -16
- package/dist/PlotSurface.svelte +162 -0
- package/dist/PlotSurface.svelte.d.ts +25 -0
- package/dist/charts/AreaChart.svelte +37 -4
- package/dist/charts/AreaChart.svelte.d.ts +12 -2
- package/dist/charts/BarChart.svelte +32 -3
- package/dist/charts/BarChart.svelte.d.ts +10 -1
- package/dist/charts/BoxPlot.svelte +4 -2
- package/dist/charts/BoxPlot.svelte.d.ts +1 -0
- package/dist/charts/BubbleChart.svelte +34 -3
- package/dist/charts/BubbleChart.svelte.d.ts +11 -1
- package/dist/charts/LineChart.svelte +35 -4
- package/dist/charts/LineChart.svelte.d.ts +11 -2
- package/dist/charts/PieChart.svelte +1 -1
- package/dist/charts/ScatterPlot.svelte +32 -3
- package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
- package/dist/charts/ViolinPlot.svelte +4 -2
- package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
- package/dist/examples/BarChartExample.svelte +1 -1
- package/dist/geoms/Arc.svelte +3 -2
- package/dist/geoms/Area.svelte +20 -3
- package/dist/geoms/Bar.svelte +57 -13
- package/dist/geoms/Box.svelte +131 -76
- package/dist/geoms/Box.svelte.d.ts +7 -0
- package/dist/geoms/Candlestick.svelte +17 -11
- package/dist/geoms/Highlight.svelte +105 -0
- package/dist/geoms/Highlight.svelte.d.ts +10 -0
- package/dist/geoms/Jitter.svelte +82 -0
- package/dist/geoms/Jitter.svelte.d.ts +15 -0
- package/dist/geoms/Line.svelte +26 -9
- package/dist/geoms/Point.svelte +37 -11
- package/dist/geoms/Rule.svelte +113 -0
- package/dist/geoms/Rule.svelte.d.ts +16 -0
- package/dist/geoms/Trend.svelte +66 -0
- package/dist/geoms/Trend.svelte.d.ts +12 -0
- package/dist/geoms/Violin.svelte +30 -6
- package/dist/geoms/Violin.svelte.d.ts +5 -0
- package/dist/geoms/Waterfall.svelte +16 -9
- package/dist/geoms/lib/areas.d.ts +6 -2
- package/dist/geoms/lib/areas.js +17 -11
- package/dist/geoms/lib/bars.d.ts +18 -3
- package/dist/geoms/lib/bars.js +70 -96
- package/dist/index.d.ts +30 -4
- package/dist/index.js +31 -5
- package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
- package/dist/lib/brewing/brewer.svelte.d.ts +1 -0
- package/dist/lib/brewing/marks/arcs.js +7 -1
- package/dist/lib/brewing/marks/boxes.d.ts +11 -2
- package/dist/lib/brewing/marks/boxes.js +32 -11
- package/dist/lib/brewing/marks/lines.d.ts +6 -2
- package/dist/lib/brewing/marks/lines.js +19 -12
- package/dist/lib/brewing/marks/swarm.d.ts +32 -0
- package/dist/lib/brewing/marks/swarm.js +125 -0
- package/dist/lib/brewing/marks/violins.d.ts +4 -20
- package/dist/lib/brewing/marks/violins.js +41 -25
- package/dist/lib/brewing/stats.d.ts +3 -1
- package/dist/lib/brewing/stats.js +28 -11
- package/dist/lib/highlight.d.ts +10 -0
- package/dist/lib/highlight.js +43 -0
- package/dist/lib/plot/scales.js +12 -7
- package/dist/lib/plot/types.d.ts +9 -1
- package/dist/lib/plot/types.js +3 -1
- package/dist/lib/scale.d.ts +10 -0
- package/dist/lib/scale.js +14 -0
- package/dist/lib/select.d.ts +22 -0
- package/dist/lib/select.js +22 -0
- package/dist/lib/trend.d.ts +27 -0
- package/dist/lib/trend.js +113 -0
- package/dist/patterns/README.md +1 -1
- package/package.json +1 -1
- package/dist/Plot/Area.svelte +0 -54
- package/dist/Plot/Area.svelte.d.ts +0 -14
- package/dist/Plot/Bar.svelte +0 -69
- package/dist/Plot/Bar.svelte.d.ts +0 -11
- package/dist/Plot/Line.svelte +0 -54
- package/dist/Plot/Line.svelte.d.ts +0 -13
- package/dist/Plot/Point.svelte +0 -58
- package/dist/Plot/Point.svelte.d.ts +0 -13
package/dist/PlotState.svelte.js
CHANGED
|
@@ -17,6 +17,9 @@ import { assignSymbols } from './lib/brewing/marks/points.js'
|
|
|
17
17
|
|
|
18
18
|
let nextId = 0
|
|
19
19
|
|
|
20
|
+
// Geoms that treat the x channel as categorical (band scale), even for numeric x.
|
|
21
|
+
const CATEGORICAL_X = new Set(['bar', 'box', 'violin', 'jitter'])
|
|
22
|
+
|
|
20
23
|
export class PlotState {
|
|
21
24
|
#data = $state([])
|
|
22
25
|
#rawData = $state([])
|
|
@@ -39,7 +42,17 @@ export class PlotState {
|
|
|
39
42
|
#mode = $state('light')
|
|
40
43
|
#chartPreset = $state(defaultPreset)
|
|
41
44
|
#hovered = $state(null)
|
|
45
|
+
#selected = $state(new SvelteSet())
|
|
46
|
+
#onselect = $state(undefined)
|
|
47
|
+
#selectable = $state(false)
|
|
42
48
|
#orientationOverride = $state(undefined)
|
|
49
|
+
// When true, the category (x) axis is a CONTINUOUS position scale (kept linear, not
|
|
50
|
+
// band-forced) — e.g. AnimatedPlot's bar-chart race, whose tweened `_rank` must position
|
|
51
|
+
// smoothly. It still flips like any category-x chart; buildBars uses continuous positioning.
|
|
52
|
+
#continuousCategory = $state(false)
|
|
53
|
+
// Order the category (band) axis by aggregated value instead of by label: 'desc' | 'asc'.
|
|
54
|
+
// Sorts bars by size (histogram-style); undefined keeps data/label order.
|
|
55
|
+
#sort = $state(undefined)
|
|
43
56
|
|
|
44
57
|
axisOrigin = $state([undefined, undefined])
|
|
45
58
|
#axisOffset = $state(0)
|
|
@@ -50,8 +63,11 @@ export class PlotState {
|
|
|
50
63
|
#innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
|
|
51
64
|
#innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
|
|
52
65
|
|
|
53
|
-
// Effective channels: prefer top-level channels
|
|
54
|
-
//
|
|
66
|
+
// Effective channels: prefer top-level channels PER FIELD, falling back to the
|
|
67
|
+
// first geom's channels. Merged per-field (not all-or-nothing) so a composable
|
|
68
|
+
// <Plot.Root x y> that omits `color` still picks up a geom's color channel
|
|
69
|
+
// (e.g. <Plot.Box color=x>) — otherwise `colors` has no per-category entries and
|
|
70
|
+
// every mark falls back to gray.
|
|
55
71
|
#mergeGeomChannels(tc, geom) {
|
|
56
72
|
return {
|
|
57
73
|
x: tc.x ?? geom.channels?.x,
|
|
@@ -64,15 +80,32 @@ export class PlotState {
|
|
|
64
80
|
|
|
65
81
|
#effectiveChannels = $derived.by(() => {
|
|
66
82
|
const tc = this.#channels
|
|
67
|
-
if (tc.x && tc.y) return tc
|
|
68
83
|
const firstGeom = this.#geoms[0]
|
|
69
84
|
if (!firstGeom) return tc
|
|
70
85
|
return this.#mergeGeomChannels(tc, firstGeom)
|
|
71
86
|
})
|
|
72
87
|
|
|
73
88
|
#resolveXType(rawXType, yType) {
|
|
74
|
-
const
|
|
75
|
-
return
|
|
89
|
+
const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
|
|
90
|
+
return hasBandGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Category order sorted by aggregated value (sum of the value channel per category).
|
|
94
|
+
// Drives "sort bars by size" for the band axis + its ticks. Returns null when not sorting.
|
|
95
|
+
#sortedBandDomain(datasets) {
|
|
96
|
+
if (!this.#sort) return null
|
|
97
|
+
const xf = this.#effectiveChannels.x
|
|
98
|
+
const yf = this.#effectiveChannels.y
|
|
99
|
+
if (!xf || !yf) return null
|
|
100
|
+
const totals = new Map()
|
|
101
|
+
for (const ds of datasets) {
|
|
102
|
+
for (const d of ds) {
|
|
103
|
+
const key = d[xf]
|
|
104
|
+
totals.set(key, (totals.get(key) ?? 0) + (Number(d[yf]) || 0))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const dir = this.#sort === 'asc' ? 1 : -1
|
|
108
|
+
return [...totals.keys()].sort((a, b) => dir * ((totals.get(a) ?? 0) - (totals.get(b) ?? 0)))
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
orientation = $derived.by(() => {
|
|
@@ -86,6 +119,24 @@ export class PlotState {
|
|
|
86
119
|
return inferOrientation(this.#resolveXType(rawXType, yType), yType)
|
|
87
120
|
})
|
|
88
121
|
|
|
122
|
+
// True when the x-channel is the categorical (band) axis — a string category, or a numeric
|
|
123
|
+
// category a bar/box/violin/jitter geom bands (incl. a race's rank via continuousCategory).
|
|
124
|
+
// Only such charts flip; a plain scatter (both continuous, no banding geom) is false → the
|
|
125
|
+
// orientation is a no-op for the scales.
|
|
126
|
+
#bandIsX = $derived.by(() => {
|
|
127
|
+
const x = this.#effectiveChannels.x
|
|
128
|
+
const y = this.#effectiveChannels.y
|
|
129
|
+
if (!x || !y) return false
|
|
130
|
+
// Resolved type: a bar/box/violin/jitter geom bands a numeric x (e.g. year, week),
|
|
131
|
+
// so those NUMERIC categories are flippable too — not just string categories.
|
|
132
|
+
return this.#resolveXType(inferFieldType(this.#data, x), inferFieldType(this.#data, y)) === 'band'
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Flip = render a category-on-x chart horizontally: the category (x) axis stands up on the
|
|
136
|
+
// vertical screen and the value (y) axis runs along the horizontal screen. x/y channels are
|
|
137
|
+
// unchanged — only the screen mapping rotates.
|
|
138
|
+
#flipped = $derived(this.orientation === 'horizontal' && this.#bandIsX)
|
|
139
|
+
|
|
89
140
|
colorScaleType = $derived.by(() => {
|
|
90
141
|
const field = this.#effectiveChannels.color
|
|
91
142
|
if (!field) return 'categorical'
|
|
@@ -115,15 +166,30 @@ export class PlotState {
|
|
|
115
166
|
if (!field) return null
|
|
116
167
|
const datasets =
|
|
117
168
|
this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
169
|
+
// includeZero applies to the VALUE axis. When flipped, x is the category axis, not
|
|
170
|
+
// the value axis, so it must NOT include zero.
|
|
171
|
+
const includeZero = this.orientation === 'horizontal' && !this.#flipped
|
|
172
|
+
// Force scaleBand when x is the categorical axis (incl. flipped, where numeric categories
|
|
173
|
+
// must stay a band). Exception: a CONTINUOUS category axis (a bar race's tweened rank) is
|
|
174
|
+
// kept LINEAR so fractional positions tween smoothly — buildBars positions it directly.
|
|
175
|
+
const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
|
|
176
|
+
const bandX =
|
|
177
|
+
hasBandGeom &&
|
|
178
|
+
!this.#continuousCategory &&
|
|
179
|
+
(this.orientation !== 'horizontal' || this.#flipped)
|
|
180
|
+
// Flip: the category (x) axis stands up on the vertical screen → range over height.
|
|
181
|
+
const range = this.#flipped ? [this.#innerHeight, 0] : undefined
|
|
182
|
+
// Sort the band domain by aggregated value when requested (bars by size); an explicit
|
|
183
|
+
// xDomain override wins.
|
|
184
|
+
const domain = this.#xDomain ?? (bandX ? this.#sortedBandDomain(datasets) : null) ?? undefined
|
|
123
185
|
const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
|
|
124
|
-
domain
|
|
186
|
+
domain,
|
|
125
187
|
includeZero,
|
|
126
|
-
band: bandX
|
|
188
|
+
band: bandX,
|
|
189
|
+
range,
|
|
190
|
+
// A continuous category axis (bar-race rank) uses a deliberately padded domain —
|
|
191
|
+
// don't nice() it back to whole numbers.
|
|
192
|
+
nice: !this.#continuousCategory
|
|
127
193
|
})
|
|
128
194
|
return this.#zoomTransform && typeof base?.bandwidth !== 'function'
|
|
129
195
|
? this.#zoomTransform.rescaleX(base)
|
|
@@ -136,8 +202,9 @@ export class PlotState {
|
|
|
136
202
|
if (!boxGeom) return null
|
|
137
203
|
const boxData = this.geomData(boxGeom.id)
|
|
138
204
|
const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
|
|
139
|
-
const
|
|
140
|
-
const
|
|
205
|
+
const flatOutliers = boxData.flatMap((d) => d.outliers ?? []).filter(isValid)
|
|
206
|
+
const mins = [...boxData.map((d) => d.iqr_min).filter(isValid), ...flatOutliers]
|
|
207
|
+
const maxs = [...boxData.map((d) => d.iqr_max).filter(isValid), ...flatOutliers]
|
|
141
208
|
return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
|
|
142
209
|
}
|
|
143
210
|
|
|
@@ -178,9 +245,13 @@ export class PlotState {
|
|
|
178
245
|
if (!field) return null
|
|
179
246
|
const datasets =
|
|
180
247
|
this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
|
|
181
|
-
|
|
248
|
+
// includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
|
|
249
|
+
// charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
|
|
250
|
+
const includeZero = this.orientation === 'vertical' || this.#flipped
|
|
182
251
|
const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
|
|
183
|
-
|
|
252
|
+
// Flip: the value (y) axis runs along the horizontal screen → range over width.
|
|
253
|
+
const range = this.#flipped ? [0, this.#innerWidth] : undefined
|
|
254
|
+
const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
|
|
184
255
|
return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
|
|
185
256
|
})
|
|
186
257
|
|
|
@@ -274,9 +345,14 @@ export class PlotState {
|
|
|
274
345
|
this.#height = config.height ?? 400
|
|
275
346
|
this.#mode = config.mode ?? 'light'
|
|
276
347
|
this.#chartPreset = config.chartPreset ?? defaultPreset
|
|
348
|
+
this.#onselect = config.onselect
|
|
349
|
+
this.#selectable = config.selectable ?? false
|
|
350
|
+
if (config.selected) this.#selected = new SvelteSet(config.selected)
|
|
277
351
|
this.#axisOffset = config.axisOffset ?? 0
|
|
278
352
|
this.#marginOverride = config.margin ?? undefined
|
|
279
353
|
this.#orientationOverride = config.orientation ?? undefined
|
|
354
|
+
this.#continuousCategory = config.continuousCategory ?? false
|
|
355
|
+
this.#sort = config.sort ?? undefined
|
|
280
356
|
}
|
|
281
357
|
|
|
282
358
|
update(config) {
|
|
@@ -298,9 +374,13 @@ export class PlotState {
|
|
|
298
374
|
if (config.height !== undefined) this.#height = config.height
|
|
299
375
|
if (config.mode !== undefined) this.#mode = config.mode
|
|
300
376
|
if (config.chartPreset !== undefined) this.#chartPreset = config.chartPreset
|
|
377
|
+
if (config.onselect !== undefined) this.#onselect = config.onselect
|
|
378
|
+
if (config.selectable !== undefined) this.#selectable = config.selectable
|
|
301
379
|
if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
|
|
302
380
|
this.#marginOverride = config.margin ?? undefined
|
|
303
381
|
this.#orientationOverride = config.orientation ?? undefined
|
|
382
|
+
this.#continuousCategory = config.continuousCategory ?? false
|
|
383
|
+
this.#sort = config.sort ?? undefined
|
|
304
384
|
}
|
|
305
385
|
|
|
306
386
|
registerGeom(config) {
|
|
@@ -345,6 +425,43 @@ export class PlotState {
|
|
|
345
425
|
return resolvePreset(this.#presetName, this.#helpers)
|
|
346
426
|
}
|
|
347
427
|
|
|
428
|
+
get data() {
|
|
429
|
+
// Return #rawData (not #data): geoms read their rows via geomData() which
|
|
430
|
+
// returns #rawData, so overlays and index lookups (plotState.data.indexOf(datum))
|
|
431
|
+
// must use the SAME proxy identity. #data and #rawData proxy the same source
|
|
432
|
+
// array separately, so their elements are not ===. Same content, aligned identity.
|
|
433
|
+
return this.#rawData
|
|
434
|
+
}
|
|
435
|
+
/** @returns {{ x?: string, y?: string, color?: string, pattern?: string, symbol?: string }} */
|
|
436
|
+
get channels() {
|
|
437
|
+
return this.#channels
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── Orientation helpers (horizontal / axis-flip) ──────────────────────────
|
|
441
|
+
// True when the chart is rendered horizontally (category axis stood up on the
|
|
442
|
+
// vertical screen, value axis along the horizontal screen). x/y channels unchanged.
|
|
443
|
+
get isFlipped() {
|
|
444
|
+
return this.#flipped
|
|
445
|
+
}
|
|
446
|
+
// True when the category (x) axis is a continuous position scale (kept linear) rather than a
|
|
447
|
+
// band — a bar-chart race's tweened rank. buildBars uses continuous positioning for it.
|
|
448
|
+
get continuousCategory() {
|
|
449
|
+
return this.#continuousCategory
|
|
450
|
+
}
|
|
451
|
+
// Map abstract (x-channel, y-channel) scale outputs to screen coords. When flipped,
|
|
452
|
+
// the two screen axes swap. Geoms compute u = xScale(d[x]), v = yScale(d[y]) then
|
|
453
|
+
// `const { x, y } = plotState.place(u, v)`.
|
|
454
|
+
place(u, v) {
|
|
455
|
+
return this.#flipped ? { x: v, y: u } : { x: u, y: v }
|
|
456
|
+
}
|
|
457
|
+
// The categorical (band) scale and the continuous (value) scale, regardless of
|
|
458
|
+
// orientation — their ranges are already oriented to the correct screen axis.
|
|
459
|
+
get bandScale() {
|
|
460
|
+
return this.#bandIsX ? this.xScale : this.yScale
|
|
461
|
+
}
|
|
462
|
+
get valueScale() {
|
|
463
|
+
return this.#bandIsX ? this.yScale : this.xScale
|
|
464
|
+
}
|
|
348
465
|
get margin() {
|
|
349
466
|
return this.#effectiveMargin
|
|
350
467
|
}
|
|
@@ -363,6 +480,12 @@ export class PlotState {
|
|
|
363
480
|
get hovered() {
|
|
364
481
|
return this.#hovered
|
|
365
482
|
}
|
|
483
|
+
get interactive() {
|
|
484
|
+
return Boolean(this.#onselect) || this.#selectable
|
|
485
|
+
}
|
|
486
|
+
get selectedRows() {
|
|
487
|
+
return [...this.#selected]
|
|
488
|
+
}
|
|
366
489
|
|
|
367
490
|
setHovered(data) {
|
|
368
491
|
this.#hovered = data
|
|
@@ -370,6 +493,22 @@ export class PlotState {
|
|
|
370
493
|
clearHovered() {
|
|
371
494
|
this.#hovered = null
|
|
372
495
|
}
|
|
496
|
+
isSelected(row) {
|
|
497
|
+
return this.#selected.has(row)
|
|
498
|
+
}
|
|
499
|
+
setSelected(rows) {
|
|
500
|
+
this.#selected = new SvelteSet(rows ?? [])
|
|
501
|
+
}
|
|
502
|
+
clearSelected() {
|
|
503
|
+
this.#selected = new SvelteSet()
|
|
504
|
+
}
|
|
505
|
+
handleSelect(detail) {
|
|
506
|
+
this.#onselect?.(detail)
|
|
507
|
+
if (this.#selectable && detail?.datum !== undefined) {
|
|
508
|
+
if (this.#selected.has(detail.datum)) this.#selected.delete(detail.datum)
|
|
509
|
+
else this.#selected.add(detail.datum)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
373
512
|
|
|
374
513
|
applyZoom(transform) {
|
|
375
514
|
this.#zoomTransform = transform
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { setContext, untrack } from 'svelte'
|
|
3
|
+
import type { Snippet } from 'svelte'
|
|
4
|
+
import { PlotState } from './PlotState.svelte.js'
|
|
5
|
+
import DefinePatterns from './patterns/DefinePatterns.svelte'
|
|
6
|
+
|
|
7
|
+
type Row = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
/** Full PlotState config. `config.width` is the initial/fallback width — PlotSurface
|
|
11
|
+
* owns the responsive width via a ResizeObserver and overrides it. */
|
|
12
|
+
config?: Record<string, unknown>
|
|
13
|
+
/** Animate marks on data/flip changes (data-plot-animate). Enabled one frame after the
|
|
14
|
+
* width settles so the initial layout paints un-animated. */
|
|
15
|
+
animate?: boolean
|
|
16
|
+
/** Enable d3-zoom pan/zoom on the svg. */
|
|
17
|
+
zoom?: boolean
|
|
18
|
+
ariaLabel?: string
|
|
19
|
+
/** SVG <desc> for screen readers. */
|
|
20
|
+
summary?: string
|
|
21
|
+
/** Two-way selection (row references), synced with the PlotState selection. */
|
|
22
|
+
selected?: Row[]
|
|
23
|
+
/** Canvas content (geoms/grid/axes) — rendered inside the svg. */
|
|
24
|
+
children?: Snippet
|
|
25
|
+
/** HTML overlays (legend/tooltip/a11y table) — rendered outside the svg but INSIDE the
|
|
26
|
+
* plot-state context, so they can read plotState. */
|
|
27
|
+
overlay?: Snippet
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
config = {},
|
|
32
|
+
animate = true,
|
|
33
|
+
zoom = false,
|
|
34
|
+
ariaLabel = 'Chart visualization',
|
|
35
|
+
summary = '',
|
|
36
|
+
selected = $bindable([]),
|
|
37
|
+
children,
|
|
38
|
+
overlay
|
|
39
|
+
}: Props = $props()
|
|
40
|
+
|
|
41
|
+
// ── Responsive width: observe the container, use its pixel width so the chart fills the stage.
|
|
42
|
+
let containerEl = $state<HTMLDivElement | null>(null)
|
|
43
|
+
let svgEl = $state<SVGSVGElement | null>(null)
|
|
44
|
+
let observedWidth = $state(0)
|
|
45
|
+
$effect(() => {
|
|
46
|
+
if (!containerEl) return
|
|
47
|
+
const ro = new ResizeObserver((entries) => {
|
|
48
|
+
const w = Math.floor(entries[0].contentRect.width)
|
|
49
|
+
if (w > 0) observedWidth = w
|
|
50
|
+
})
|
|
51
|
+
ro.observe(containerEl)
|
|
52
|
+
return () => ro.disconnect()
|
|
53
|
+
})
|
|
54
|
+
const fallbackWidth = $derived(Number(config.width) || 600)
|
|
55
|
+
const effectiveWidth = $derived(observedWidth > 0 ? observedWidth : fallbackWidth)
|
|
56
|
+
const svgHeight = $derived(Number(config.height) || 400)
|
|
57
|
+
const mode = $derived(config.mode as 'light' | 'dark' | undefined)
|
|
58
|
+
|
|
59
|
+
// ── The single PlotState + context. config.width is overridden with the observed width.
|
|
60
|
+
const fullConfig = () => ({ ...config, width: effectiveWidth })
|
|
61
|
+
const plotState = untrack(() => new PlotState(fullConfig()))
|
|
62
|
+
setContext('plot-state', plotState)
|
|
63
|
+
$effect(() => {
|
|
64
|
+
plotState.update(fullConfig())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ── Selection sync (external prop ↔ PlotState). Guarded to avoid an update loop.
|
|
68
|
+
$effect(() => {
|
|
69
|
+
plotState.setSelected(selected)
|
|
70
|
+
})
|
|
71
|
+
$effect(() => {
|
|
72
|
+
const rows = plotState.selectedRows
|
|
73
|
+
const same = rows.length === selected.length && rows.every((r, i) => r === selected[i])
|
|
74
|
+
if (!same) selected = rows
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ── Enable data-change / flip animation one frame after the width first settles, so the
|
|
78
|
+
// initial (possibly reflowed) layout paints without animating.
|
|
79
|
+
let animateReady = $state(false)
|
|
80
|
+
$effect(() => {
|
|
81
|
+
if (!animate || animateReady || observedWidth <= 0) return
|
|
82
|
+
const raf = requestAnimationFrame(() => {
|
|
83
|
+
animateReady = true
|
|
84
|
+
})
|
|
85
|
+
return () => cancelAnimationFrame(raf)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ── Pan/zoom (d3-zoom/d3-selection are client-only → dynamic import).
|
|
89
|
+
$effect(() => {
|
|
90
|
+
if (!zoom || !svgEl) return
|
|
91
|
+
let cancelled = false
|
|
92
|
+
let detach: (() => void) | undefined
|
|
93
|
+
Promise.all([import('d3-zoom'), import('d3-selection')]).then(
|
|
94
|
+
([{ zoom: d3Zoom }, { select }]) => {
|
|
95
|
+
if (cancelled || !svgEl) return
|
|
96
|
+
const zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
|
|
97
|
+
.scaleExtent([1, 8])
|
|
98
|
+
.on('zoom', (event) => plotState.applyZoom(event.transform))
|
|
99
|
+
const sel = select(svgEl)
|
|
100
|
+
sel.call(zoomBehavior)
|
|
101
|
+
detach = () => sel.on('.zoom', null)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true
|
|
106
|
+
detach?.()
|
|
107
|
+
plotState.resetZoom()
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<div class="plot-surface" bind:this={containerEl}>
|
|
113
|
+
<svg
|
|
114
|
+
bind:this={svgEl}
|
|
115
|
+
width={effectiveWidth}
|
|
116
|
+
height={svgHeight}
|
|
117
|
+
viewBox="0 0 {effectiveWidth} {svgHeight}"
|
|
118
|
+
role="img"
|
|
119
|
+
aria-label={ariaLabel}
|
|
120
|
+
data-plot-root
|
|
121
|
+
data-mode={mode}
|
|
122
|
+
data-plot-animate={animate && animateReady ? '' : undefined}
|
|
123
|
+
style:cursor={zoom ? 'grab' : undefined}
|
|
124
|
+
>
|
|
125
|
+
{#if summary}
|
|
126
|
+
<desc>{summary}</desc>
|
|
127
|
+
{/if}
|
|
128
|
+
<!-- SVG <pattern> defs for texture fills (Bar/Area/Arc/Box/Violin `pattern` channel) -->
|
|
129
|
+
<DefinePatterns />
|
|
130
|
+
<g
|
|
131
|
+
class="plot-canvas"
|
|
132
|
+
transform="translate({plotState.margin.left}, {plotState.margin.top})"
|
|
133
|
+
data-plot-canvas
|
|
134
|
+
>
|
|
135
|
+
{@render children?.()}
|
|
136
|
+
</g>
|
|
137
|
+
</svg>
|
|
138
|
+
<!-- HTML overlays (legend/tooltip/a11y table) — inside the context, outside the svg. -->
|
|
139
|
+
{@render overlay?.()}
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<style>
|
|
143
|
+
.plot-surface {
|
|
144
|
+
width: 100%;
|
|
145
|
+
}
|
|
146
|
+
.plot-surface svg {
|
|
147
|
+
display: block;
|
|
148
|
+
width: 100%;
|
|
149
|
+
height: auto;
|
|
150
|
+
overflow: visible;
|
|
151
|
+
}
|
|
152
|
+
.plot-canvas {
|
|
153
|
+
pointer-events: all;
|
|
154
|
+
}
|
|
155
|
+
/* Mode token-lock colour (only when data-mode is set explicitly). */
|
|
156
|
+
[data-plot-root][data-mode='light'] {
|
|
157
|
+
color: rgba(var(--color-surface-700, 59, 65, 77), 1);
|
|
158
|
+
}
|
|
159
|
+
[data-plot-root][data-mode='dark'] {
|
|
160
|
+
color: rgba(var(--color-surface-200, 224, 224, 224), 1);
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Row = Record<string, unknown>;
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Full PlotState config. `config.width` is the initial/fallback width — PlotSurface
|
|
5
|
+
* owns the responsive width via a ResizeObserver and overrides it. */
|
|
6
|
+
config?: Record<string, unknown>;
|
|
7
|
+
/** Animate marks on data/flip changes (data-plot-animate). Enabled one frame after the
|
|
8
|
+
* width settles so the initial layout paints un-animated. */
|
|
9
|
+
animate?: boolean;
|
|
10
|
+
/** Enable d3-zoom pan/zoom on the svg. */
|
|
11
|
+
zoom?: boolean;
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
/** SVG <desc> for screen readers. */
|
|
14
|
+
summary?: string;
|
|
15
|
+
/** Two-way selection (row references), synced with the PlotState selection. */
|
|
16
|
+
selected?: Row[];
|
|
17
|
+
/** Canvas content (geoms/grid/axes) — rendered inside the svg. */
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
/** HTML overlays (legend/tooltip/a11y table) — rendered outside the svg but INSIDE the
|
|
20
|
+
* plot-state context, so they can read plotState. */
|
|
21
|
+
overlay?: Snippet;
|
|
22
|
+
};
|
|
23
|
+
declare const PlotSurface: import("svelte").Component<Props, {}, "selected">;
|
|
24
|
+
type PlotSurface = ReturnType<typeof PlotSurface>;
|
|
25
|
+
export default PlotSurface;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import Area from '../geoms/Area.svelte'
|
|
4
4
|
|
|
5
5
|
type Row = Record<string, unknown>
|
|
6
|
+
type Method = string | number | { type: string; [k: string]: unknown }
|
|
6
7
|
type Format = (v: unknown) => string
|
|
7
8
|
|
|
8
9
|
type Props = {
|
|
@@ -17,13 +18,19 @@
|
|
|
17
18
|
width?: number
|
|
18
19
|
height?: number
|
|
19
20
|
mode?: 'light' | 'dark'
|
|
20
|
-
grid?: boolean
|
|
21
|
+
grid?: boolean | 'x' | 'y' | 'both'
|
|
21
22
|
legend?: boolean
|
|
23
|
+
tooltip?: boolean | ((data: Row) => string)
|
|
24
|
+
highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean)
|
|
25
|
+
trend?: Method | Method[]
|
|
22
26
|
xFormat?: Format
|
|
23
27
|
yFormat?: Format
|
|
24
28
|
xTicks?: number
|
|
25
29
|
yTicks?: number
|
|
26
30
|
minorTicks?: boolean
|
|
31
|
+
onselect?: (detail: unknown) => void
|
|
32
|
+
selectable?: boolean
|
|
33
|
+
selected?: Row[]
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
let {
|
|
@@ -37,17 +44,43 @@
|
|
|
37
44
|
stack = false,
|
|
38
45
|
width = 600,
|
|
39
46
|
height = 400,
|
|
40
|
-
mode =
|
|
47
|
+
mode = undefined,
|
|
41
48
|
grid = true,
|
|
42
49
|
legend = false,
|
|
50
|
+
tooltip = false,
|
|
51
|
+
highlight = undefined,
|
|
52
|
+
trend = undefined,
|
|
43
53
|
xFormat = undefined,
|
|
44
54
|
yFormat = undefined,
|
|
45
55
|
xTicks = undefined,
|
|
46
56
|
yTicks = undefined,
|
|
47
|
-
minorTicks = false
|
|
57
|
+
minorTicks = false,
|
|
58
|
+
onselect = undefined,
|
|
59
|
+
selectable = false,
|
|
60
|
+
selected = $bindable([])
|
|
48
61
|
}: Props = $props()
|
|
49
62
|
</script>
|
|
50
63
|
|
|
51
|
-
<Plot
|
|
64
|
+
<Plot
|
|
65
|
+
{data}
|
|
66
|
+
{x}
|
|
67
|
+
{y}
|
|
68
|
+
{width}
|
|
69
|
+
{height}
|
|
70
|
+
{mode}
|
|
71
|
+
{grid}
|
|
72
|
+
{legend}
|
|
73
|
+
{tooltip}
|
|
74
|
+
{highlight}
|
|
75
|
+
{trend}
|
|
76
|
+
{xFormat}
|
|
77
|
+
{yFormat}
|
|
78
|
+
{xTicks}
|
|
79
|
+
{yTicks}
|
|
80
|
+
{minorTicks}
|
|
81
|
+
{onselect}
|
|
82
|
+
{selectable}
|
|
83
|
+
bind:selected
|
|
84
|
+
>
|
|
52
85
|
<Area {x} {y} color={fill} {pattern} {stat} options={{ curve, stack }} />
|
|
53
86
|
</Plot>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
type Row = Record<string, unknown>;
|
|
2
|
+
type Method = string | number | {
|
|
3
|
+
type: string;
|
|
4
|
+
[k: string]: unknown;
|
|
5
|
+
};
|
|
2
6
|
type Format = (v: unknown) => string;
|
|
3
7
|
type Props = {
|
|
4
8
|
data?: Row[];
|
|
@@ -12,14 +16,20 @@ type Props = {
|
|
|
12
16
|
width?: number;
|
|
13
17
|
height?: number;
|
|
14
18
|
mode?: 'light' | 'dark';
|
|
15
|
-
grid?: boolean;
|
|
19
|
+
grid?: boolean | 'x' | 'y' | 'both';
|
|
16
20
|
legend?: boolean;
|
|
21
|
+
tooltip?: boolean | ((data: Row) => string);
|
|
22
|
+
highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean);
|
|
23
|
+
trend?: Method | Method[];
|
|
17
24
|
xFormat?: Format;
|
|
18
25
|
yFormat?: Format;
|
|
19
26
|
xTicks?: number;
|
|
20
27
|
yTicks?: number;
|
|
21
28
|
minorTicks?: boolean;
|
|
29
|
+
onselect?: (detail: unknown) => void;
|
|
30
|
+
selectable?: boolean;
|
|
31
|
+
selected?: Row[];
|
|
22
32
|
};
|
|
23
|
-
declare const AreaChart: import("svelte").Component<Props, {}, "">;
|
|
33
|
+
declare const AreaChart: import("svelte").Component<Props, {}, "selected">;
|
|
24
34
|
type AreaChart = ReturnType<typeof AreaChart>;
|
|
25
35
|
export default AreaChart;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
type Row = Record<string, unknown>
|
|
6
6
|
type Format = (v: unknown) => string
|
|
7
|
+
type Method = string | number | { type: string; [k: string]: unknown }
|
|
7
8
|
|
|
8
9
|
type Props = {
|
|
9
10
|
data?: Row[]
|
|
@@ -20,11 +21,16 @@
|
|
|
20
21
|
stack?: boolean
|
|
21
22
|
label?: boolean | string | ((data: Row) => unknown)
|
|
22
23
|
tooltip?: boolean | ((data: Row) => string)
|
|
24
|
+
highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean)
|
|
25
|
+
trend?: Method | Method[]
|
|
23
26
|
xFormat?: Format
|
|
24
27
|
yFormat?: Format
|
|
25
28
|
xTicks?: number
|
|
26
29
|
yTicks?: number
|
|
27
30
|
minorTicks?: boolean
|
|
31
|
+
onselect?: (detail: unknown) => void
|
|
32
|
+
selectable?: boolean
|
|
33
|
+
selected?: Row[]
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
let {
|
|
@@ -35,21 +41,44 @@
|
|
|
35
41
|
pattern = undefined,
|
|
36
42
|
width = 600,
|
|
37
43
|
height = 400,
|
|
38
|
-
mode =
|
|
44
|
+
mode = undefined,
|
|
39
45
|
grid = true,
|
|
40
46
|
legend = false,
|
|
41
47
|
stat = 'identity',
|
|
42
48
|
stack = false,
|
|
43
49
|
label = false,
|
|
44
50
|
tooltip = false,
|
|
51
|
+
highlight = undefined,
|
|
52
|
+
trend = undefined,
|
|
45
53
|
xFormat = undefined,
|
|
46
54
|
yFormat = undefined,
|
|
47
55
|
xTicks = undefined,
|
|
48
56
|
yTicks = undefined,
|
|
49
|
-
minorTicks = false
|
|
57
|
+
minorTicks = false,
|
|
58
|
+
onselect = undefined,
|
|
59
|
+
selectable = false,
|
|
60
|
+
selected = $bindable([])
|
|
50
61
|
}: Props = $props()
|
|
51
62
|
</script>
|
|
52
63
|
|
|
53
|
-
<Plot
|
|
64
|
+
<Plot
|
|
65
|
+
{data}
|
|
66
|
+
{width}
|
|
67
|
+
{height}
|
|
68
|
+
{mode}
|
|
69
|
+
{grid}
|
|
70
|
+
{legend}
|
|
71
|
+
{tooltip}
|
|
72
|
+
{highlight}
|
|
73
|
+
{trend}
|
|
74
|
+
{xFormat}
|
|
75
|
+
{yFormat}
|
|
76
|
+
{xTicks}
|
|
77
|
+
{yTicks}
|
|
78
|
+
{minorTicks}
|
|
79
|
+
{onselect}
|
|
80
|
+
{selectable}
|
|
81
|
+
bind:selected
|
|
82
|
+
>
|
|
54
83
|
<Bar {x} {y} color={fill} {pattern} {label} {stat} options={{ stack }} />
|
|
55
84
|
</Plot>
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
type Row = Record<string, unknown>;
|
|
2
2
|
type Format = (v: unknown) => string;
|
|
3
|
+
type Method = string | number | {
|
|
4
|
+
type: string;
|
|
5
|
+
[k: string]: unknown;
|
|
6
|
+
};
|
|
3
7
|
type Props = {
|
|
4
8
|
data?: Row[];
|
|
5
9
|
x?: string;
|
|
@@ -15,12 +19,17 @@ type Props = {
|
|
|
15
19
|
stack?: boolean;
|
|
16
20
|
label?: boolean | string | ((data: Row) => unknown);
|
|
17
21
|
tooltip?: boolean | ((data: Row) => string);
|
|
22
|
+
highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean);
|
|
23
|
+
trend?: Method | Method[];
|
|
18
24
|
xFormat?: Format;
|
|
19
25
|
yFormat?: Format;
|
|
20
26
|
xTicks?: number;
|
|
21
27
|
yTicks?: number;
|
|
22
28
|
minorTicks?: boolean;
|
|
29
|
+
onselect?: (detail: unknown) => void;
|
|
30
|
+
selectable?: boolean;
|
|
31
|
+
selected?: Row[];
|
|
23
32
|
};
|
|
24
|
-
declare const BarChart: import("svelte").Component<Props, {}, "">;
|
|
33
|
+
declare const BarChart: import("svelte").Component<Props, {}, "selected">;
|
|
25
34
|
type BarChart = ReturnType<typeof BarChart>;
|
|
26
35
|
export default BarChart;
|