@rokkit/chart 1.0.0-next.150 → 1.0.0-next.155

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 (90) hide show
  1. package/dist/PlotState.svelte.d.ts +31 -3
  2. package/dist/crossfilter/createCrossFilter.svelte.d.ts +13 -15
  3. package/dist/index.d.ts +6 -1
  4. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +3 -5
  5. package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +9 -0
  6. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +3 -4
  7. package/dist/lib/brewing/brewer.svelte.d.ts +5 -36
  8. package/dist/lib/brewing/colors.d.ts +10 -1
  9. package/dist/lib/brewing/marks/points.d.ts +17 -2
  10. package/dist/lib/brewing/stats.d.ts +5 -13
  11. package/dist/lib/chart.d.ts +5 -7
  12. package/dist/lib/keyboard-nav.d.ts +15 -0
  13. package/dist/lib/plot/preset.d.ts +1 -1
  14. package/dist/lib/preset.d.ts +30 -0
  15. package/package.json +2 -1
  16. package/src/AnimatedPlot.svelte +375 -206
  17. package/src/Chart.svelte +81 -87
  18. package/src/ChartProvider.svelte +10 -0
  19. package/src/FacetPlot/Panel.svelte +30 -16
  20. package/src/FacetPlot.svelte +100 -76
  21. package/src/Plot/Area.svelte +26 -19
  22. package/src/Plot/Axis.svelte +81 -59
  23. package/src/Plot/Bar.svelte +47 -89
  24. package/src/Plot/Grid.svelte +23 -19
  25. package/src/Plot/Legend.svelte +213 -147
  26. package/src/Plot/Line.svelte +31 -21
  27. package/src/Plot/Point.svelte +35 -22
  28. package/src/Plot/Root.svelte +46 -91
  29. package/src/Plot/Timeline.svelte +82 -82
  30. package/src/Plot/Tooltip.svelte +68 -62
  31. package/src/Plot.svelte +290 -182
  32. package/src/PlotState.svelte.js +339 -267
  33. package/src/Sparkline.svelte +95 -56
  34. package/src/charts/AreaChart.svelte +22 -20
  35. package/src/charts/BarChart.svelte +23 -21
  36. package/src/charts/BoxPlot.svelte +15 -15
  37. package/src/charts/BubbleChart.svelte +17 -17
  38. package/src/charts/LineChart.svelte +20 -20
  39. package/src/charts/PieChart.svelte +30 -20
  40. package/src/charts/ScatterPlot.svelte +20 -19
  41. package/src/charts/ViolinPlot.svelte +15 -15
  42. package/src/crossfilter/CrossFilter.svelte +33 -29
  43. package/src/crossfilter/FilterBar.svelte +17 -25
  44. package/src/crossfilter/FilterHistogram.svelte +290 -0
  45. package/src/crossfilter/FilterSlider.svelte +69 -65
  46. package/src/crossfilter/createCrossFilter.svelte.js +100 -89
  47. package/src/geoms/Arc.svelte +114 -69
  48. package/src/geoms/Area.svelte +67 -39
  49. package/src/geoms/Bar.svelte +184 -126
  50. package/src/geoms/Box.svelte +102 -90
  51. package/src/geoms/LabelPill.svelte +11 -11
  52. package/src/geoms/Line.svelte +110 -87
  53. package/src/geoms/Point.svelte +132 -87
  54. package/src/geoms/Violin.svelte +45 -33
  55. package/src/geoms/lib/areas.js +122 -99
  56. package/src/geoms/lib/bars.js +195 -144
  57. package/src/index.js +21 -14
  58. package/src/lib/brewing/BoxBrewer.svelte.js +8 -50
  59. package/src/lib/brewing/CartesianBrewer.svelte.js +12 -7
  60. package/src/lib/brewing/PieBrewer.svelte.js +5 -5
  61. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  62. package/src/lib/brewing/ViolinBrewer.svelte.js +8 -49
  63. package/src/lib/brewing/brewer.svelte.js +249 -201
  64. package/src/lib/brewing/colors.js +34 -5
  65. package/src/lib/brewing/marks/arcs.js +28 -28
  66. package/src/lib/brewing/marks/areas.js +54 -41
  67. package/src/lib/brewing/marks/bars.js +34 -34
  68. package/src/lib/brewing/marks/boxes.js +51 -51
  69. package/src/lib/brewing/marks/lines.js +37 -30
  70. package/src/lib/brewing/marks/points.js +74 -26
  71. package/src/lib/brewing/marks/violins.js +57 -57
  72. package/src/lib/brewing/patterns.js +25 -11
  73. package/src/lib/brewing/scales.js +20 -20
  74. package/src/lib/brewing/stats.js +40 -28
  75. package/src/lib/brewing/symbols.js +1 -1
  76. package/src/lib/chart.js +12 -4
  77. package/src/lib/keyboard-nav.js +37 -0
  78. package/src/lib/plot/crossfilter.js +5 -5
  79. package/src/lib/plot/facet.js +30 -30
  80. package/src/lib/plot/frames.js +30 -29
  81. package/src/lib/plot/helpers.js +4 -4
  82. package/src/lib/plot/preset.js +48 -34
  83. package/src/lib/plot/scales.js +64 -39
  84. package/src/lib/plot/stat.js +47 -47
  85. package/src/lib/preset.js +41 -0
  86. package/src/patterns/DefinePatterns.svelte +24 -24
  87. package/src/patterns/PatternDef.svelte +1 -1
  88. package/src/patterns/patterns.js +328 -176
  89. package/src/patterns/scale.js +61 -32
  90. package/src/spec/chart-spec.js +64 -21
