@rokkit/chart 1.7.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 (62) 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/hull.d.ts +18 -0
  31. package/dist/lib/hull.js +64 -0
  32. package/dist/lib/plot/domains.d.ts +61 -0
  33. package/dist/lib/plot/domains.js +130 -0
  34. package/dist/lib/plot/running.d.ts +23 -0
  35. package/dist/lib/plot/running.js +32 -0
  36. package/dist/lib/plot/stacking.d.ts +32 -0
  37. package/dist/lib/plot/stacking.js +34 -0
  38. package/dist/lib/plot/types.d.ts +8 -1
  39. package/dist/lib/plot/types.js +5 -1
  40. package/dist/lib/preset.d.ts +2 -0
  41. package/dist/lib/preset.js +76 -41
  42. package/dist/lib/region.d.ts +63 -0
  43. package/dist/lib/region.js +127 -0
  44. package/dist/state/AestheticState.svelte.d.ts +43 -0
  45. package/dist/state/AestheticState.svelte.js +84 -0
  46. package/dist/state/AxisState.svelte.d.ts +26 -0
  47. package/dist/state/AxisState.svelte.js +56 -0
  48. package/dist/state/ChannelState.svelte.d.ts +34 -0
  49. package/dist/state/ChannelState.svelte.js +67 -0
  50. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  51. package/dist/state/GeomRegistry.svelte.js +86 -0
  52. package/dist/state/InteractionState.svelte.d.ts +30 -0
  53. package/dist/state/InteractionState.svelte.js +69 -0
  54. package/dist/state/OrientationState.svelte.d.ts +34 -0
  55. package/dist/state/OrientationState.svelte.js +64 -0
  56. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  57. package/dist/state/PlotConfig.svelte.js +195 -0
  58. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  59. package/dist/state/PlotFrame.svelte.js +18 -0
  60. package/dist/state/ScaleState.svelte.d.ts +36 -0
  61. package/dist/state/ScaleState.svelte.js +121 -0
  62. package/package.json +5 -3
@@ -1,613 +1,240 @@
1
- import { untrack } from 'svelte'
2
- import { SvelteMap, SvelteSet } from 'svelte/reactivity'
3
- import { applyGeomStat } from './lib/plot/stat.js'
4
- import {
5
- inferFieldType,
6
- inferOrientation,
7
- buildUnifiedXScale,
8
- buildUnifiedYScale,
9
- inferColorScaleType
10
- } from './lib/plot/scales.js'
11
- import { resolvePreset } from './lib/plot/preset.js'
12
- import { resolveFormat, resolveTooltip, resolveGeom } from './lib/plot/helpers.js'
13
- import { defaultPreset } from './lib/preset.js'
14
- import { distinct, assignColors, isLiteralColor, buildSequentialScale, buildDivergingScale } from './lib/brewing/colors.js'
15
- import { assignPatterns } from './lib/brewing/patterns.js'
16
- import { assignSymbols } from './lib/brewing/marks/points.js'
17
-
18
- let nextId = 0
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
-
1
+ import { PlotConfig } from './state/PlotConfig.svelte.js'
2
+ import { GeomRegistry } from './state/GeomRegistry.svelte.js'
3
+ import { PlotFrame } from './state/PlotFrame.svelte.js'
4
+ import { ChannelState } from './state/ChannelState.svelte.js'
5
+ import { OrientationState } from './state/OrientationState.svelte.js'
6
+ import { InteractionState } from './state/InteractionState.svelte.js'
7
+ import { ScaleState } from './state/ScaleState.svelte.js'
8
+ import { AestheticState } from './state/AestheticState.svelte.js'
9
+ import { AxisState } from './state/AxisState.svelte.js'
10
+
11
+ /**
12
+ * The state a `PlotChart` publishes on the `'plot-state'` context — a COMPOSITION of job
13
+ * classes (`./state/`), each owning one concern and reading only the ones built before it:
14
+ *
15
+ * config → geoms → frame → channels → orientation → interaction → scales → aesthetics → axes
16
+ *
17
+ * It derives nothing itself. Everything below the constructor is the contract geoms, axes, the
18
+ * legend and the tooltip read (`GEOM_CONTRACT` in SparkState.svelte.js is the geom-facing part),
19
+ * kept as one-line delegations so no consumer changed when the monolith was taken apart. New
20
+ * code can depend on a job directly — `plotState.scales`, `plotState.interactionState` — instead.
21
+ */
23
22
  export class PlotState {
24
- #data = $state([])
25
- #rawData = $state([])
26
- #channels = $state({})
27
- #labels = $state({})
28
- #helpers = $state({})
29
- #presetName = $state(undefined)
30
- #colorMidpoint = $state(undefined)
31
- #colorSpec = $state(undefined)
32
- #colorScheme = $state(undefined)
33
- #colorDomain = $state(undefined)
34
- #xDomain = $state(undefined)
35
- #yDomain = $state(undefined)
36
- #width = $state(600)
37
- #height = $state(400)
38
- #margin = $state({ top: 20, right: 20, bottom: 40, left: 50 })
39
- #marginOverride = $state(undefined)
40
-
41
- #geoms = $state([])
42
- #mode = $state('light')
43
- #chartPreset = $state(defaultPreset)
44
- #hovered = $state(null)
45
- #selected = $state(new SvelteSet())
46
- #onselect = $state(undefined)
47
- #selectable = $state(false)
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)
56
-
57
- axisOrigin = $state([undefined, undefined])
58
- #axisOffset = $state(0)
59
-
60
- #zoomTransform = $state(null)
23
+ /** The plot's inputs — the only thing `update()` writes. */
24
+ config
25
+ /** The mounted geoms and the rows each draws. */
26
+ geoms
27
+ /** The drawing area: size less margin. */
28
+ frame
29
+ /** Which field feeds each aesthetic. Suffixed: `channels` is the contract's raw map. */
30
+ channelState
31
+ /** Which way the chart reads. Suffixed: `orientation` is the contract's string. */
32
+ orientationState
33
+ /** Hover, selection, zoom. */
34
+ interactionState
35
+ /** The x / y position scales. */
36
+ scales
37
+ /** Palette, patterns, symbols, continuous colour. */
38
+ aesthetics
39
+ /** Where the axes cross. */
40
+ axes
61
41
 
