@rokkit/chart 1.6.0 → 1.8.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.
Files changed (68) hide show
  1. package/README.md +13 -0
  2. package/dist/Plot.svelte +38 -10
  3. package/dist/Plot.svelte.d.ts +7 -2
  4. package/dist/PlotState.svelte.d.ts +115 -71
  5. package/dist/PlotState.svelte.js +174 -547
  6. package/dist/SparkState.svelte.d.ts +3 -1
  7. package/dist/SparkState.svelte.js +11 -41
  8. package/dist/geoms/Contour.svelte +88 -0
  9. package/dist/geoms/Contour.svelte.d.ts +19 -0
  10. package/dist/geoms/Hull.svelte +89 -0
  11. package/dist/geoms/Hull.svelte.d.ts +17 -0
  12. package/dist/geoms/Region.svelte +107 -0
  13. package/dist/geoms/Region.svelte.d.ts +27 -0
  14. package/dist/geoms/Rule.svelte +88 -26
  15. package/dist/geoms/Rule.svelte.d.ts +8 -0
  16. package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
  17. package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
  18. package/dist/geoms/lib/bars.js +13 -20
  19. package/dist/geoms/lib/grouping.d.ts +35 -0
  20. package/dist/geoms/lib/grouping.js +59 -0
  21. package/dist/geoms/lib/marks/contour.d.ts +26 -0
  22. package/dist/geoms/lib/marks/contour.js +67 -0
  23. package/dist/geoms/lib/marks/hull.d.ts +26 -0
  24. package/dist/geoms/lib/marks/hull.js +36 -0
  25. package/dist/geoms/lib/marks/waterfall.js +15 -20
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.js +9 -0
  28. package/dist/lib/abline.d.ts +41 -0
  29. package/dist/lib/abline.js +51 -0
  30. package/dist/lib/brewing/colors.js +1 -1
  31. package/dist/lib/hull.d.ts +18 -0
  32. package/dist/lib/hull.js +64 -0
  33. package/dist/lib/plot/domains.d.ts +61 -0
  34. package/dist/lib/plot/domains.js +130 -0
  35. package/dist/lib/plot/preset.d.ts +1 -1
  36. package/dist/lib/plot/preset.js +1 -1
  37. package/dist/lib/plot/running.d.ts +23 -0
  38. package/dist/lib/plot/running.js +32 -0
  39. package/dist/lib/plot/stacking.d.ts +32 -0
  40. package/dist/lib/plot/stacking.js +34 -0
  41. package/dist/lib/plot/types.d.ts +8 -1
  42. package/dist/lib/plot/types.js +5 -1
  43. package/dist/lib/preset.d.ts +2 -0
  44. package/dist/lib/preset.js +76 -41
  45. package/dist/lib/region.d.ts +63 -0
  46. package/dist/lib/region.js +127 -0
  47. package/dist/lib/swatch.d.ts +1 -1
  48. package/dist/lib/swatch.js +1 -1
  49. package/dist/state/AestheticState.svelte.d.ts +43 -0
  50. package/dist/state/AestheticState.svelte.js +84 -0
  51. package/dist/state/AxisState.svelte.d.ts +26 -0
  52. package/dist/state/AxisState.svelte.js +56 -0
  53. package/dist/state/ChannelState.svelte.d.ts +34 -0
  54. package/dist/state/ChannelState.svelte.js +67 -0
  55. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  56. package/dist/state/GeomRegistry.svelte.js +86 -0
  57. package/dist/state/InteractionState.svelte.d.ts +30 -0
  58. package/dist/state/InteractionState.svelte.js +69 -0
  59. package/dist/state/OrientationState.svelte.d.ts +34 -0
  60. package/dist/state/OrientationState.svelte.js +64 -0
  61. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  62. package/dist/state/PlotConfig.svelte.js +195 -0
  63. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  64. package/dist/state/PlotFrame.svelte.js +18 -0
  65. package/dist/state/ScaleState.svelte.d.ts +36 -0
  66. package/dist/state/ScaleState.svelte.js +121 -0
  67. package/package.json +5 -3
  68. package/dist/lib/palette.json +0 -277
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Region geometry — data-coordinate shapes resolved to plot (u, v) coordinates.
3
+ *
4
+ * Kept out of the component so the maths is testable without a DOM, and so every geom that
5
+ * needs "a shape in data space" (Region today, anything shaded tomorrow) resolves it the same
6
+ * way: continuous values map straight through the scale, band categories cover the WHOLE
7
+ * band, and an omitted range spans the axis.
8
+ */
9
+
10
+ /** @typedef {{ (v: unknown): number | undefined, bandwidth?: () => number, range: () => number[], domain: () => unknown[] }} AnyScale */
11
+
12
+ /** @param {AnyScale} scale */
13
+ const isBand = (scale) => typeof scale.bandwidth === 'function'
14
+
15
+ /**
16
+ * The data value an open (null/undefined) range end stands for: the low or high end of the
17
+ * axis's domain — the first or last category on a band axis.
18
+ *
19
+ * @param {AnyScale} scale
20
+ * @param {unknown} v
21
+ * @param {'lo' | 'hi'} side
22
+ */
23
+ function endValue(scale, v, side) {
24
+ if (v !== null && v !== undefined) return v
25
+ const domain = scale.domain()
26
+ if (isBand(scale)) return side === 'lo' ? domain[0] : domain[domain.length - 1]
27
+ const nums = domain.map(Number)
28
+ return side === 'lo' ? Math.min(...nums) : Math.max(...nums)
29
+ }
30
+
31
+ /**
32
+ * Screen span of a [lo, hi] range on one axis. A band category contributes its whole band,
33
+ * so `x={['a', 'b']}` shades both columns edge to edge. Omitted → the full range; a `null`
34
+ * end runs to that edge of the axis, so `[p75, null]` is "the top quartile and beyond".
35
+ *
36
+ * @param {AnyScale} scale
37
+ * @param {unknown[] | undefined} range
38
+ * @returns {[number, number]}
39
+ */
40
+ export function spanOf(scale, range) {
41
+ if (!range || range.length < 2) {
42
+ const r = scale.range()
43
+ return [Math.min(...r), Math.max(...r)]
44
+ }
45
+ const band = isBand(scale) ? /** @type {() => number} */ (scale.bandwidth)() : 0
46
+ const ends = [endValue(scale, range[0], 'lo'), endValue(scale, range[1], 'hi')].map((v) =>
47
+ Number(scale(v))
48
+ )
49
+ return [Math.min(...ends), Math.max(...ends) + band]
50
+ }
51
+
52
+ /**
53
+ * Position of a single data value — band categories resolve to the band centre, which is
54
+ * where a point in that category is drawn.
55
+ *
56
+ * @param {AnyScale} scale
57
+ * @param {unknown} v
58
+ */
59
+ export function positionOf(scale, v) {
60
+ const base = Number(scale(v))
61
+ return isBand(scale) ? base + /** @type {() => number} */ (scale.bandwidth)() / 2 : base
62
+ }
63
+
64
+ /**
65
+ * The region's vertices in (u, v) plot coordinates — before orientation `place`.
66
+ *
67
+ * `points` (a polygon, three or more vertices) wins over `x`/`y` ranges. Fewer than three
68
+ * points is not an area, so it resolves to nothing rather than a degenerate sliver.
69
+ *
70
+ * @param {{ x?: unknown[], y?: unknown[], points?: unknown[][] }} shape
71
+ * @param {AnyScale} xScale
72
+ * @param {AnyScale} yScale
73
+ * @returns {Array<[number, number]>}
74
+ */
75
+ export function regionVertices(shape, xScale, yScale) {
76
+ if (shape.points) {
77
+ if (shape.points.length < 3) return []
78
+ return shape.points.map(([x, y]) => [positionOf(xScale, x), positionOf(yScale, y)])
79
+ }
80
+ if (!shape.x && !shape.y) return []
81
+ const [x0, x1] = spanOf(xScale, shape.x)
82
+ const [y0, y1] = spanOf(yScale, shape.y)
83
+ // Wound so the first vertex is the (lo-x, lo-y) corner in DATA terms: on a vertical chart
84
+ // the y range is inverted, so y1 (the larger pixel) is the lower value.
85
+ return [
86
+ [x0, y1],
87
+ [x1, y1],
88
+ [x1, y0],
89
+ [x0, y0]
90
+ ]
91
+ }
92
+
93
+ /**
94
+ * Area centroid of a simple polygon — where its label goes. Falls back to the vertex mean for
95
+ * a zero-area (collinear) polygon, which has no area centroid.
96
+ *
97
+ * @param {Array<{ x: number, y: number }>} pts
98
+ * @returns {{ x: number, y: number }}
99
+ */
100
+ export function centroid(pts) {
101
+ let a = 0
102
+ let cx = 0
103
+ let cy = 0
104
+ for (let i = 0; i < pts.length; i++) {
105
+ const p = pts[i]
106
+ const q = pts[(i + 1) % pts.length]
107
+ const cross = p.x * q.y - q.x * p.y
108
+ a += cross
109
+ cx += (p.x + q.x) * cross
110
+ cy += (p.y + q.y) * cross
111
+ }
112
+ if (Math.abs(a) < 1e-9) {
113
+ const n = pts.length || 1
114
+ return { x: pts.reduce((s, p) => s + p.x, 0) / n, y: pts.reduce((s, p) => s + p.y, 0) / n }
115
+ }
116
+ return { x: cx / (3 * a), y: cy / (3 * a) }
117
+ }
118
+
119
+ /**
120
+ * SVG path data for a closed polygon.
121
+ *
122
+ * @param {Array<{ x: number, y: number }>} pts
123
+ */
124
+ export function polygonPath(pts) {
125
+ const fmt = (/** @type {number} */ n) => Math.round(n * 1000) / 1000
126
+ return `M${pts.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(' L')} Z`
127
+ }
@@ -7,6 +7,6 @@ export namespace swatch {
7
7
  export { PATTERN_ORDER as pattern };
8
8
  }
