@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.
Files changed (93) hide show
  1. package/dist/AnimatedPlot.svelte +26 -8
  2. package/dist/AnimatedPlot.svelte.d.ts +5 -0
  3. package/dist/Chart.svelte +1 -1
  4. package/dist/FacetPlot.svelte +1 -1
  5. package/dist/Plot/Axis.svelte +21 -10
  6. package/dist/Plot/Grid.svelte +32 -13
  7. package/dist/Plot/Grid.svelte.d.ts +6 -1
  8. package/dist/Plot/Root.svelte +64 -46
  9. package/dist/Plot/Root.svelte.d.ts +27 -2
  10. package/dist/Plot/Timeline.svelte +39 -13
  11. package/dist/Plot/index.d.ts +4 -4
  12. package/dist/Plot/index.js +4 -4
  13. package/dist/Plot.svelte +128 -164
  14. package/dist/Plot.svelte.d.ts +18 -2
  15. package/dist/PlotState.svelte.d.ts +23 -0
  16. package/dist/PlotState.svelte.js +155 -16
  17. package/dist/PlotSurface.svelte +162 -0
  18. package/dist/PlotSurface.svelte.d.ts +25 -0
  19. package/dist/charts/AreaChart.svelte +37 -4
  20. package/dist/charts/AreaChart.svelte.d.ts +12 -2
  21. package/dist/charts/BarChart.svelte +32 -3
  22. package/dist/charts/BarChart.svelte.d.ts +10 -1
  23. package/dist/charts/BoxPlot.svelte +4 -2
  24. package/dist/charts/BoxPlot.svelte.d.ts +1 -0
  25. package/dist/charts/BubbleChart.svelte +34 -3
  26. package/dist/charts/BubbleChart.svelte.d.ts +11 -1
  27. package/dist/charts/LineChart.svelte +35 -4
  28. package/dist/charts/LineChart.svelte.d.ts +11 -2
  29. package/dist/charts/PieChart.svelte +1 -1
  30. package/dist/charts/ScatterPlot.svelte +32 -3
  31. package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
  32. package/dist/charts/ViolinPlot.svelte +4 -2
  33. package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
  34. package/dist/examples/BarChartExample.svelte +1 -1
  35. package/dist/geoms/Arc.svelte +3 -2
  36. package/dist/geoms/Area.svelte +20 -3
  37. package/dist/geoms/Bar.svelte +57 -13
  38. package/dist/geoms/Box.svelte +131 -76
  39. package/dist/geoms/Box.svelte.d.ts +7 -0
  40. package/dist/geoms/Candlestick.svelte +17 -11
  41. package/dist/geoms/Highlight.svelte +105 -0
  42. package/dist/geoms/Highlight.svelte.d.ts +10 -0
  43. package/dist/geoms/Jitter.svelte +82 -0
  44. package/dist/geoms/Jitter.svelte.d.ts +15 -0
  45. package/dist/geoms/Line.svelte +26 -9
  46. package/dist/geoms/Point.svelte +37 -11
  47. package/dist/geoms/Rule.svelte +113 -0
  48. package/dist/geoms/Rule.svelte.d.ts +16 -0
  49. package/dist/geoms/Trend.svelte +66 -0
  50. package/dist/geoms/Trend.svelte.d.ts +12 -0
  51. package/dist/geoms/Violin.svelte +30 -6
  52. package/dist/geoms/Violin.svelte.d.ts +5 -0
  53. package/dist/geoms/Waterfall.svelte +16 -9
  54. package/dist/geoms/lib/areas.d.ts +6 -2
  55. package/dist/geoms/lib/areas.js +17 -11
  56. package/dist/geoms/lib/bars.d.ts +18 -3
  57. package/dist/geoms/lib/bars.js +70 -96
  58. package/dist/index.d.ts +30 -4
  59. package/dist/index.js +31 -5
  60. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
  61. package/dist/lib/brewing/brewer.svelte.d.ts +1 -0
  62. package/dist/lib/brewing/marks/arcs.js +7 -1
  63. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  64. package/dist/lib/brewing/marks/boxes.js +32 -11
  65. package/dist/lib/brewing/marks/lines.d.ts +6 -2
  66. package/dist/lib/brewing/marks/lines.js +19 -12
  67. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  68. package/dist/lib/brewing/marks/swarm.js +125 -0
  69. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  70. package/dist/lib/brewing/marks/violins.js +41 -25
  71. package/dist/lib/brewing/stats.d.ts +3 -1
  72. package/dist/lib/brewing/stats.js +28 -11
  73. package/dist/lib/highlight.d.ts +10 -0
  74. package/dist/lib/highlight.js +43 -0
  75. package/dist/lib/plot/scales.js +12 -7
  76. package/dist/lib/plot/types.d.ts +9 -1
  77. package/dist/lib/plot/types.js +3 -1
  78. package/dist/lib/scale.d.ts +10 -0
  79. package/dist/lib/scale.js +14 -0
  80. package/dist/lib/select.d.ts +22 -0
  81. package/dist/lib/select.js +22 -0
  82. package/dist/lib/trend.d.ts +27 -0
  83. package/dist/lib/trend.js +113 -0
  84. package/dist/patterns/README.md +1 -1
  85. package/package.json +1 -1
  86. package/dist/Plot/Area.svelte +0 -54
  87. package/dist/Plot/Area.svelte.d.ts +0 -14
  88. package/dist/Plot/Bar.svelte +0 -69
  89. package/dist/Plot/Bar.svelte.d.ts +0 -11
  90. package/dist/Plot/Line.svelte +0 -54
  91. package/dist/Plot/Line.svelte.d.ts +0 -13
  92. package/dist/Plot/Point.svelte +0 -58
  93. package/dist/Plot/Point.svelte.d.ts +0 -13
@@ -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; fall back to first geom's channels
54
- // for the declarative API where no spec is provided.
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 hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
75
- return hasBarGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
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
- const includeZero = this.orientation === 'horizontal'
119
- // For vertical bar charts, force scaleBand even when X values are numeric (e.g. year).
120
- // Horizontal bar charts keep X as a continuous value axis.
121
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
122
- const bandX = hasBarGeom && this.orientation !== 'horizontal'
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: this.#xDomain,
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 mins = boxData.map((d) => d.iqr_min).filter(isValid)
140
- const maxs = boxData.map((d) => d.iqr_max).filter(isValid)
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
- const includeZero = this.orientation === 'vertical'
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
- const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
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 = 'light',
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 {data} {width} {height} {mode} {grid} {legend} {xFormat} {yFormat} {xTicks} {yTicks} {minorTicks}>
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 = 'light',
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 {data} {width} {height} {mode} {grid} {legend} {tooltip} {xFormat} {yFormat} {xTicks} {yTicks} {minorTicks}>
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;