62
- #effectiveMargin = $derived(this.#marginOverride ?? this.#margin)
63
- #innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
64
- #innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
65
-
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.
71
- #mergeGeomChannels(tc, geom) {
72
- return {
73
- x: tc.x ?? geom.channels?.x,
74
- y: tc.y ?? geom.channels?.y,
75
- color: tc.color ?? geom.channels?.color,
76
- fill: tc.fill ?? geom.channels?.fill,
77
- pattern: tc.pattern ?? geom.channels?.pattern,
78
- symbol: tc.symbol ?? geom.channels?.symbol
79
- }
42
+ constructor(config = {}) {
43
+ this.config = new PlotConfig(config)
44
+ this.geoms = new GeomRegistry(this.config, 'geom')
45
+ this.frame = new PlotFrame(this.config)
46
+ this.channelState = new ChannelState(this.config, this.geoms)
47
+ this.orientationState = new OrientationState(this.config, this.channelState, this.geoms)
48
+ this.interactionState = new InteractionState(this.config, config.selected)
49
+ this.scales = new ScaleState({
50
+ config: this.config,
51
+ geoms: this.geoms,
52
+ channels: this.channelState,
53
+ orientation: this.orientationState,
54
+ frame: this.frame,
55
+ interaction: this.interactionState
56
+ })
57
+ this.aesthetics = new AestheticState(this.config, this.channelState)
58
+ this.axes = new AxisState(this.config, this.scales, this.frame)
80
59
  }
81
60
 