9
9
  }
10
- import palette from './palette.json';
10
+ import { categoricalPalette as palette } from '@rokkit/core';
11
11
  import { shapes } from '../symbols';
12
12
  import { PATTERN_ORDER } from './brewing/patterns.js';
@@ -1,4 +1,4 @@
1
- import palette from './palette.json'
1
+ import { categoricalPalette as palette } from '@rokkit/core'
2
2
  import { PATTERN_ORDER } from './brewing/patterns.js'
3
3
  import { shapes } from '../symbols'
4
4
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What each category looks like: its palette entry, pattern and symbol — and, for a numeric
3
+ * colour field, the continuous (sequential or diverging) colour scale.
4
+ */
5
+ export class AestheticState {
6
+ /**
7
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
8
+ * @param {import('./ChannelState.svelte.js').ChannelState} channels
9
+ */
10
+ constructor(config: import("./PlotConfig.svelte.js").PlotConfig, channels: import("./ChannelState.svelte.js").ChannelState);
11
+ /** 'categorical' | 'sequential' | 'diverging' — inferred from the colour field and the spec. */
12
+ colorScaleType: any;
13
+ /** The sequential or diverging colour scale, or null for a categorical colour field. */
14
+ continuousColorScale: {
15
+ scale: (v: number) => string;
16
+ domain: [number, number];
17
+ type: "sequential";
18
+ } | {
19
+ scale: (v: number) => string;
20
+ domain: [number, number, number];
21
+ type: "diverging";
22
+ } | null;
23
+ /**
24
+ * Map<category, { fill, stroke }> — one shared palette across the colour and fill channels.
25
+ *
26
+ * A literal CSS colour is a singleton keyed by null, which every mark falls back to. A
27
+ * `colorDomain` (FacetPlot passes one so panels agree) wins over the local categories. With
28
+ * no colour field at all, a single series takes the first palette entry rather than grey.
29
+ */
30
+ colors: Map<unknown, {
31
+ fill: string;
32
+ stroke: string;
33
+ }> | Map<null, {
34
+ fill: any;
35
+ stroke: any;
36
+ }>;
37
+ /** Map<category, pattern name> — only for a categorical pattern field. */
38
+ patterns: Map<unknown, string> | SvelteMap<any, any>;
39
+ /** Map<category, symbol shape>. */
40
+ symbols: Map<unknown, string> | SvelteMap<any, any>;
41
+ #private;
42
+ }
43
+ import { SvelteMap } from 'svelte/reactivity';
@@ -0,0 +1,84 @@
1
+ import { SvelteMap } from 'svelte/reactivity'
2
+ import { inferFieldType, inferColorScaleType } from '../lib/plot/scales.js'
3
+ import {
4
+ distinct,
5
+ assignColors,
6
+ isLiteralColor,
7
+ buildSequentialScale,
8
+ buildDivergingScale
9
+ } from '../lib/brewing/colors.js'
10
+ import { assignPatterns } from '../lib/brewing/patterns.js'
11
+ import { assignSymbols } from '../lib/brewing/marks/points.js'
12
+
13
+ /**
14
+ * What each category looks like: its palette entry, pattern and symbol — and, for a numeric
15
+ * colour field, the continuous (sequential or diverging) colour scale.
16
+ */
17
+ export class AestheticState {
18
+ #config
19
+ #channels
20
+
21
+ /**
22
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
23
+ * @param {import('./ChannelState.svelte.js').ChannelState} channels
24
+ */
25
+ constructor(config, channels) {
26
+ this.#config = config
27
+ this.#channels = channels
28
+ }
29
+
30
+ /** 'categorical' | 'sequential' | 'diverging' — inferred from the colour field and the spec. */
31
+ colorScaleType = $derived.by(() => {
32
+ const field = this.#channels.effective.color
33
+ if (!field) return 'categorical'
34
+ return inferColorScaleType(this.#config.data, field, {
35
+ colorScale: this.#config.colorScale,
36
+ colorMidpoint: this.#config.colorMidpoint
37
+ })
38
+ })
39
+
40
+ /** The sequential or diverging colour scale, or null for a categorical colour field. */
41
+ continuousColorScale = $derived.by(() => {
42
+ const field = this.#channels.effective.color
43
+ if (!field || this.colorScaleType === 'categorical') return null
44
+ const opts = {
45
+ colorScheme: this.#config.colorScheme,
46
+ colorDomain: this.#config.colorDomain,
47
+ colorMidpoint: this.#config.colorMidpoint
48
+ }
49
+ const build = this.colorScaleType === 'diverging' ? buildDivergingScale : buildSequentialScale
50
+ return build(this.#config.data, field, opts)
51
+ })
52
+
53
+ /**
54
+ * Map<category, { fill, stroke }> — one shared palette across the colour and fill channels.
55
+ *
56
+ * A literal CSS colour is a singleton keyed by null, which every mark falls back to. A
57
+ * `colorDomain` (FacetPlot passes one so panels agree) wins over the local categories. With
58
+ * no colour field at all, a single series takes the first palette entry rather than grey.
59
+ */
60
+ colors = $derived.by(() => {
61
+ const field = this.#channels.effective.color
62
+ if (isLiteralColor(field)) {
63
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- a derived value, rebuilt whole; nothing mutates it
64
+ return new Map([[null, { fill: field, stroke: field }]])
65
+ }
66
+ const values = this.#config.colorDomain ?? this.#channels.colorValues
67
+ const keys = values.length === 0 && this.#config.data.length > 0 ? [null] : values
68
+ return assignColors(keys, this.#config.mode, this.#config.chartPreset)
69
+ })
70
+
71
+ /** Map<category, pattern name> — only for a categorical pattern field. */
72
+ patterns = $derived.by(() => {
73
+ const pf = this.#channels.effective.pattern
74
+ if (!pf || inferFieldType(this.#config.data, pf) === 'continuous') return new SvelteMap()
75
+ return assignPatterns(distinct(this.#config.data, pf))
76
+ })
77
+
78
+ /** Map<category, symbol shape>. */
79
+ symbols = $derived.by(() => {
80
+ const sf = this.#channels.effective.symbol
81
+ if (!sf) return new SvelteMap()
82
+ return assignSymbols(distinct(this.#config.data, sf), this.#config.chartPreset)
83
+ })
84
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where the axes cross, in screen coordinates.
3
+ *
4
+ * At an explicit `axisOrigin` (a BCG or risk matrix); else at zero when the domain spans it
5
+ * (two- and four-quadrant charts fall out of where zero sits); else pinned to the bottom / left
6
+ * edge, pushed outward by `axisOffset`. A band x axis always pins left.
7
+ */
8
+ export class AxisState {
9
+ /**
10
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
11
+ * @param {{ x: any, y: any }} scales
12
+ * @param {{ innerWidth: number, innerHeight: number }} frame
13
+ */
14
+ constructor(config: import("./PlotConfig.svelte.js").PlotConfig, scales: {
15
+ x: any;
16
+ y: any;
17
+ }, frame: {
18
+ innerWidth: number;
19
+ innerHeight: number;
20
+ });
21
+ /** The x axis's screen y. */
22
+ xAxisY: any;
23
+ /** The y axis's screen x. */
24
+ yAxisX: any;
25
+ #private;
26
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Where the axes cross, in screen coordinates.
3
+ *
4
+ * At an explicit `axisOrigin` (a BCG or risk matrix); else at zero when the domain spans it
5
+ * (two- and four-quadrant charts fall out of where zero sits); else pinned to the bottom / left
6
+ * edge, pushed outward by `axisOffset`. A band x axis always pins left.
7
+ */
8
+ export class AxisState {
9
+ #config
10
+ #scales
11
+ #frame
12
+
13
+ /**
14
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
15
+ * @param {{ x: any, y: any }} scales
16
+ * @param {{ innerWidth: number, innerHeight: number }} frame
17
+ */
18
+ constructor(config, scales, frame) {
19
+ this.#config = config
20
+ this.#scales = scales
21
+ this.#frame = frame
22
+ }
23
+
24
+ /** The x axis's screen y. */
25
+ xAxisY = $derived.by(() => {
26
+ const y = this.#scales.y
27
+ if (typeof y !== 'function') return this.#frame.innerHeight
28
+ const cross = this.#config.axisOrigin[1]
29
+ if (cross !== undefined) return y(cross)
30
+ const domain = y.domain()
31
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return y(0)
32
+ return this.#offset(y(domain[0]), 1)
33
+ })
34
+
35
+ /** The y axis's screen x. */
36
+ yAxisX = $derived.by(() => {
37
+ const x = this.#scales.x
38
+ if (typeof x !== 'function') return 0
39
+ const cross = this.#config.axisOrigin[0]
40
+ if (cross !== undefined) return x(cross)
41
+ if (typeof x.bandwidth === 'function') return 0
42
+ const domain = x.domain()
43
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return x(0)
44
+ return this.#offset(x(domain[0]), -1)
45
+ })
46
+
47
+ /**
48
+ * Push an edge-pinned axis outward by `axisOffset`. Only when there IS an offset: a scale with
49
+ * an empty domain (a race's first frame, before any rows) returns undefined, and
50
+ * `undefined + 0` is NaN — which lands in the axis transform as `translate(NaN, 0)`.
51
+ */
52
+ #offset(base, direction) {
53
+ const offset = this.#config.axisOffset
54
+ return offset ? base + direction * offset : base
55
+ }
56
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which data field feeds each aesthetic, once the container and its geoms are merged.
3
+ */
4
+ export class ChannelState {
5
+ /**
6
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
7
+ * @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
8
+ */
9
+ constructor(config: import("./PlotConfig.svelte.js").PlotConfig, geoms: import("./GeomRegistry.svelte.js").GeomRegistry);
10
+ /**
11
+ * The container's channels, with each field it omits taken from the FIRST geom — per field,
12
+ * not all-or-nothing, so a `<Plot.Root x y>` that omits `color` still picks up
13
+ * `<Plot.Box color=x>`'s, and the palette gets per-category entries instead of grey.
14
+ */
15
+ effective: import("./PlotConfig.svelte.js").Channels | {
16
+ [k: string]: any;
17
+ };
18
+ /** The colour field, or null when `color` is a literal CSS colour rather than a field. */
19
+ colorField: any;
20
+ /** The fill field, or null for a literal colour. */
21
+ fillField: any;
22
+ patternField: any;
23
+ symbolField: any;
24
+ /**
25
+ * The category values feeding ONE shared palette and legend: every `color` and `fill` field
26
+ * of the container and of every geom (literal colours excluded), their values unioned in
27
+ * order. See docs/backlog/2026-08-17-chart-aesthetics-unification.md §2.
28
+ *
29
+ * `includes` rather than a Set: a domain of dozens of categories, not rows, so the scan
30
+ * costs nothing and there is no collection for the reactivity linter to flag.
31
+ */
32
+ colorValues: unknown[];
33
+ #private;
34
+ }
@@ -0,0 +1,67 @@
1
+ import { distinct, isLiteralColor } from '../lib/brewing/colors.js'
2
+
3
+ /** The positional and aesthetic channels a container can inherit from its first geom. */
4
+ const INHERITED = ['x', 'y', 'color', 'fill', 'pattern', 'symbol']
5
+
6
+ /**
7
+ * Which data field feeds each aesthetic, once the container and its geoms are merged.
8
+ */
9
+ export class ChannelState {
10
+ #config
11
+ #geoms
12
+
13
+ /**
14
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
15
+ * @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
16
+ */
17
+ constructor(config, geoms) {
18
+ this.#config = config
19
+ this.#geoms = geoms
20
+ }
21
+
22
+ /**
23
+ * The container's channels, with each field it omits taken from the FIRST geom — per field,
24
+ * not all-or-nothing, so a `<Plot.Root x y>` that omits `color` still picks up
25
+ * `<Plot.Box color=x>`'s, and the palette gets per-category entries instead of grey.
26
+ */
27
+ effective = $derived.by(() => {
28
+ const tc = this.#config.channels
29
+ const geom = this.#geoms.first
30
+ if (!geom) return tc
31
+ const own = geom.channels ?? {}
32
+ return Object.fromEntries(INHERITED.map((key) => [key, tc[key] ?? own[key]]))
33
+ })
34
+
35
+ /** The colour field, or null when `color` is a literal CSS colour rather than a field. */
36
+ colorField = $derived(isLiteralColor(this.effective.color) ? null : this.effective.color)
37
+ /** The fill field, or null for a literal colour. */
38
+ fillField = $derived(isLiteralColor(this.effective.fill) ? null : this.effective.fill)
39
+ patternField = $derived(this.effective.pattern)
40
+ symbolField = $derived(this.effective.symbol)
41
+
42
+ /**
43
+ * The category values feeding ONE shared palette and legend: every `color` and `fill` field
44
+ * of the container and of every geom (literal colours excluded), their values unioned in
45
+ * order. See docs/backlog/2026-08-17-chart-aesthetics-unification.md §2.
46
+ *
47
+ * `includes` rather than a Set: a domain of dozens of categories, not rows, so the scan
48
+ * costs nothing and there is no collection for the reactivity linter to flag.
49
+ */
50
+ colorValues = $derived.by(() => {
51
+ const fields = []
52
+ const add = (f) => {
53
+ if (f && !isLiteralColor(f) && !fields.includes(f)) fields.push(f)
54
+ }
55
+ add(this.#config.channels.color)
56
+ add(this.#config.channels.fill)
57
+ for (const g of this.#geoms.list) {
58
+ add(g.channels?.color)
59
+ add(g.channels?.fill)
60
+ }
61
+ const values = []
62
+ for (const f of fields) {
63
+ for (const v of distinct(this.#config.data, f)) if (!values.includes(v)) values.push(v)
64
+ }
65
+ return values
66
+ })
67
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The geoms mounted in one plot, and the rows each one draws.
3
+ *
4
+ * Shared by `PlotState` and `SparkState`, which used to carry a copy each — and so needed the
5
+ * undefined-channel fix below made twice. `source` is whatever owns the rows and the container
6
+ * channels: `{ data, channels, helpers? }`, read live through its getters.
7
+ */
8
+ export class GeomRegistry {
9
+ /**
10
+ * @param {{ data: Record<string, unknown>[], channels: Record<string, string | undefined>, helpers?: Record<string, unknown> }} source
11
+ * @param {string} [prefix] - id prefix, so a plot's geom ids and a spark's never collide
12
+ */
13
+ constructor(source: {
14
+ data: Record<string, unknown>[];
15
+ channels: Record<string, string | undefined>;
16
+ helpers?: Record<string, unknown>;
17
+ }, prefix?: string);
18
+ /** Every mounted geom's config, in mount order. */
19
+ get list(): any[];
20
+ /** The first mounted geom — the one a container without channels borrows them from. */
21
+ get first(): any;
22
+ /** Distinct geom types mounted (the legend picks a swatch style from these). */
23
+ types: SvelteSet<any>;
24
+ /** @param {(g: any) => boolean} predicate */
25
+ find(predicate: (g: any) => boolean): any;
26
+ /** Called once per geom from its onMount; the returned id is its handle. */
27
+ register(config: any): string;
28
+ /**
29
+ * Called from a geom's `$effect`. The read of the list is untracked: tracked, the effect
30
+ * would re-run on its own write (effect_update_depth_exceeded).
31
+ */
32
+ update(id: any, config: any): void;
33
+ /** Called from a geom's onDestroy, which Svelte runs outside any tracking scope. */
34
+ unregister(id: any): void;
35
+ /**
36
+ * The rows one geom draws.
37
+ *
38
+ * Identity stat returns the source rows THEMSELVES, not a copy: geoms look a row up with
39
+ * `plotState.data.indexOf(row)`, which only matches the same object.
40
+ */
41
+ data(id: any): Object[];
42
+ #private;
43
+ }
44
+ import { SvelteSet } from 'svelte/reactivity';
@@ -0,0 +1,86 @@
1
+ import { untrack } from 'svelte'
2
+ import { SvelteSet } from 'svelte/reactivity'
3
+ import { applyGeomStat } from '../lib/plot/stat.js'
4
+
5
+ /** Module-wide so ids stay unique across every plot and spark on the page. */
6
+ let nextId = 0
7
+
8
+ /**
9
+ * The geoms mounted in one plot, and the rows each one draws.
10
+ *
11
+ * Shared by `PlotState` and `SparkState`, which used to carry a copy each — and so needed the
12
+ * undefined-channel fix below made twice. `source` is whatever owns the rows and the container
13
+ * channels: `{ data, channels, helpers? }`, read live through its getters.
14
+ */
15
+ export class GeomRegistry {
16
+ #source
17
+ #prefix
18
+ #geoms = $state([])
19
+
20
+ /**
21
+ * @param {{ data: Record<string, unknown>[], channels: Record<string, string | undefined>, helpers?: Record<string, unknown> }} source
22
+ * @param {string} [prefix] - id prefix, so a plot's geom ids and a spark's never collide
23
+ */
24
+ constructor(source, prefix = 'geom') {
25
+ this.#source = source
26
+ this.#prefix = prefix
27
+ }
28
+
29
+ /** Every mounted geom's config, in mount order. */
30
+ get list() {
31
+ return this.#geoms
32
+ }
33
+ /** The first mounted geom — the one a container without channels borrows them from. */
34
+ get first() {
35
+ return this.#geoms[0]
36
+ }
37
+ /** Distinct geom types mounted (the legend picks a swatch style from these). */
38
+ types = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
39
+
40
+ /** @param {(g: any) => boolean} predicate */
41
+ find(predicate) {
42
+ return this.#geoms.find(predicate)
43
+ }
44
+
45
+ /** Called once per geom from its onMount; the returned id is its handle. */
46
+ register(config) {
47
+ const id = `${this.#prefix}-${nextId++}`
48
+ this.#geoms = [...this.#geoms, { id, ...config }]
49
+ return id
50
+ }
51
+
52
+ /**
53
+ * Called from a geom's `$effect`. The read of the list is untracked: tracked, the effect
54
+ * would re-run on its own write (effect_update_depth_exceeded).
55
+ */
56
+ update(id, config) {
57
+ this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
58
+ }
59
+
60
+ /** Called from a geom's onDestroy, which Svelte runs outside any tracking scope. */
61
+ unregister(id) {
62
+ this.#geoms = this.#geoms.filter((g) => g.id !== id)
63
+ }
64
+
65
+ /**
66
+ * The rows one geom draws.
67
+ *
68
+ * Identity stat returns the source rows THEMSELVES, not a copy: geoms look a row up with
69
+ * `plotState.data.indexOf(row)`, which only matches the same object.
70
+ */
71
+ data(id) {
72
+ const geom = this.#geoms.find((g) => g.id === id)
73
+ if (!geom) return []
74
+ const stat = geom.stat ?? 'identity'
75
+ const rows = this.#source.data
76
+ if (stat === 'identity') return rows
77
+ // A geom that omits a channel to inherit it from the container sends
78
+ // `{ x: undefined }`, not `{}`. Spread as-is, that own `undefined` would clobber the
79
+ // container's value and the stat would silently fall back to identity.
80
+ const geomChannels = Object.fromEntries(
81
+ Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
82
+ )
83
+ const channels = { ...this.#source.channels, ...geomChannels }
84
+ return applyGeomStat(rows, { stat, channels }, this.#source.helpers)
85
+ }
86
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What the reader is doing to the chart: the hovered row, the selected rows, the zoom.
3
+ *
4
+ * The selection is a set of ROW objects, compared by identity — they must be the rows the
5
+ * geoms were handed (`PlotState.data`), which is why the plot keeps one proxy for its data.
6
+ */
7
+ export class InteractionState {
8
+ /**
9
+ * @param {import('./PlotConfig.svelte.js').PlotConfig} config
10
+ * @param {unknown[]} [selected] - the initial selection; afterwards the caller drives it
11
+ * through `setSelected` (PlotSurface syncs its bindable prop that way)
12
+ */
13
+ constructor(config: import("./PlotConfig.svelte.js").PlotConfig, selected?: unknown[]);
14
+ get hovered(): null;
15
+ setHovered(row: any): void;
16
+ clearHovered(): void;
17
+ /** Marks are clickable: the caller listens, or selection is on. */
18
+ get interactive(): boolean;
19
+ get selectedRows(): any[];
20
+ isSelected(row: any): boolean;
21
+ setSelected(rows: any): void;
22
+ clearSelected(): void;
23
+ /** An activation: always reported; toggles the row in the selection when selectable. */
24
+ handleSelect(detail: any): void;
25
+ /** The d3-zoom transform, or null at rest. Scales rescale through it. */
26
+ get zoom(): null;
27
+ applyZoom(transform: any): void;
28
+ resetZoom(): void;
29
+ #private;
30
+ }