@@ -1,278 +1,350 @@
1
1
  import { untrack } from 'svelte'
2
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
3
  import { applyGeomStat } from './lib/plot/stat.js'
3
- import { inferFieldType, inferOrientation, buildUnifiedXScale, buildUnifiedYScale, inferColorScaleType } from './lib/plot/scales.js'
4
+ import {
5
+ inferFieldType,
6
+ inferOrientation,
7
+ buildUnifiedXScale,
8
+ buildUnifiedYScale,
9
+ inferColorScaleType
10
+ } from './lib/plot/scales.js'
4
11
  import { resolvePreset } from './lib/plot/preset.js'
5
12
  import { resolveFormat, resolveTooltip, resolveGeom } from './lib/plot/helpers.js'
6
- import { distinct, assignColors } from './lib/brewing/colors.js'
13
+ import { defaultPreset } from './lib/preset.js'
14
+ import { distinct, assignColors, isLiteralColor } from './lib/brewing/colors.js'
7
15
  import { assignPatterns } from './lib/brewing/patterns.js'
8
16
  import { assignSymbols } from './lib/brewing/marks/points.js'
9
17
 
10
18
  let nextId = 0
11
19
 
12
20
  export class PlotState {
13
- #data = $state([])
14
- #rawData = []
15
- #channels = $state({})
16
- #labels = $state({})
17
- #helpers = $state({})
18
- #presetName = $state(undefined)
19
- #colorMidpoint = $state(undefined)
20
- #colorSpec = $state(undefined)
21
- #colorDomain = $state(undefined)
22
- #xDomain = $state(undefined)
23
- #yDomain = $state(undefined)
24
- #width = $state(600)
25
- #height = $state(400)
26
- #margin = $state({ top: 20, right: 20, bottom: 40, left: 50 })
27
- #marginOverride = $state(undefined)
28
-
29
- #geoms = $state([])
30
- #mode = $state('light')
31
- #hovered = $state(null)
32
-
33
- axisOrigin = $state([undefined, undefined])
34
-
35
- #effectiveMargin = $derived(this.#marginOverride ?? this.#margin)
36
- #innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
37
- #innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
38
-
39
- // Effective channels: prefer top-level channels; fall back to first geom's channels
40
- // for the declarative API where no spec is provided.
41
- #effectiveChannels = $derived.by(() => {
42
- const tc = this.#channels
43
- if (tc.x && tc.y) return tc
44
- const firstGeom = this.#geoms[0]
45
- if (!firstGeom) return tc
46
- return {
47
- x: tc.x ?? firstGeom.channels?.x,
48
- y: tc.y ?? firstGeom.channels?.y,
49
- color: tc.color ?? firstGeom.channels?.color,
50
- pattern: tc.pattern ?? firstGeom.channels?.pattern,
51
- symbol: tc.symbol ?? firstGeom.channels?.symbol,
52
- }
53
- })
54
-
55
- orientation = $derived.by(() => {
56
- const xField = this.#effectiveChannels.x
57
- const yField = this.#effectiveChannels.y
58
- if (!xField || !yField) return 'none'
59
- const rawXType = inferFieldType(this.#data, xField)
60
- const yType = inferFieldType(this.#data, yField)
61
- // Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
62
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
63
- const xType = (hasBarGeom && rawXType === 'continuous' && yType === 'continuous') ? 'band' : rawXType
64
- return inferOrientation(xType, yType)
65
- })
66
-
67
- colorScaleType = $derived.by(() => {
68
- const field = this.#effectiveChannels.color
69
- if (!field) return 'categorical'
70
- return inferColorScaleType(this.#data, field, {
71
- colorScale: this.#colorSpec,
72
- colorMidpoint: this.#colorMidpoint
73
- })
74
- })
75
-
76
- xScale = $derived.by(() => {
77
- const field = this.#effectiveChannels.x
78
- if (!field) return null
79
- const datasets = this.#geoms.length > 0
80
- ? this.#geoms.map((g) => this.geomData(g.id))
81
- : [this.#rawData]
82
- const includeZero = this.orientation === 'horizontal'
83
- // For vertical bar charts, force scaleBand even when X values are numeric (e.g. year).
84
- // Horizontal bar charts keep X as a continuous value axis.
85
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
86
- const bandX = hasBarGeom && this.orientation !== 'horizontal'
87
- return buildUnifiedXScale(datasets, field, this.#innerWidth, {
88
- domain: this.#xDomain,
89
- includeZero,
90
- band: bandX
91
- })
92
- })
93
-
94
- yScale = $derived.by(() => {
95
- const field = this.#effectiveChannels.y
96
- if (!field) return null
97
- const datasets = this.#geoms.length > 0
98
- ? this.#geoms.map((g) => this.geomData(g.id))
99
- : [this.#rawData]
100
- const includeZero = this.orientation === 'vertical'
101
-
102
- // For stacked bars, the y domain must cover the per-x column *total*, not the
103
- // per-row max otherwise bars overflow the plot area.
104
- // For box/violin geoms, the processed data has iqr_min/iqr_max instead of raw y values.
105
- let yDomain = this.#yDomain
106
- if (!yDomain) {
107
- const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
108
- if (boxGeom) {
109
- const boxData = this.geomData(boxGeom.id)
110
- const mins = boxData.map((d) => d.iqr_min).filter((v) => v !== null && v !== undefined && !isNaN(v))
111
- const maxs = boxData.map((d) => d.iqr_max).filter((v) => v !== null && v !== undefined && !isNaN(v))
112
- if (mins.length > 0 && maxs.length > 0) {
113
- yDomain = [Math.min(...mins), Math.max(...maxs)]
114
- }
115
- }
116
- }
117
- if (!yDomain) {
118
- const stackGeom = this.#geoms.find((g) => g.options?.stack)
119
- if (stackGeom) {
120
- const xField = this.#effectiveChannels.x
121
- const stackData = this.geomData(stackGeom.id)
122
- if (xField && stackData.length > 0) {
123
- // Mirror buildStackedBars/subBandFields: stack dimension is the first
124
- // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
125
- // would overcount when multiple rows share the same (x, stack) key.
126
- const colorField = this.#effectiveChannels.color
127
- const patternField = this.#effectiveChannels.pattern
128
- const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
129
- const lookup = new Map()
130
- for (const d of stackData) {
131
- const xVal = d[xField]
132
- const cKey = stackField ? String(d[stackField]) : '_'
133
- if (!lookup.has(xVal)) lookup.set(xVal, new Map())
134
- lookup.get(xVal).set(cKey, Number(d[field]) || 0)
135
- }
136
- const totals = new Map()
137
- for (const [xVal, colorMap] of lookup) {
138
- totals.set(xVal, [...colorMap.values()].reduce((s, v) => s + v, 0))
139
- }
140
- const maxStack = Math.max(0, ...totals.values())
141
- yDomain = [0, maxStack]
142
- }
143
- }
144
- }
145
-
146
- return buildUnifiedYScale(datasets, field, this.#innerHeight, {
147
- domain: yDomain,
148
- includeZero
149
- })
150
- })
151
-
152
- // Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
153
- // If a colorDomain is provided (e.g. from FacetPlot for cross-panel consistency),
154
- // use it instead of deriving distinct values from the local panel data.
155
- colors = $derived.by(() => {
156
- const field = this.#effectiveChannels.color
157
- const values = this.#colorDomain ?? distinct(this.#data, field)
158
- return assignColors(values, this.#mode)
159
- })
160
-
161
- // Patterns: Map<patternKey, patternName> — only populated when a pattern channel is set
162
- // and the pattern field is categorical (continuous fields can't be discretely patterned).
163
- patterns = $derived.by(() => {
164
- const pf = this.#effectiveChannels.pattern
165
- if (!pf) return new Map()
166
- if (inferFieldType(this.#data, pf) === 'continuous') return new Map()
167
- return assignPatterns(distinct(this.#data, pf))
168
- })
169
-
170
- // Symbols: Map<symbolKey, shapeName> only populated when a symbol channel is set.
171
- symbols = $derived.by(() => {
172
- const sf = this.#effectiveChannels.symbol
173
- if (!sf) return new Map()
174
- return assignSymbols(distinct(this.#data, sf))
175
- })
176
-
177
- // Expose effective channel fields for consumers (e.g. Legend)
178
- colorField = $derived(this.#effectiveChannels.color)
179
- patternField = $derived(this.#effectiveChannels.pattern)
180
- symbolField = $derived(this.#effectiveChannels.symbol)
181
-
182
- // Set of geom types currently registered (used by Legend to pick swatch style)
183
- geomTypes = $derived(new Set(this.#geoms.map((g) => g.type)))
184
-
185
- xAxisY = $derived.by(() => {
186
- if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
187
- const crossVal = this.axisOrigin[1]
188
- if (crossVal !== undefined) return this.yScale(crossVal)
189
- const domain = this.yScale.domain?.()
190
- return domain ? this.yScale(domain[0]) : this.#innerHeight
191
- })
192
-
193
- yAxisX = $derived.by(() => {
194
- if (!this.xScale || typeof this.xScale !== 'function') return 0
195
- const crossVal = this.axisOrigin[0]
196
- if (crossVal !== undefined) return this.xScale(crossVal)
197
- const domain = this.xScale.domain?.()
198
- return domain ? this.xScale(domain[0]) : 0
199
- })
200
-
201
- constructor(config = {}) {
202
- this.#rawData = config.data ?? []
203
- this.#data = config.data ?? []
204
- this.#channels = config.channels ?? {}
205
- this.#labels = config.labels ?? {}
206
- this.#helpers = config.helpers ?? {}
207
- this.#presetName = config.preset
208
- this.#colorMidpoint = config.colorMidpoint
209
- this.#colorSpec = config.colorScale
210
- this.#colorDomain = config.colorDomain
211
- this.#xDomain = config.xDomain
212
- this.#yDomain = config.yDomain
213
- this.#width = config.width ?? 600
214
- this.#height = config.height ?? 400
215
- this.#mode = config.mode ?? 'light'
216
- this.#marginOverride = config.margin ?? undefined
217
- }
218
-
219
- update(config) {
220
- if (config.data !== undefined) { this.#rawData = config.data; this.#data = config.data }
221
- if (config.channels !== undefined) this.#channels = config.channels
222
- if (config.labels !== undefined) this.#labels = config.labels
223
- if (config.helpers !== undefined) this.#helpers = config.helpers
224
- if (config.preset !== undefined) this.#presetName = config.preset
225
- if (config.colorMidpoint !== undefined) this.#colorMidpoint = config.colorMidpoint
226
- if (config.colorScale !== undefined) this.#colorSpec = config.colorScale
227
- this.#colorDomain = config.colorDomain
228
- this.#xDomain = config.xDomain
229
- this.#yDomain = config.yDomain
230
- if (config.width !== undefined) this.#width = config.width
231
- if (config.height !== undefined) this.#height = config.height
232
- if (config.mode !== undefined) this.#mode = config.mode
233
- this.#marginOverride = config.margin ?? undefined
234
- }
235
-
236
- registerGeom(config) {
237
- const id = `geom-${nextId++}`
238
- this.#geoms = [...this.#geoms, { id, ...config }]
239
- return id
240
- }
241
-
242
- updateGeom(id, config) {
243
- // untrack the read of #geoms to avoid effect_update_depth_exceeded when
244
- // called from a geom's $effect (which would otherwise track #geoms as a dependency)
245
- this.#geoms = untrack(() => this.#geoms).map((g) => g.id === id ? { ...g, ...config } : g)
246
- }
247
-
248
- unregisterGeom(id) {
249
- this.#geoms = this.#geoms.filter((g) => g.id !== id)
250
- }
251
-
252
- geomData(id) {
253
- const geom = this.#geoms.find((g) => g.id === id)
254
- if (!geom) return []
255
- const stat = geom.stat ?? 'identity'
256
- if (stat === 'identity') return this.#rawData
257
- const mergedChannels = { ...this.#channels, ...geom.channels }
258
- return applyGeomStat(this.#rawData, { stat, channels: mergedChannels }, this.#helpers)
259
- }
260
-
261
- label(field) {
262
- return this.#labels?.[field] ?? field
263
- }
264
-
265
- format(field) { return resolveFormat(field, this.#helpers) }
266
- tooltip() { return resolveTooltip(this.#helpers) }
267
- geomComponent(type) { return resolveGeom(type, this.#helpers) }
268
- preset() { return resolvePreset(this.#presetName, this.#helpers) }
269
-
270
- get margin() { return this.#effectiveMargin }
271
- get innerWidth() { return this.#innerWidth }
272
- get innerHeight() { return this.#innerHeight }
273
- get mode() { return this.#mode }
274
- get hovered() { return this.#hovered }
275
-
276
- setHovered(data) { this.#hovered = data }
277
- clearHovered() { this.#hovered = null }
21
+ #data = $state([])
22
+ #rawData = $state([])
23
+ #channels = $state({})
24
+ #labels = $state({})
25
+ #helpers = $state({})
26
+ #presetName = $state(undefined)
27
+ #colorMidpoint = $state(undefined)
28
+ #colorSpec = $state(undefined)
29
+ #colorDomain = $state(undefined)
30
+ #xDomain = $state(undefined)
31
+ #yDomain = $state(undefined)
32
+ #width = $state(600)
33
+ #height = $state(400)
34
+ #margin = $state({ top: 20, right: 20, bottom: 40, left: 50 })
35
+ #marginOverride = $state(undefined)
36
+
37
+ #geoms = $state([])
38
+ #mode = $state('light')
39
+ #chartPreset = $state(defaultPreset)
40
+ #hovered = $state(null)
41
+ #orientationOverride = $state(undefined)
42
+
43
+ axisOrigin = $state([undefined, undefined])
44
+
45
+ #zoomTransform = $state(null)
46
+
47
+ #effectiveMargin = $derived(this.#marginOverride ?? this.#margin)
48
+ #innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
49
+ #innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
50
+
51
+ // Effective channels: prefer top-level channels; fall back to first geom's channels
52
+ // for the declarative API where no spec is provided.
53
+ #mergeGeomChannels(tc, geom) {
54
+ return {
55
+ x: tc.x ?? geom.channels?.x,
56
+ y: tc.y ?? geom.channels?.y,
57
+ color: tc.color ?? geom.channels?.color,
58
+ pattern: tc.pattern ?? geom.channels?.pattern,
59
+ symbol: tc.symbol ?? geom.channels?.symbol
60
+ }
61
+ }
62
+
63
+ #effectiveChannels = $derived.by(() => {
64
+ const tc = this.#channels
65
+ if (tc.x && tc.y) return tc
66
+ const firstGeom = this.#geoms[0]
67
+ if (!firstGeom) return tc
68
+ return this.#mergeGeomChannels(tc, firstGeom)
69
+ })
70
+
71
+ #resolveXType(rawXType, yType) {
72
+ const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
73
+ return hasBarGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
74
+ }
75
+
76
+ orientation = $derived.by(() => {
77
+ if (this.#orientationOverride) return this.#orientationOverride
78
+ const xField = this.#effectiveChannels.x
79
+ const yField = this.#effectiveChannels.y
80
+ if (!xField || !yField) return 'none'
81
+ const rawXType = inferFieldType(this.#data, xField)
82
+ const yType = inferFieldType(this.#data, yField)
83
+ // Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
84
+ return inferOrientation(this.#resolveXType(rawXType, yType), yType)
85
+ })
86
+
87
+ colorScaleType = $derived.by(() => {
88
+ const field = this.#effectiveChannels.color
89
+ if (!field) return 'categorical'
90
+ return inferColorScaleType(this.#data, field, {
91
+ colorScale: this.#colorSpec,
92
+ colorMidpoint: this.#colorMidpoint
93
+ })
94
+ })
95
+
96
+ xScale = $derived.by(() => {
97
+ const field = this.#effectiveChannels.x
98
+ if (!field) return null
99
+ const datasets =
100
+ this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
101
+ const includeZero = this.orientation === 'horizontal'
102
+ // For vertical bar charts, force scaleBand even when X values are numeric (e.g. year).
103
+ // Horizontal bar charts keep X as a continuous value axis.
104
+ const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
105
+ const bandX = hasBarGeom && this.orientation !== 'horizontal'
106
+ const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
107
+ domain: this.#xDomain,
108
+ includeZero,
109
+ band: bandX
110
+ })
111
+ return this.#zoomTransform && typeof base?.bandwidth !== 'function'
112
+ ? this.#zoomTransform.rescaleX(base)
113
+ : base
114
+ })
115
+
116
+ // For box/violin geoms, compute y domain from iqr_min/iqr_max instead of raw y values.
117
+ #resolveBoxDomain() {
118
+ const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
119
+ if (!boxGeom) return null
120
+ const boxData = this.geomData(boxGeom.id)
121
+ const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
122
+ const mins = boxData.map((d) => d.iqr_min).filter(isValid)
123
+ const maxs = boxData.map((d) => d.iqr_max).filter(isValid)
124
+ return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
125
+ }
126
+
127
+ // For stacked bars, compute y domain from per-x column totals.
128
+ #resolveStackDomain(field) {
129
+ const stackGeom = this.#geoms.find((g) => g.options?.stack)
130
+ if (!stackGeom) return null
131
+ const xField = this.#effectiveChannels.x
132
+ const stackData = this.geomData(stackGeom.id)
133
+ if (!xField || stackData.length === 0) return null
134
+ // Mirror buildStackedBars/subBandFields: stack dimension is the first
135
+ // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
136
+ // would overcount when multiple rows share the same (x, stack) key.
137
+ const colorField = isLiteralColor(this.#effectiveChannels.color)
138
+ ? null
139
+ : this.#effectiveChannels.color
140
+ const patternField = this.#effectiveChannels.pattern
141
+ const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
142
+ const lookup = new SvelteMap()
143
+ for (const d of stackData) {
144
+ const xVal = d[xField]
145
+ const cKey = stackField ? String(d[stackField]) : '_'
146
+ if (!lookup.has(xVal)) lookup.set(xVal, new SvelteMap())
147
+ lookup.get(xVal).set(cKey, Number(d[field]) || 0)
148
+ }
149
+ const totals = new SvelteMap()
150
+ for (const [xVal, colorMap] of lookup) {
151
+ totals.set(
152
+ xVal,
153
+ [...colorMap.values()].reduce((s, v) => s + v, 0)
154
+ )
155
+ }
156
+ return [0, Math.max(0, ...totals.values())]
157
+ }
158
+
159
+ yScale = $derived.by(() => {
160
+ const field = this.#effectiveChannels.y
161
+ if (!field) return null
162
+ const datasets =
163
+ this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
164
+ const includeZero = this.orientation === 'vertical'
165
+ const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
166
+ const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
167
+ return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
168
+ })
169
+
170
+ // Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
171
+ // If the color channel is a CSS literal (e.g. '#4a90d9'), return a singleton map
172
+ // keyed by null so all marks pick it up via the fallback path.
173
+ // If a colorDomain is provided (e.g. from FacetPlot for cross-panel consistency),
174
+ // use it instead of deriving distinct values from the local panel data.
175
+ colors = $derived.by(() => {
176
+ const field = this.#effectiveChannels.color
177
+ if (isLiteralColor(field)) return new Map([[null, { fill: field, stroke: field }]])
178
+ const values = this.#colorDomain ?? distinct(this.#data, field)
179
+ // No color channel but data exists → use first preset color for single-series rendering.
180
+ // This prevents geoms from falling back to gray (#888) on charts with no fill channel.
181
+ if (values.length === 0 && this.#data.length > 0) return assignColors([null], this.#mode, this.#chartPreset)
182
+ return assignColors(values, this.#mode, this.#chartPreset)
183
+ })
184
+
185
+ // Patterns: Map<patternKey, patternName> only populated when a pattern channel is set
186
+ // and the pattern field is categorical (continuous fields can't be discretely patterned).
187
+ patterns = $derived.by(() => {
188
+ const pf = this.#effectiveChannels.pattern
189
+ if (!pf) return new SvelteMap()
190
+ if (inferFieldType(this.#data, pf) === 'continuous') return new SvelteMap()
191
+ return assignPatterns(distinct(this.#data, pf))
192
+ })
193
+
194
+ // Symbols: Map<symbolKey, shapeName> only populated when a symbol channel is set.
195
+ symbols = $derived.by(() => {
196
+ const sf = this.#effectiveChannels.symbol
197
+ if (!sf) return new SvelteMap()
198
+ return assignSymbols(distinct(this.#data, sf), this.#chartPreset)
199
+ })
200
+
201
+ // Expose effective channel fields for consumers (e.g. Legend).
202
+ // Returns null for literal CSS colors since they don't map to a data field.
203
+ colorField = $derived(
204
+ isLiteralColor(this.#effectiveChannels.color) ? null : this.#effectiveChannels.color
205
+ )
206
+ patternField = $derived(this.#effectiveChannels.pattern)
207
+ symbolField = $derived(this.#effectiveChannels.symbol)
208
+
209
+ // Set of geom types currently registered (used by Legend to pick swatch style)
210
+ geomTypes = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
211
+
212
+ xAxisY = $derived.by(() => {
213
+ if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
214
+ const crossVal = this.axisOrigin[1]
215
+ if (crossVal !== undefined) return this.yScale(crossVal)
216
+ const domain = this.yScale.domain?.()
217
+ if (!domain) return this.#innerHeight
218
+ // Auto quadrant: place x-axis at y=0 when domain spans zero
219
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.yScale(0)
220
+ return this.yScale(domain[0])
221
+ })
222
+
223
+ yAxisX = $derived.by(() => {
224
+ if (!this.xScale || typeof this.xScale !== 'function') return 0
225
+ const crossVal = this.axisOrigin[0]
226
+ if (crossVal !== undefined) return this.xScale(crossVal)
227
+ const domain = this.xScale.domain?.()
228
+ if (!domain || typeof this.xScale.bandwidth === 'function') return 0
229
+ // Auto quadrant: place y-axis at x=0 when domain spans zero
230
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.xScale(0)
231
+ return this.xScale(domain[0])
232
+ })
233
+
234
+ constructor(config = {}) {
235
+ this.#rawData = config.data ?? []
236
+ this.#data = config.data ?? []
237
+ this.#channels = config.channels ?? {}
238
+ this.#labels = config.labels ?? {}
239
+ this.#helpers = config.helpers ?? {}
240
+ this.#presetName = config.preset
241
+ this.#colorMidpoint = config.colorMidpoint
242
+ this.#colorSpec = config.colorScale
243
+ this.#colorDomain = config.colorDomain
244
+ this.#xDomain = config.xDomain
245
+ this.#yDomain = config.yDomain
246
+ this.#width = config.width ?? 600
247
+ this.#height = config.height ?? 400
248
+ this.#mode = config.mode ?? 'light'
249
+ this.#chartPreset = config.chartPreset ?? defaultPreset
250
+ this.#marginOverride = config.margin ?? undefined
251
+ this.#orientationOverride = config.orientation ?? undefined
252
+ }
253
+
254
+ update(config) {
255
+ if (config.data !== undefined) {
256
+ this.#rawData = config.data
257
+ this.#data = config.data
258
+ }
259
+ if (config.channels !== undefined) this.#channels = config.channels
260
+ if (config.labels !== undefined) this.#labels = config.labels
261
+ if (config.helpers !== undefined) this.#helpers = config.helpers
262
+ if (config.preset !== undefined) this.#presetName = config.preset
263
+ if (config.colorMidpoint !== undefined) this.#colorMidpoint = config.colorMidpoint
264
+ if (config.colorScale !== undefined) this.#colorSpec = config.colorScale
265
+ this.#colorDomain = config.colorDomain
266
+ this.#xDomain = config.xDomain
267
+ this.#yDomain = config.yDomain
268
+ if (config.width !== undefined) this.#width = config.width
269
+ if (config.height !== undefined) this.#height = config.height
270
+ if (config.mode !== undefined) this.#mode = config.mode
271
+ if (config.chartPreset !== undefined) this.#chartPreset = config.chartPreset
272
+ this.#marginOverride = config.margin ?? undefined
273
+ this.#orientationOverride = config.orientation ?? undefined
274
+ }
275
+
276
+ registerGeom(config) {
277
+ const id = `geom-${nextId++}`
278
+ this.#geoms = [...this.#geoms, { id, ...config }]
279
+ return id
280
+ }
281
+
282
+ updateGeom(id, config) {
283
+ // untrack the read of #geoms to avoid effect_update_depth_exceeded when
284
+ // called from a geom's $effect (which would otherwise track #geoms as a dependency)
285
+ this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
286
+ }
287
+
288
+ unregisterGeom(id) {
289
+ this.#geoms = this.#geoms.filter((g) => g.id !== id)
290
+ }
291
+
292
+ geomData(id) {
293
+ const geom = this.#geoms.find((g) => g.id === id)
294
+ if (!geom) return []
295
+ const stat = geom.stat ?? 'identity'
296
+ if (stat === 'identity') return this.#rawData
297
+ const mergedChannels = { ...this.#channels, ...geom.channels }
298
+ return applyGeomStat(this.#rawData, { stat, channels: mergedChannels }, this.#helpers)
299
+ }
300
+
301
+ label(field) {
302
+ return this.#labels?.[field] ?? field
303
+ }
304
+
305
+ format(field) {
306
+ return resolveFormat(field, this.#helpers)
307
+ }
308
+ tooltip() {
309
+ return resolveTooltip(this.#helpers)
310
+ }
311
+ geomComponent(type) {
312
+ return resolveGeom(type, this.#helpers)
313
+ }
314
+ preset() {
315
+ return resolvePreset(this.#presetName, this.#helpers)
316
+ }
317
+
318
+ get margin() {
319
+ return this.#effectiveMargin
320
+ }
321
+ get innerWidth() {
322
+ return this.#innerWidth
323
+ }
324
+ get innerHeight() {
325
+ return this.#innerHeight
326
+ }
327
+ get mode() {
328
+ return this.#mode
329
+ }
330
+ get chartPreset() {
331
+ return this.#chartPreset
332
+ }
333
+ get hovered() {
334
+ return this.#hovered
335
+ }
336
+
337
+ setHovered(data) {
338
+ this.#hovered = data
339
+ }
340
+ clearHovered() {
341
+ this.#hovered = null
342
+ }
343
+
344
+ applyZoom(transform) {
345
+ this.#zoomTransform = transform
346
+ }
347
+ resetZoom() {
348
+ this.#zoomTransform = null
349
+ }
278
350
  }