82
- // Field names feeding the shared categorical color scale: the `color` AND `fill` channels,
83
- // from the top-level channels and EVERY geom (union, de-duped, order-preserving). Literal
84
- // CSS colors are excluded. One palette/legend spans both aesthetics — the "shared scale"
85
- // model (see docs/backlog/2026-08-17-chart-aesthetics-unification.md §2).
86
- #sharedColorValues() {
87
- const fields = []
88
- const addField = (f) => {
89
- if (f && !isLiteralColor(f) && !fields.includes(f)) fields.push(f)
90
- }
91
- addField(this.#channels.color)
92
- addField(this.#channels.fill)
93
- for (const g of this.#geoms) {
94
- addField(g.channels?.color)
95
- addField(g.channels?.fill)
96
- }
97
- // `includes` dedup rather than a Set, matching `addField` above. These are
98
- // category values feeding one palette/legend — a domain of dozens, not
99
- // rows — so the linear scan costs nothing and the function stays free of
100
- // a collection the reactivity linter has to be told to ignore.
101
- const values = []
102
- for (const f of fields) {
103
- for (const v of distinct(this.#data, f)) if (!values.includes(v)) values.push(v)
104
- }
105
- return values
61
+ /** Re-apply inputs. Which omitted fields keep and which reset: `CONFIG_FIELDS`. */
62
+ update(config) {
63
+ this.config.update(config)
106
64
  }
107
65
 
108
- #effectiveChannels = $derived.by(() => {
109
- const tc = this.#channels
110
- const firstGeom = this.#geoms[0]
111
- if (!firstGeom) return tc
112
- return this.#mergeGeomChannels(tc, firstGeom)
113
- })
114
-
115
- #resolveXType(rawXType, yType) {
116
- const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
117
- return hasBandGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
66
+ // ─── config ────────────────────────────────────────────────────────────────
67
+ /** The rows — the same objects every identity geom is handed, so `indexOf(row)` works. */
68
+ get data() {
69
+ return this.config.data
118
70
  }
119
-
120
- // Category order sorted by aggregated value (sum of the value channel per category).
121
- // Drives "sort bars by size" for the band axis + its ticks. Returns null when not sorting.
122
- #sortedBandDomain(datasets) {
123
- if (!this.#sort) return null
124
- const xf = this.#effectiveChannels.x
125
- const yf = this.#effectiveChannels.y
126
- if (!xf || !yf) return null
127
- // Keyed accumulator, function-local and discarded before returning — a
128
- // SvelteMap would register a reactive signal per category on every
129
- // derivation for a collection nothing outside this call can observe.
130
- // Not a plain object either: keys are raw data values (numbers, Dates),
131
- // which object keys would coerce to strings.
132
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
133
- const totals = new Map()
134
- for (const ds of datasets) {
135
- for (const d of ds) {
136
- const key = d[xf]
137
- totals.set(key, (totals.get(key) ?? 0) + (Number(d[yf]) || 0))
138
- }
139
- }
140
- const dir = this.#sort === 'asc' ? 1 : -1
141
- return [...totals.keys()].sort((a, b) => dir * ((totals.get(a) ?? 0) - (totals.get(b) ?? 0)))
71
+ /** The caller's channel map, unmerged. `channelState.effective` is the merged one. */
72
+ get channels() {
73
+ return this.config.channels
142
74
  }
143
-
144
- orientation = $derived.by(() => {
145
- if (this.#orientationOverride) return this.#orientationOverride
146
- const xField = this.#effectiveChannels.x
147
- const yField = this.#effectiveChannels.y
148
- if (!xField || !yField) return 'none'
149
- const rawXType = inferFieldType(this.#data, xField)
150
- const yType = inferFieldType(this.#data, yField)
151
- // Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
152
- return inferOrientation(this.#resolveXType(rawXType, yType), yType)
153
- })
154
-
155
- // True when the x-channel is the categorical (band) axis — a string category, or a numeric
156
- // category a bar/box/violin/jitter geom bands (incl. a race's rank via continuousCategory).
157
- // Only such charts flip; a plain scatter (both continuous, no banding geom) is false → the
158
- // orientation is a no-op for the scales.
159
- #bandIsX = $derived.by(() => {
160
- const x = this.#effectiveChannels.x
161
- const y = this.#effectiveChannels.y
162
- if (!x || !y) return false
163
- // Resolved type: a bar/box/violin/jitter geom bands a numeric x (e.g. year, week),
164
- // so those NUMERIC categories are flippable too — not just string categories.
165
- return this.#resolveXType(inferFieldType(this.#data, x), inferFieldType(this.#data, y)) === 'band'
166
- })
167
-
168
- // Flip = render a category-on-x chart horizontally: the category (x) axis stands up on the
169
- // vertical screen and the value (y) axis runs along the horizontal screen. x/y channels are
170
- // unchanged — only the screen mapping rotates.
171
- #flipped = $derived(this.orientation === 'horizontal' && this.#bandIsX)
172
-
173
- colorScaleType = $derived.by(() => {
174
- const field = this.#effectiveChannels.color
175
- if (!field) return 'categorical'
176
- return inferColorScaleType(this.#data, field, {
177
- colorScale: this.#colorSpec,
178
- colorMidpoint: this.#colorMidpoint
179
- })
180
- })
181
-
182
- // Continuous color scale (sequential or diverging) — null for categorical.
183
- continuousColorScale = $derived.by(() => {
184
- const field = this.#effectiveChannels.color
185
- if (!field || this.colorScaleType === 'categorical') return null
186
- const opts = {
187
- colorScheme: this.#colorScheme,
188
- colorDomain: this.#colorDomain,
189
- colorMidpoint: this.#colorMidpoint
190
- }
191
- if (this.colorScaleType === 'diverging') {
192
- return buildDivergingScale(this.#data, field, opts)
193
- }
194
- return buildSequentialScale(this.#data, field, opts)
195
- })
196
-
197
- xScale = $derived.by(() => {
198
- const field = this.#effectiveChannels.x
199
- if (!field) return null
200
- const datasets =
201
- this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
202
- // includeZero applies to the VALUE axis. When flipped, x is the category axis, not
203
- // the value axis, so it must NOT include zero.
204
- const includeZero = this.orientation === 'horizontal' && !this.#flipped
205
- // Force scaleBand when x is the categorical axis (incl. flipped, where numeric categories
206
- // must stay a band). Exception: a CONTINUOUS category axis (a bar race's tweened rank) is
207
- // kept LINEAR so fractional positions tween smoothly — buildBars positions it directly.
208
- const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
209
- const bandX =
210
- hasBandGeom &&
211
- !this.#continuousCategory &&
212
- (this.orientation !== 'horizontal' || this.#flipped)
213
- // Flip: the category (x) axis stands up on the vertical screen → range over height.
214
- const range = this.#flipped ? [this.#innerHeight, 0] : undefined
215
- // Sort the band domain by aggregated value when requested (bars by size); an explicit
216
- // xDomain override wins.
217
- const domain = this.#xDomain ?? (bandX ? this.#sortedBandDomain(datasets) : null) ?? undefined
218
- const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
219
- domain,
220
- includeZero,
221
- band: bandX,
222
- range,
223
- // A continuous category axis (bar-race rank) uses a deliberately padded domain —
224
- // don't nice() it back to whole numbers.
225
- nice: !this.#continuousCategory
226
- })
227
- return this.#zoomTransform && typeof base?.bandwidth !== 'function'
228
- ? this.#zoomTransform.rescaleX(base)
229
- : base
230
- })
231
-
232
- // For box/violin geoms, compute y domain from iqr_min/iqr_max instead of raw y values.
233
- #resolveBoxDomain() {
234
- const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
235
- if (!boxGeom) return null
236
- const boxData = this.geomData(boxGeom.id)
237
- const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
238
- const flatOutliers = boxData.flatMap((d) => d.outliers ?? []).filter(isValid)
239
- const mins = [...boxData.map((d) => d.iqr_min).filter(isValid), ...flatOutliers]
240
- const maxs = [...boxData.map((d) => d.iqr_max).filter(isValid), ...flatOutliers]
241
- return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
75
+ get mode() {
76
+ return this.config.mode
242
77
  }
243
-
244
- // For stacked bars, compute y domain from per-x column totals.
245
- #resolveStackDomain(field) {
246
- const stackGeom = this.#geoms.find(
247
- (g) => g.options?.stack || g.options?.position === 'stack' || g.options?.position === 'fill'
248
- )
249
- if (!stackGeom) return null
250
- // position='fill' normalizes each column to 100% → the value domain is [0,1].
251
- if (stackGeom.options?.position === 'fill') return [0, 1]
252
- const xField = this.#effectiveChannels.x
253
- const stackData = this.geomData(stackGeom.id)
254
- if (!xField || stackData.length === 0) return null
255
- // Mirror buildStackedBars/subBandFields: stack dimension is the first
256
- // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
257
- // would overcount when multiple rows share the same (x, stack) key.
258
- const colorField = isLiteralColor(this.#effectiveChannels.color)
259
- ? null
260
- : this.#effectiveChannels.color
261
- const fillField = isLiteralColor(this.#effectiveChannels.fill)
262
- ? null
263
- : this.#effectiveChannels.fill
264
- const patternField = this.#effectiveChannels.pattern
265
- // Mirror buildStackedBars/subBandFields (group=fill first, then color, then pattern).
266
- const stackField =
267
- [fillField, colorField, patternField].find((f) => f && f !== xField) ?? (fillField ?? colorField)
268
- // No grouping field → buildStackedBars falls back to buildBars (individual bars, no
269
- // actual stacking), so there's no stacked total to size to. Bail to the normal value
270
- // extent; otherwise every row collapses to the same cKey below and the lookup's set()
271
- // overwrites, shrinking the domain to the last row per x — and the bars overflow.
272
- if (!stackField) return null
273
- const lookup = new SvelteMap()
274
- for (const d of stackData) {
275
- const xVal = d[xField]
276
- const cKey = stackField ? String(d[stackField]) : '_'
277
- if (!lookup.has(xVal)) lookup.set(xVal, new SvelteMap())
278
- lookup.get(xVal).set(cKey, Number(d[field]) || 0)
279
- }
280
- const totals = new SvelteMap()
281
- for (const [xVal, colorMap] of lookup) {
282
- totals.set(
283
- xVal,
284
- [...colorMap.values()].reduce((s, v) => s + v, 0)
285
- )
286
- }
287
- return [0, Math.max(0, ...totals.values())]
78
+ get chartPreset() {
79
+ return this.config.chartPreset
288
80
  }
289
-
290
- // Waterfall bars sit at the RUNNING TOTAL, not the per-step value — so the y-domain must
291
- // span the cumulative range, else bars overflow the axis. Mirrors buildWaterfallMarks.
292
- #resolveWaterfallDomain(field) {
293
- const geom = this.#geoms.find((g) => g.type === 'waterfall')
294
- if (!geom) return null
295
- const data = this.geomData(geom.id)
296
- if (data.length === 0) return null
297
- const totalField = geom.options?.totalField
298
- let cumulative = 0
299
- let min = 0
300
- let max = 0
301
- for (const d of data) {
302
- if (totalField && d[totalField]) {
303
- min = Math.min(min, 0, cumulative)
304
- max = Math.max(max, 0, cumulative)
305
- } else {
306
- const start = cumulative
307
- cumulative += Number(d[field]) || 0
308
- min = Math.min(min, start, cumulative)
309
- max = Math.max(max, start, cumulative)
310
- }
311
- }
312
- return [min, max]
81
+ /** A continuous category axis (a bar race's tweened rank) rather than a band. */
82
+ get continuousCategory() {
83
+ return this.config.continuousCategory
313
84
  }
314
-
315
- yScale = $derived.by(() => {
316
- const field = this.#effectiveChannels.y
317
- if (!field) return null
318
- const datasets =
319
- this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
320
- // includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
321
- // charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
322
- const includeZero = this.orientation === 'vertical' || this.#flipped
323
- const yDomain =
324
- this.#yDomain ??
325
- this.#resolveBoxDomain() ??
326
- this.#resolveStackDomain(field) ??
327
- this.#resolveWaterfallDomain(field)
328
- // Flip: the value (y) axis runs along the horizontal screen → range over width.
329
- const range = this.#flipped ? [0, this.#innerWidth] : undefined
330
- const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
331
- return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
332
- })
333
-
334
- // Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
335
- // If the color channel is a CSS literal (e.g. '#4a90d9'), return a singleton map
336
- // keyed by null so all marks pick it up via the fallback path.
337
- // If a colorDomain is provided (e.g. from FacetPlot for cross-panel consistency),
338
- // use it instead of deriving distinct values from the local panel data.
339
- colors = $derived.by(() => {
340
- const field = this.#effectiveChannels.color
341
- if (isLiteralColor(field)) {
342
- /** @type {Map<unknown, { fill: string, stroke: string }>} */
343
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
344
- const literal = new Map([[null, { fill: field, stroke: field }]])
345
- return literal
346
- }
347
- const values = this.#colorDomain ?? this.#sharedColorValues()
348
- // No color channel but data exists → use first preset color for single-series rendering.
349
- // This prevents geoms from falling back to gray (#888) on charts with no fill channel.
350
- if (values.length === 0 && this.#data.length > 0) return assignColors([null], this.#mode, this.#chartPreset)
351
- return assignColors(values, this.#mode, this.#chartPreset)
352
- })
353
-
354
- // Patterns: Map<patternKey, patternName> — only populated when a pattern channel is set
355
- // and the pattern field is categorical (continuous fields can't be discretely patterned).
356
- patterns = $derived.by(() => {
357
- const pf = this.#effectiveChannels.pattern
358
- if (!pf) return new SvelteMap()
359
- if (inferFieldType(this.#data, pf) === 'continuous') return new SvelteMap()
360
- return assignPatterns(distinct(this.#data, pf))
361
- })
362
-
363
- // Symbols: Map<symbolKey, shapeName> — only populated when a symbol channel is set.
364
- symbols = $derived.by(() => {
365
- const sf = this.#effectiveChannels.symbol
366
- if (!sf) return new SvelteMap()
367
- return assignSymbols(distinct(this.#data, sf), this.#chartPreset)
368
- })
369
-
370
- // Expose effective channel fields for consumers (e.g. Legend).
371
- // Returns null for literal CSS colors since they don't map to a data field.
372
- colorField = $derived(
373
- isLiteralColor(this.#effectiveChannels.color) ? null : this.#effectiveChannels.color
374
- )
375
- // Fill field (interior aesthetic), parallel to colorField. Null for a literal CSS color.
376
- fillField = $derived(
377
- isLiteralColor(this.#effectiveChannels.fill) ? null : this.#effectiveChannels.fill
378
- )
379
- patternField = $derived(this.#effectiveChannels.pattern)
380
- symbolField = $derived(this.#effectiveChannels.symbol)
381
-
382
- // Set of geom types currently registered (used by Legend to pick swatch style)
383
- geomTypes = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
384
-
385
- xAxisY = $derived.by(() => {
386
- if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
387
- const crossVal = this.axisOrigin[1]
388
- if (crossVal !== undefined) return this.yScale(crossVal)
389
- const domain = this.yScale.domain?.()
390
- /* v8 ignore start -- unreachable: yScale is either null (caught by the guard
391
- above) or a d3 scale from buildUnifiedYScale, whose every return path is a
392
- scaleLinear or scaleBand — both always expose domain() returning an array.
393
- `ignore next` does not fire on a single-line `if (...) return`. */
394
- if (!domain) return this.#innerHeight
395
- /* v8 ignore stop */
396
- // Auto quadrant: place x-axis at y=0 when domain spans zero (no offset)
397
- if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.yScale(0)
398
- // Q1-only: axis at bottom edge, optionally with offset
399
- const base = this.yScale(domain[0])
400
- return this.#axisOffset ? base + this.#axisOffset : base
401
- })
402
-
403
- yAxisX = $derived.by(() => {
404
- if (!this.xScale || typeof this.xScale !== 'function') return 0
405
- const crossVal = this.axisOrigin[0]
406
- if (crossVal !== undefined) return this.xScale(crossVal)
407
- const domain = this.xScale.domain?.()
408
- if (!domain || typeof this.xScale.bandwidth === 'function') return 0
409
- // Auto quadrant: place y-axis at x=0 when domain spans zero (no offset)
410
- if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.xScale(0)
411
- // Q1-only: axis at left edge, optionally with offset
412
- const base = this.xScale(domain[0])
413
- return this.#axisOffset ? base - this.#axisOffset : base
414
- })
415
-
416
- constructor(config = {}) {
417
- this.#rawData = config.data ?? []
418
- this.#data = config.data ?? []
419
- this.#channels = config.channels ?? {}
420
- this.#labels = config.labels ?? {}
421
- this.#helpers = config.helpers ?? {}
422
- this.#presetName = config.preset
423
- this.#colorMidpoint = config.colorMidpoint
424
- this.#colorSpec = config.colorScale
425
- this.#colorScheme = config.colorScheme
426
- this.#colorDomain = config.colorDomain
427
- this.#xDomain = config.xDomain
428
- this.#yDomain = config.yDomain
429
- this.#width = config.width ?? 600
430
- this.#height = config.height ?? 400
431
- this.#mode = config.mode ?? 'light'
432
- this.#chartPreset = config.chartPreset ?? defaultPreset
433
- this.#onselect = config.onselect
434
- this.#selectable = config.selectable ?? false
435
- if (config.selected) this.#selected = new SvelteSet(config.selected)
436
- this.#axisOffset = config.axisOffset ?? 0
437
- this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
438
- this.#marginOverride = config.margin ?? undefined
439
- this.#orientationOverride = config.orientation ?? undefined
440
- this.#continuousCategory = config.continuousCategory ?? false
441
- this.#sort = config.sort ?? undefined
85
+ /** Pin the axis crossing directly — also reachable through `update({ axisOrigin })`. */
86
+ get axisOrigin() {
87
+ return this.config.axisOrigin
442
88
  }
443
-
444
- update(config) {
445
- if (config.data !== undefined) {
446
- this.#rawData = config.data
447
- this.#data = config.data
448
- }
449
- if (config.channels !== undefined) this.#channels = config.channels
450
- if (config.labels !== undefined) this.#labels = config.labels
451
- if (config.helpers !== undefined) this.#helpers = config.helpers
452
- if (config.preset !== undefined) this.#presetName = config.preset
453
- if (config.colorMidpoint !== undefined) this.#colorMidpoint = config.colorMidpoint
454
- if (config.colorScale !== undefined) this.#colorSpec = config.colorScale
455
- if (config.colorScheme !== undefined) this.#colorScheme = config.colorScheme
456
- this.#colorDomain = config.colorDomain
457
- this.#xDomain = config.xDomain
458
- this.#yDomain = config.yDomain
459
- if (config.width !== undefined) this.#width = config.width
460
- if (config.height !== undefined) this.#height = config.height
461
- if (config.mode !== undefined) this.#mode = config.mode
462
- if (config.chartPreset !== undefined) this.#chartPreset = config.chartPreset
463
- if (config.onselect !== undefined) this.#onselect = config.onselect
464
- if (config.selectable !== undefined) this.#selectable = config.selectable
465
- if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
466
- this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
467
- this.#marginOverride = config.margin ?? undefined
468
- this.#orientationOverride = config.orientation ?? undefined
469
- this.#continuousCategory = config.continuousCategory ?? false
470
- this.#sort = config.sort ?? undefined
89
+ set axisOrigin(value) {
90
+ this.config.axisOrigin = value
91
+ }
92
+ label(field) {
93
+ return this.config.label(field)
94
+ }
95
+ format(field) {
96
+ return this.config.format(field)
97
+ }
98
+ tooltip() {
99
+ return this.config.tooltip()
100
+ }
101
+ geomComponent(type) {
102
+ return this.config.geomComponent(type)
103
+ }
104
+ preset() {
105
+ return this.config.resolvedPreset()
471
106
  }
472
107
 
108
+ // ─── geoms ─────────────────────────────────────────────────────────────────
473
109
  registerGeom(config) {
474
- const id = `geom-${nextId++}`
475
- this.#geoms = [...this.#geoms, { id, ...config }]
476
- return id
110
+ return this.geoms.register(config)
477
111
  }
478
-
479
112
  updateGeom(id, config) {
480
- // untrack the read of #geoms to avoid effect_update_depth_exceeded when
481
- // called from a geom's $effect (which would otherwise track #geoms as a dependency)
482
- this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
113
+ this.geoms.update(id, config)
483
114
  }
484
-
485
115
  unregisterGeom(id) {
486
- this.#geoms = this.#geoms.filter((g) => g.id !== id)
116
+ this.geoms.unregister(id)
487
117
  }
488
-
489
118
  geomData(id) {
490
- const geom = this.#geoms.find((g) => g.id === id)
491
- if (!geom) return []
492
- const stat = geom.stat ?? 'identity'
493
- if (stat === 'identity') return this.#rawData
494
- // Strip explicit `undefined` values before spreading: a geom that omits a channel
495
- // to inherit it from the container sends `{ x: undefined }`, not `{}`, which would
496
- // otherwise become an own property that clobbers the container's value below (see
497
- // SparkState.geomData for the fuller writeup — same bug, same fix, both classes).
498
- const geomChannels = Object.fromEntries(
499
- Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
500
- )
501
- const mergedChannels = { ...this.#channels, ...geomChannels }
502
- return applyGeomStat(this.#rawData, { stat, channels: mergedChannels }, this.#helpers)
119
+ return this.geoms.data(id)
503
120
  }
504
-
505
- label(field) {
506
- return this.#labels?.[field] ?? field
121
+ /** The legend picks a swatch style from these. */
122
+ get geomTypes() {
123
+ return this.geoms.types
507
124
  }
508
125
 
509
- format(field) {
510
- return resolveFormat(field, this.#helpers)
511
- }
512
- tooltip() {
513
- return resolveTooltip(this.#helpers)
126
+ // ─── frame ─────────────────────────────────────────────────────────────────
127
+ get margin() {
128
+ return this.frame.margin
514
129
  }
515
- geomComponent(type) {
516
- return resolveGeom(type, this.#helpers)
130
+ get innerWidth() {
131
+ return this.frame.innerWidth
517
132
  }
518
- preset() {
519
- return resolvePreset(this.#presetName, this.#helpers)
133
+ get innerHeight() {
134
+ return this.frame.innerHeight
520
135
  }
521
136
 
522
- get data() {
523
- // Return #rawData (not #data): geoms read their rows via geomData() which
524
- // returns #rawData, so overlays and index lookups (plotState.data.indexOf(datum))
525
- // must use the SAME proxy identity. #data and #rawData proxy the same source
526
- // array separately, so their elements are not ===. Same content, aligned identity.
527
- return this.#rawData
137
+ // ─── channels ──────────────────────────────────────────────────────────────
138
+ /** Null for a literal CSS colour — it maps to no field. */
139
+ get colorField() {
140
+ return this.channelState.colorField
528
141
  }
529
- /** @returns {{ x?: string, y?: string, color?: string, fill?: string, pattern?: string, symbol?: string }} */
530
- get channels() {
531
- return this.#channels
142
+ get fillField() {
143
+ return this.channelState.fillField
144
+ }
145
+ get patternField() {
146
+ return this.channelState.patternField
147
+ }
148
+ get symbolField() {
149
+ return this.channelState.symbolField
532
150
  }
533
151
 
534
- // ─── Orientation helpers (horizontal / axis-flip) ──────────────────────────
535
- // True when the chart is rendered horizontally (category axis stood up on the
536
- // vertical screen, value axis along the horizontal screen). x/y channels unchanged.
537
- get isFlipped() {
538
- return this.#flipped
152
+ // ─── orientation ───────────────────────────────────────────────────────────
153
+ get orientation() {
154
+ return this.orientationState.orientation
539
155
  }
540
- // True when the category (x) axis is a continuous position scale (kept linear) rather than a
541
- // band — a bar-chart race's tweened rank. buildBars uses continuous positioning for it.
542
- get continuousCategory() {
543
- return this.#continuousCategory
156
+ /** Rendered horizontally: the band axis stands up the screen. Channels are unchanged. */
157
+ get isFlipped() {
158
+ return this.orientationState.flipped
544
159
  }
545
- // Map abstract (x-channel, y-channel) scale outputs to screen coords. When flipped,
546
- // the two screen axes swap. Geoms compute u = xScale(d[x]), v = yScale(d[y]) then
547
- // `const { x, y } = plotState.place(u, v)`.
160
+ /** Channel space to screen: `const { x, y } = plotState.place(xScale(d[x]), yScale(d[y]))`. */
548
161
  place(u, v) {
549
- return this.#flipped ? { x: v, y: u } : { x: u, y: v }
162
+ return this.orientationState.place(u, v)
550
163
  }
551
- // The categorical (band) scale and the continuous (value) scale, regardless of
552
- // orientation — their ranges are already oriented to the correct screen axis.
164
+
165
+ // ─── scales ────────────────────────────────────────────────────────────────
166
+ get xScale() {
167
+ return this.scales.x
168
+ }
169
+ get yScale() {
170
+ return this.scales.y
171
+ }
172
+ /** The categorical scale, whichever screen axis it is on. */
553
173
  get bandScale() {
554
- return this.#bandIsX ? this.xScale : this.yScale
174
+ return this.scales.band
555
175
  }
176
+ /** The value scale, whichever screen axis it is on. */
556
177
  get valueScale() {
557
- return this.#bandIsX ? this.yScale : this.xScale
178
+ return this.scales.value
558
179
  }
559
- get margin() {
560
- return this.#effectiveMargin
180
+
181
+ // ─── aesthetics ────────────────────────────────────────────────────────────
182
+ get colors() {
183
+ return this.aesthetics.colors
561
184
  }
562
- get innerWidth() {
563
- return this.#innerWidth
185
+ get patterns() {
186
+ return this.aesthetics.patterns
564
187
  }
565
- get innerHeight() {
566
- return this.#innerHeight
188
+ get symbols() {
189
+ return this.aesthetics.symbols
567
190
  }
568
- get mode() {
569
- return this.#mode
191
+ get colorScaleType() {
192
+ return this.aesthetics.colorScaleType
570
193
  }
571
- get chartPreset() {
572
- return this.#chartPreset
194
+ get continuousColorScale() {
195
+ return this.aesthetics.continuousColorScale
196
+ }
197
+
198
+ // ─── axes ──────────────────────────────────────────────────────────────────
199
+ get xAxisY() {
200
+ return this.axes.xAxisY
201
+ }
202
+ get yAxisX() {
203
+ return this.axes.yAxisX
573
204
  }
205
+
206
+ // ─── interaction ───────────────────────────────────────────────────────────
574
207
  get hovered() {
575
- return this.#hovered
208
+ return this.interactionState.hovered
576
209
  }
577
210
  get interactive() {
578
- return Boolean(this.#onselect) || this.#selectable
211
+ return this.interactionState.interactive
579
212
  }
580
213
  get selectedRows() {
581
- return [...this.#selected]
214
+ return this.interactionState.selectedRows
582
215
  }
583
-
584
- setHovered(data) {
585
- this.#hovered = data
216
+ setHovered(row) {
217
+ this.interactionState.setHovered(row)
586
218
  }
587
219
  clearHovered() {
588
- this.#hovered = null
220
+ this.interactionState.clearHovered()
589
221
  }
590
222
  isSelected(row) {
591
- return this.#selected.has(row)
223
+ return this.interactionState.isSelected(row)
592
224
  }
593
225
  setSelected(rows) {
594
- this.#selected = new SvelteSet(rows ?? [])
226
+ this.interactionState.setSelected(rows)
595
227
  }
596
228
  clearSelected() {
597
- this.#selected = new SvelteSet()
229
+ this.interactionState.clearSelected()
598
230
  }
599
231
  handleSelect(detail) {
600
- this.#onselect?.(detail)
601
- if (this.#selectable && detail?.datum !== undefined) {
602
- if (this.#selected.has(detail.datum)) this.#selected.delete(detail.datum)
603
- else this.#selected.add(detail.datum)
604
- }
232
+ this.interactionState.handleSelect(detail)
605
233
  }
606
-
607
234
  applyZoom(transform) {
608
- this.#zoomTransform = transform
235
+ this.interactionState.applyZoom(transform)
609
236
  }
610
237
  resetZoom() {
611
- this.#zoomTransform = null
238
+ this.interactionState.resetZoom()
612
239
  }
613
240
  }