@rokkit/chart 1.0.0-next.98 → 1.0.1

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 (137) hide show
  1. package/README.md +150 -46
  2. package/package.json +40 -51
  3. package/src/AnimatedPlot.svelte +383 -0
  4. package/src/Chart.svelte +87 -59
  5. package/src/ChartExporter.svelte +125 -0
  6. package/src/ChartLegend.svelte +148 -0
  7. package/src/ChartProvider.svelte +10 -0
  8. package/src/FacetPlot/Panel.svelte +37 -0
  9. package/src/FacetPlot.svelte +114 -0
  10. package/src/Plot/Arc.svelte +29 -0
  11. package/src/Plot/Area.svelte +32 -0
  12. package/src/Plot/Axis.svelte +164 -0
  13. package/src/Plot/Bar.svelte +54 -0
  14. package/src/Plot/Grid.svelte +34 -0
  15. package/src/Plot/Legend.svelte +268 -0
  16. package/src/Plot/Line.svelte +37 -0
  17. package/src/Plot/Point.svelte +40 -0
  18. package/src/Plot/Root.svelte +62 -0
  19. package/src/Plot/Timeline.svelte +95 -0
  20. package/src/Plot/Tooltip.svelte +87 -0
  21. package/src/Plot/index.js +9 -0
  22. package/src/Plot.svelte +313 -0
  23. package/src/PlotState.svelte.js +375 -0
  24. package/src/Sparkline.svelte +108 -0
  25. package/src/Symbol.svelte +15 -11
  26. package/src/Texture.svelte +9 -7
  27. package/src/charts/AreaChart.svelte +30 -0
  28. package/src/charts/BarChart.svelte +31 -0
  29. package/src/charts/BoxPlot.svelte +24 -0
  30. package/src/charts/BubbleChart.svelte +28 -0
  31. package/src/charts/LineChart.svelte +31 -0
  32. package/src/charts/PieChart.svelte +35 -0
  33. package/src/charts/ScatterPlot.svelte +31 -0
  34. package/src/charts/ViolinPlot.svelte +24 -0
  35. package/src/crossfilter/CrossFilter.svelte +42 -0
  36. package/src/crossfilter/FilterBar.svelte +24 -0
  37. package/src/crossfilter/FilterHistogram.svelte +290 -0
  38. package/src/crossfilter/FilterSlider.svelte +83 -0
  39. package/src/crossfilter/createCrossFilter.svelte.js +124 -0
  40. package/src/elements/Bar.svelte +20 -16
  41. package/src/elements/ColorRamp.svelte +19 -19
  42. package/src/elements/ContinuousLegend.svelte +41 -19
  43. package/src/elements/DefinePatterns.svelte +9 -7
  44. package/src/elements/DiscreteLegend.svelte +13 -11
  45. package/src/elements/Label.svelte +4 -9
  46. package/src/elements/SymbolGrid.svelte +8 -9
  47. package/src/examples/BarChartExample.svelte +81 -0
  48. package/src/geoms/Arc.svelte +126 -0
  49. package/src/geoms/Area.svelte +78 -0
  50. package/src/geoms/Bar.svelte +200 -0
  51. package/src/geoms/Box.svelte +113 -0
  52. package/src/geoms/Candlestick.svelte +110 -0
  53. package/src/geoms/Heatmap.svelte +92 -0
  54. package/src/geoms/Hexbin.svelte +114 -0
  55. package/src/geoms/LabelPill.svelte +17 -0
  56. package/src/geoms/Line.svelte +123 -0
  57. package/src/geoms/Point.svelte +145 -0
  58. package/src/geoms/Ribbon.svelte +161 -0
  59. package/src/geoms/Violin.svelte +56 -0
  60. package/src/geoms/Waterfall.svelte +114 -0
  61. package/src/geoms/lib/areas.js +154 -0
  62. package/src/geoms/lib/bars.js +223 -0
  63. package/src/index.js +81 -2
  64. package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
  65. package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
  66. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  67. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  68. package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
  69. package/src/lib/brewing/axes.svelte.js +270 -0
  70. package/src/lib/brewing/bars.svelte.js +201 -0
  71. package/src/lib/brewing/brewer.svelte.js +277 -0
  72. package/src/lib/brewing/colors.js +157 -0
  73. package/src/lib/brewing/dimensions.svelte.js +56 -0
  74. package/src/lib/brewing/index.svelte.js +205 -0
  75. package/src/lib/brewing/legends.svelte.js +137 -0
  76. package/src/lib/brewing/marks/arcs.js +43 -0
  77. package/src/lib/brewing/marks/areas.js +72 -0
  78. package/src/lib/brewing/marks/bars.js +49 -0
  79. package/src/lib/brewing/marks/boxes.js +75 -0
  80. package/src/lib/brewing/marks/lines.js +55 -0
  81. package/src/lib/brewing/marks/points.js +105 -0
  82. package/src/lib/brewing/marks/violins.js +90 -0
  83. package/src/lib/brewing/patterns.js +45 -0
  84. package/src/lib/brewing/scales.js +51 -0
  85. package/src/lib/brewing/scales.svelte.js +82 -0
  86. package/src/lib/brewing/stats.js +74 -0
  87. package/src/lib/brewing/symbols.js +10 -0
  88. package/src/lib/brewing/types.js +73 -0
  89. package/src/lib/chart.js +16 -8
  90. package/src/lib/context.js +131 -0
  91. package/src/lib/keyboard-nav.js +37 -0
  92. package/src/lib/plot/chartProps.js +76 -0
  93. package/src/lib/plot/crossfilter.js +16 -0
  94. package/src/lib/plot/facet.js +58 -0
  95. package/src/lib/plot/frames.js +81 -0
  96. package/src/lib/plot/helpers.js +14 -0
  97. package/src/lib/plot/preset.js +67 -0
  98. package/src/lib/plot/scales.js +81 -0
  99. package/src/lib/plot/stat.js +92 -0
  100. package/src/lib/plot/types.js +65 -0
  101. package/src/lib/preset.js +41 -0
  102. package/src/lib/scales.svelte.js +151 -0
  103. package/src/lib/swatch.js +4 -7
  104. package/src/lib/utils.js +142 -0
  105. package/src/lib/xscale.js +31 -0
  106. package/src/patterns/DefinePatterns.svelte +32 -0
  107. package/src/patterns/PatternDef.svelte +27 -0
  108. package/src/patterns/index.js +4 -14
  109. package/src/patterns/patterns.js +360 -0
  110. package/src/patterns/scale.js +116 -0
  111. package/src/spec/chart-spec.js +72 -0
  112. package/src/symbols/RoundedSquare.svelte +18 -12
  113. package/src/symbols/Shape.svelte +22 -16
  114. package/src/symbols/constants/index.js +1 -1
  115. package/LICENSE +0 -21
  116. package/src/lib/index.js +0 -4
  117. package/src/lib/plots.js +0 -27
  118. package/src/patterns/Brick.svelte +0 -17
  119. package/src/patterns/Circles.svelte +0 -18
  120. package/src/patterns/CrossHatch.svelte +0 -14
  121. package/src/patterns/CurvedWave.svelte +0 -9
  122. package/src/patterns/Dots.svelte +0 -19
  123. package/src/patterns/OutlineCircles.svelte +0 -15
  124. package/src/patterns/Tile.svelte +0 -17
  125. package/src/patterns/Triangles.svelte +0 -15
  126. package/src/patterns/Waves.svelte +0 -13
  127. package/src/patterns/paths/NamedPattern.svelte +0 -12
  128. package/src/patterns/paths/constants.js +0 -4
  129. package/src/plots/Plot.svelte +0 -40
  130. package/src/plots/index.js +0 -1
  131. package/src/template/Texture.svelte +0 -16
  132. package/src/template/constants.js +0 -43
  133. package/src/template/shapes/Circles.svelte +0 -16
  134. package/src/template/shapes/Lines.svelte +0 -17
  135. package/src/template/shapes/Path.svelte +0 -12
  136. package/src/template/shapes/Polygons.svelte +0 -18
  137. package/src/template/shapes/index.js +0 -4
@@ -0,0 +1,313 @@
1
+ <script>
2
+ import { setContext, getContext, untrack } from 'svelte'
3
+ import { zoom as d3Zoom } from 'd3-zoom'
4
+ import { select } from 'd3-selection'
5
+ import { PlotState } from './PlotState.svelte.js'
6
+ import { defaultPreset } from './lib/preset.js'
7
+ import Axis from './Plot/Axis.svelte'
8
+ import Grid from './Plot/Grid.svelte'
9
+ import Legend from './Plot/Legend.svelte'
10
+ import Tooltip from './Plot/Tooltip.svelte'
11
+ import DefinePatterns from './patterns/DefinePatterns.svelte'
12
+ import Bar from './geoms/Bar.svelte'
13
+ import Line from './geoms/Line.svelte'
14
+ import Area from './geoms/Area.svelte'
15
+ import Point from './geoms/Point.svelte'
16
+ import Arc from './geoms/Arc.svelte'
17
+ import Box from './geoms/Box.svelte'
18
+ import Violin from './geoms/Violin.svelte'
19
+ import Heatmap from './geoms/Heatmap.svelte'
20
+ import Candlestick from './geoms/Candlestick.svelte'
21
+ import Waterfall from './geoms/Waterfall.svelte'
22
+ import Hexbin from './geoms/Hexbin.svelte'
23
+ import Ribbon from './geoms/Ribbon.svelte'
24
+
25
+ /**
26
+ * @type {{
27
+ * data?: Object[],
28
+ * spec?: import('./lib/plot/types.js').PlotSpec,
29
+ * helpers?: import('./lib/plot/types.js').PlotHelpers,
30
+ * width?: number,
31
+ * height?: number,
32
+ * mode?: 'light' | 'dark',
33
+ * grid?: boolean,
34
+ * axes?: boolean,
35
+ * margin?: { top: number, right: number, bottom: number, left: number },
36
+ * legend?: boolean,
37
+ * title?: string,
38
+ * summary?: string,
39
+ * tooltip?: boolean | ((data: Record<string, unknown>) => string),
40
+ * zoom?: boolean,
41
+ * children?: import('svelte').Snippet,
42
+ * }}
43
+ */
44
+ let {
45
+ data = [],
46
+ spec = undefined,
47
+ helpers = {},
48
+ width = 600,
49
+ height = 400,
50
+ mode = 'light',
51
+ grid = true,
52
+ axes = true,
53
+ margin = undefined,
54
+ legend = false,
55
+ title = '',
56
+ summary = '',
57
+ tooltip = false,
58
+ zoom = false,
59
+ xFormat = undefined,
60
+ yFormat = undefined,
61
+ xTicks = undefined,
62
+ yTicks = undefined,
63
+ minorTicks = false,
64
+ children
65
+ } = $props()
66
+
67
+ const chartPresetCtx = getContext('chart-preset')
68
+ const chartPreset = $derived(chartPresetCtx?.current ?? defaultPreset)
69
+
70
+ // Responsive width: observe container and use actual pixel width for scale calculations
71
+ let svgEl = $state(null)
72
+ let containerEl = $state(null)
73
+ let observedWidth = $state(0)
74
+
75
+ $effect(() => {
76
+ if (!containerEl) return
77
+ const ro = new ResizeObserver((entries) => {
78
+ const w = Math.floor(entries[0].contentRect.width)
79
+ if (w > 0) observedWidth = w
80
+ })
81
+ ro.observe(containerEl)
82
+ return () => ro.disconnect()
83
+ })
84
+
85
+ const effectiveWidth = $derived(observedWidth > 0 ? observedWidth : (spec?.width ?? width))
86
+ const svgHeight = $derived(spec?.height ?? height)
87
+ const showGrid = $derived(spec?.grid ?? grid)
88
+ const showLegend = $derived(spec?.legend ?? legend)
89
+ const chartTitle = $derived(spec?.title ?? title)
90
+ const chartSummary = $derived(spec?.summary ?? summary)
91
+
92
+ // Accessible data table — screen reader fallback
93
+ const tableData = $derived(spec?.data ?? data)
94
+ const tableColumns = $derived.by(() => {
95
+ const cols = [spec?.x, spec?.y, spec?.color ?? spec?.fill].filter(Boolean)
96
+ if (cols.length > 0) return cols
97
+ const first = tableData[0]
98
+ return first ? Object.keys(first) : []
99
+ })
100
+
101
+ function buildPlotConfig() {
102
+ return {
103
+ data: spec?.data ?? data,
104
+ width: effectiveWidth,
105
+ height: spec?.height ?? height,
106
+ mode,
107
+ margin,
108
+ channels: spec ? { x: spec.x, y: spec.y, color: spec.color ?? spec.fill } : {},
109
+ labels: spec?.labels ?? {},
110
+ helpers,
111
+ xDomain: spec?.xDomain,
112
+ yDomain: spec?.yDomain,
113
+ colorDomain: spec?.colorDomain,
114
+ colorScale: spec?.colorScale,
115
+ colorScheme: spec?.colorScheme,
116
+ colorMidpoint: spec?.colorMidpoint,
117
+ orientation: spec?.orientation,
118
+ chartPreset
119
+ }
120
+ }
121
+
122
+ // Create PlotState with initial values and provide as context.
123
+ // untrack() suppresses "captures initial value" warnings — intentional:
124
+ // the $effect below handles all subsequent reactive updates.
125
+ const plotState = untrack(() => new PlotState(buildPlotConfig()))
126
+ setContext('plot-state', plotState)
127
+
128
+ // Keep state in sync when reactive config changes
129
+ $effect(() => {
130
+ plotState.update(buildPlotConfig())
131
+ })
132
+
133
+ $effect(() => {
134
+ if (!zoom || !svgEl) return
135
+ const zoomBehavior = d3Zoom()
136
+ .scaleExtent([1, 8])
137
+ .on('zoom', (event) => {
138
+ plotState.applyZoom(event.transform)
139
+ })
140
+ const sel = select(svgEl)
141
+ sel.call(zoomBehavior)
142
+ return () => {
143
+ sel.on('.zoom', null)
144
+ plotState.resetZoom()
145
+ }
146
+ })
147
+
148
+ // Geoms from spec (spec-driven API)
149
+ const specGeoms = $derived(spec?.geoms ?? [])
150
+
151
+ // Geom component resolver for spec-driven mode
152
+ const GEOM_COMPONENTS = {
153
+ bar: Bar,
154
+ line: Line,
155
+ area: Area,
156
+ point: Point,
157
+ arc: Arc,
158
+ box: Box,
159
+ violin: Violin,
160
+ heatmap: Heatmap,
161
+ candlestick: Candlestick,
162
+ waterfall: Waterfall,
163
+ hexbin: Hexbin,
164
+ ribbon: Ribbon
165
+ }
166
+
167
+ /**
168
+ * @param {string} type
169
+ */
170
+ function resolveGeomComponent(type) {
171
+ return helpers?.geoms?.[type] ?? GEOM_COMPONENTS[type]
172
+ }
173
+ </script>
174
+
175
+ <div class="plot-root" data-plot-root data-mode={mode} bind:this={containerEl}>
176
+ {#if chartTitle}
177
+ <div class="plot-title" data-plot-title>{chartTitle}</div>
178
+ {/if}
179
+
180
+ <svg
181
+ bind:this={svgEl}
182
+ width={effectiveWidth}
183
+ height={svgHeight}
184
+ viewBox="0 0 {effectiveWidth} {svgHeight}"
185
+ role="img"
186
+ aria-label={chartTitle || 'Chart visualization'}
187
+ style:cursor={zoom ? 'grab' : undefined}
188
+ >
189
+ {#if chartSummary}
190
+ <desc>{chartSummary}</desc>
191
+ {/if}
192
+ <!-- SVG pattern defs -->
193
+ <DefinePatterns />
194
+
195
+ <g
196
+ class="plot-canvas"
197
+ transform="translate({plotState.margin.left}, {plotState.margin.top})"
198
+ data-plot-canvas
199
+ >
200
+ <!-- Grid (behind everything) -->
201
+ {#if showGrid}
202
+ <Grid />
203
+ {/if}
204
+
205
+ <!-- Declarative children (geom components) -->
206
+ {@render children?.()}
207
+
208
+ <!-- Spec-driven geoms -->
209
+ {#each specGeoms as geomSpec (geomSpec.type)}
210
+ {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
211
+ {#if GeomComponent}
212
+ <GeomComponent
213
+ x={geomSpec.x ?? spec?.x}
214
+ y={geomSpec.y ?? spec?.y}
215
+ color={geomSpec.color ?? spec?.color}
216
+ fill={geomSpec.fill ?? spec?.fill}
217
+ pattern={geomSpec.pattern}
218
+ symbol={geomSpec.symbol}
219
+ stat={geomSpec.stat}
220
+ label={geomSpec.label}
221
+ options={geomSpec.options}
222
+ />
223
+ {/if}
224
+ {/each}
225
+
226
+ <!-- Axes -->
227
+ {#if axes}
228
+ <Axis type="x" label={spec?.labels?.[spec?.x ?? ''] ?? ''} format={xFormat} ticks={xTicks} {minorTicks} />
229
+ <Axis type="y" label={spec?.labels?.[spec?.y ?? ''] ?? ''} format={yFormat} ticks={yTicks} {minorTicks} />
230
+ {/if}
231
+ </g>
232
+ </svg>
233
+
234
+ <!-- Legend (HTML, outside SVG) -->
235
+ {#if showLegend}
236
+ <Legend labels={spec?.labels ?? {}} />
237
+ {/if}
238
+
239
+ <!-- Tooltip (HTML, fixed-position overlay) -->
240
+ {#if tooltip}
241
+ <Tooltip {tooltip} />
242
+ {/if}
243
+
244
+ <!-- Accessible data table — visually hidden, announces data to screen readers -->
245
+ {#if tableData.length > 0 && tableColumns.length > 0}
246
+ <table class="plot-sr-table" aria-label={chartTitle || 'Chart data'}>
247
+ {#if chartTitle}
248
+ <caption>{chartTitle}</caption>
249
+ {/if}
250
+ <thead>
251
+ <tr>
252
+ {#each tableColumns as col (col)}
253
+ <th scope="col">{col}</th>
254
+ {/each}
255
+ </tr>
256
+ </thead>
257
+ <tbody>
258
+ {#each tableData as row, i (i)}
259
+ <tr>
260
+ {#each tableColumns as col (col)}
261
+ <td>{row[col] ?? ''}</td>
262
+ {/each}
263
+ </tr>
264
+ {/each}
265
+ </tbody>
266
+ </table>
267
+ {/if}
268
+ </div>
269
+
270
+ <style>
271
+ .plot-root {
272
+ position: relative;
273
+ width: 100%;
274
+ height: auto;
275
+ }
276
+
277
+ [data-plot-root][data-mode='light'] {
278
+ color: rgba(var(--color-surface-700, 59, 65, 77), 1);
279
+ }
280
+
281
+ [data-plot-root][data-mode='dark'] {
282
+ color: rgba(var(--color-surface-200, 224, 224, 224), 1);
283
+ }
284
+
285
+ svg {
286
+ display: block;
287
+ overflow: visible;
288
+ }
289
+
290
+ .plot-canvas {
291
+ pointer-events: all;
292
+ }
293
+
294
+ .plot-title {
295
+ font-size: 14px;
296
+ font-weight: 600;
297
+ text-align: center;
298
+ margin-bottom: 4px;
299
+ }
300
+
301
+ /* Visually hidden — in DOM for screen readers, not visible */
302
+ .plot-sr-table {
303
+ position: absolute;
304
+ width: 1px;
305
+ height: 1px;
306
+ padding: 0;
307
+ margin: -1px;
308
+ overflow: hidden;
309
+ clip: rect(0, 0, 0, 0);
310
+ white-space: nowrap;
311
+ border: 0;
312
+ }
313
+ </style>
@@ -0,0 +1,375 @@
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
+ export class PlotState {
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
+ #colorScheme = $state(undefined)
30
+ #colorDomain = $state(undefined)
31
+ #xDomain = $state(undefined)
32
+ #yDomain = $state(undefined)
33
+ #width = $state(600)
34
+ #height = $state(400)
35
+ #margin = $state({ top: 20, right: 20, bottom: 40, left: 50 })
36
+ #marginOverride = $state(undefined)
37
+
38
+ #geoms = $state([])
39
+ #mode = $state('light')
40
+ #chartPreset = $state(defaultPreset)
41
+ #hovered = $state(null)
42
+ #orientationOverride = $state(undefined)
43
+
44
+ axisOrigin = $state([undefined, undefined])
45
+ #axisOffset = $state(0)
46
+
47
+ #zoomTransform = $state(null)
48
+
49
+ #effectiveMargin = $derived(this.#marginOverride ?? this.#margin)
50
+ #innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
51
+ #innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
52
+
53
+ // Effective channels: prefer top-level channels; fall back to first geom's channels
54
+ // for the declarative API where no spec is provided.
55
+ #mergeGeomChannels(tc, geom) {
56
+ return {
57
+ x: tc.x ?? geom.channels?.x,
58
+ y: tc.y ?? geom.channels?.y,
59
+ color: tc.color ?? geom.channels?.color,
60
+ pattern: tc.pattern ?? geom.channels?.pattern,
61
+ symbol: tc.symbol ?? geom.channels?.symbol
62
+ }
63
+ }
64
+
65
+ #effectiveChannels = $derived.by(() => {
66
+ const tc = this.#channels
67
+ if (tc.x && tc.y) return tc
68
+ const firstGeom = this.#geoms[0]
69
+ if (!firstGeom) return tc
70
+ return this.#mergeGeomChannels(tc, firstGeom)
71
+ })
72
+
73
+ #resolveXType(rawXType, yType) {
74
+ const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
75
+ return hasBarGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
76
+ }
77
+
78
+ orientation = $derived.by(() => {
79
+ if (this.#orientationOverride) return this.#orientationOverride
80
+ const xField = this.#effectiveChannels.x
81
+ const yField = this.#effectiveChannels.y
82
+ if (!xField || !yField) return 'none'
83
+ const rawXType = inferFieldType(this.#data, xField)
84
+ const yType = inferFieldType(this.#data, yField)
85
+ // Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
86
+ return inferOrientation(this.#resolveXType(rawXType, yType), yType)
87
+ })
88
+
89
+ colorScaleType = $derived.by(() => {
90
+ const field = this.#effectiveChannels.color
91
+ if (!field) return 'categorical'
92
+ return inferColorScaleType(this.#data, field, {
93
+ colorScale: this.#colorSpec,
94
+ colorMidpoint: this.#colorMidpoint
95
+ })
96
+ })
97
+
98
+ // Continuous color scale (sequential or diverging) — null for categorical.
99
+ continuousColorScale = $derived.by(() => {
100
+ const field = this.#effectiveChannels.color
101
+ if (!field || this.colorScaleType === 'categorical') return null
102
+ const opts = {
103
+ colorScheme: this.#colorScheme,
104
+ colorDomain: this.#colorDomain,
105
+ colorMidpoint: this.#colorMidpoint
106
+ }
107
+ if (this.colorScaleType === 'diverging') {
108
+ return buildDivergingScale(this.#data, field, opts)
109
+ }
110
+ return buildSequentialScale(this.#data, field, opts)
111
+ })
112
+
113
+ xScale = $derived.by(() => {
114
+ const field = this.#effectiveChannels.x
115
+ if (!field) return null
116
+ const datasets =
117
+ this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
118
+ const includeZero = this.orientation === 'horizontal'
119
+ // For vertical bar charts, force scaleBand even when X values are numeric (e.g. year).
120
+ // Horizontal bar charts keep X as a continuous value axis.
121
+ const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
122
+ const bandX = hasBarGeom && this.orientation !== 'horizontal'
123
+ const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
124
+ domain: this.#xDomain,
125
+ includeZero,
126
+ band: bandX
127
+ })
128
+ return this.#zoomTransform && typeof base?.bandwidth !== 'function'
129
+ ? this.#zoomTransform.rescaleX(base)
130
+ : base
131
+ })
132
+
133
+ // For box/violin geoms, compute y domain from iqr_min/iqr_max instead of raw y values.
134
+ #resolveBoxDomain() {
135
+ const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
136
+ if (!boxGeom) return null
137
+ const boxData = this.geomData(boxGeom.id)
138
+ const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
139
+ const mins = boxData.map((d) => d.iqr_min).filter(isValid)
140
+ const maxs = boxData.map((d) => d.iqr_max).filter(isValid)
141
+ return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
142
+ }
143
+
144
+ // For stacked bars, compute y domain from per-x column totals.
145
+ #resolveStackDomain(field) {
146
+ const stackGeom = this.#geoms.find((g) => g.options?.stack)
147
+ if (!stackGeom) return null
148
+ const xField = this.#effectiveChannels.x
149
+ const stackData = this.geomData(stackGeom.id)
150
+ if (!xField || stackData.length === 0) return null
151
+ // Mirror buildStackedBars/subBandFields: stack dimension is the first
152
+ // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
153
+ // would overcount when multiple rows share the same (x, stack) key.
154
+ const colorField = isLiteralColor(this.#effectiveChannels.color)
155
+ ? null
156
+ : this.#effectiveChannels.color
157
+ const patternField = this.#effectiveChannels.pattern
158
+ const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
159
+ const lookup = new SvelteMap()
160
+ for (const d of stackData) {
161
+ const xVal = d[xField]
162
+ const cKey = stackField ? String(d[stackField]) : '_'
163
+ if (!lookup.has(xVal)) lookup.set(xVal, new SvelteMap())
164
+ lookup.get(xVal).set(cKey, Number(d[field]) || 0)
165
+ }
166
+ const totals = new SvelteMap()
167
+ for (const [xVal, colorMap] of lookup) {
168
+ totals.set(
169
+ xVal,
170
+ [...colorMap.values()].reduce((s, v) => s + v, 0)
171
+ )
172
+ }
173
+ return [0, Math.max(0, ...totals.values())]
174
+ }
175
+
176
+ yScale = $derived.by(() => {
177
+ const field = this.#effectiveChannels.y
178
+ if (!field) return null
179
+ const datasets =
180
+ this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
181
+ const includeZero = this.orientation === 'vertical'
182
+ const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
183
+ const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
184
+ return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
185
+ })
186
+
187
+ // Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
188
+ // If the color channel is a CSS literal (e.g. '#4a90d9'), return a singleton map
189
+ // keyed by null so all marks pick it up via the fallback path.
190
+ // If a colorDomain is provided (e.g. from FacetPlot for cross-panel consistency),
191
+ // use it instead of deriving distinct values from the local panel data.
192
+ colors = $derived.by(() => {
193
+ const field = this.#effectiveChannels.color
194
+ if (isLiteralColor(field)) return new Map([[null, { fill: field, stroke: field }]])
195
+ const values = this.#colorDomain ?? distinct(this.#data, field)
196
+ // No color channel but data exists → use first preset color for single-series rendering.
197
+ // This prevents geoms from falling back to gray (#888) on charts with no fill channel.
198
+ if (values.length === 0 && this.#data.length > 0) return assignColors([null], this.#mode, this.#chartPreset)
199
+ return assignColors(values, this.#mode, this.#chartPreset)
200
+ })
201
+
202
+ // Patterns: Map<patternKey, patternName> — only populated when a pattern channel is set
203
+ // and the pattern field is categorical (continuous fields can't be discretely patterned).
204
+ patterns = $derived.by(() => {
205
+ const pf = this.#effectiveChannels.pattern
206
+ if (!pf) return new SvelteMap()
207
+ if (inferFieldType(this.#data, pf) === 'continuous') return new SvelteMap()
208
+ return assignPatterns(distinct(this.#data, pf))
209
+ })
210
+
211
+ // Symbols: Map<symbolKey, shapeName> — only populated when a symbol channel is set.
212
+ symbols = $derived.by(() => {
213
+ const sf = this.#effectiveChannels.symbol
214
+ if (!sf) return new SvelteMap()
215
+ return assignSymbols(distinct(this.#data, sf), this.#chartPreset)
216
+ })
217
+
218
+ // Expose effective channel fields for consumers (e.g. Legend).
219
+ // Returns null for literal CSS colors since they don't map to a data field.
220
+ colorField = $derived(
221
+ isLiteralColor(this.#effectiveChannels.color) ? null : this.#effectiveChannels.color
222
+ )
223
+ patternField = $derived(this.#effectiveChannels.pattern)
224
+ symbolField = $derived(this.#effectiveChannels.symbol)
225
+
226
+ // Set of geom types currently registered (used by Legend to pick swatch style)
227
+ geomTypes = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
228
+
229
+ xAxisY = $derived.by(() => {
230
+ if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
231
+ const crossVal = this.axisOrigin[1]
232
+ if (crossVal !== undefined) return this.yScale(crossVal)
233
+ const domain = this.yScale.domain?.()
234
+ if (!domain) return this.#innerHeight
235
+ // Auto quadrant: place x-axis at y=0 when domain spans zero (no offset)
236
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.yScale(0)
237
+ // Q1-only: axis at bottom edge, optionally with offset
238
+ const base = this.yScale(domain[0])
239
+ return this.#axisOffset ? base + this.#axisOffset : base
240
+ })
241
+
242
+ yAxisX = $derived.by(() => {
243
+ if (!this.xScale || typeof this.xScale !== 'function') return 0
244
+ const crossVal = this.axisOrigin[0]
245
+ if (crossVal !== undefined) return this.xScale(crossVal)
246
+ const domain = this.xScale.domain?.()
247
+ if (!domain || typeof this.xScale.bandwidth === 'function') return 0
248
+ // Auto quadrant: place y-axis at x=0 when domain spans zero (no offset)
249
+ if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.xScale(0)
250
+ // Q1-only: axis at left edge, optionally with offset
251
+ const base = this.xScale(domain[0])
252
+ return this.#axisOffset ? base - this.#axisOffset : base
253
+ })
254
+
255
+ constructor(config = {}) {
256
+ this.#rawData = config.data ?? []
257
+ this.#data = config.data ?? []
258
+ this.#channels = config.channels ?? {}
259
+ this.#labels = config.labels ?? {}
260
+ this.#helpers = config.helpers ?? {}
261
+ this.#presetName = config.preset
262
+ this.#colorMidpoint = config.colorMidpoint
263
+ this.#colorSpec = config.colorScale
264
+ this.#colorScheme = config.colorScheme
265
+ this.#colorDomain = config.colorDomain
266
+ this.#xDomain = config.xDomain
267
+ this.#yDomain = config.yDomain
268
+ this.#width = config.width ?? 600
269
+ this.#height = config.height ?? 400
270
+ this.#mode = config.mode ?? 'light'
271
+ this.#chartPreset = config.chartPreset ?? defaultPreset
272
+ this.#axisOffset = config.axisOffset ?? 0
273
+ this.#marginOverride = config.margin ?? undefined
274
+ this.#orientationOverride = config.orientation ?? undefined
275
+ }
276
+
277
+ update(config) {
278
+ if (config.data !== undefined) {
279
+ this.#rawData = config.data
280
+ this.#data = config.data
281
+ }
282
+ if (config.channels !== undefined) this.#channels = config.channels
283
+ if (config.labels !== undefined) this.#labels = config.labels
284
+ if (config.helpers !== undefined) this.#helpers = config.helpers
285
+ if (config.preset !== undefined) this.#presetName = config.preset
286
+ if (config.colorMidpoint !== undefined) this.#colorMidpoint = config.colorMidpoint
287
+ if (config.colorScale !== undefined) this.#colorSpec = config.colorScale
288
+ if (config.colorScheme !== undefined) this.#colorScheme = config.colorScheme
289
+ this.#colorDomain = config.colorDomain
290
+ this.#xDomain = config.xDomain
291
+ this.#yDomain = config.yDomain
292
+ if (config.width !== undefined) this.#width = config.width
293
+ if (config.height !== undefined) this.#height = config.height
294
+ if (config.mode !== undefined) this.#mode = config.mode
295
+ if (config.chartPreset !== undefined) this.#chartPreset = config.chartPreset
296
+ if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
297
+ this.#marginOverride = config.margin ?? undefined
298
+ this.#orientationOverride = config.orientation ?? undefined
299
+ }
300
+
301
+ registerGeom(config) {
302
+ const id = `geom-${nextId++}`
303
+ this.#geoms = [...this.#geoms, { id, ...config }]
304
+ return id
305
+ }
306
+
307
+ updateGeom(id, config) {
308
+ // untrack the read of #geoms to avoid effect_update_depth_exceeded when
309
+ // called from a geom's $effect (which would otherwise track #geoms as a dependency)
310
+ this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
311
+ }
312
+
313
+ unregisterGeom(id) {
314
+ this.#geoms = this.#geoms.filter((g) => g.id !== id)
315
+ }
316
+
317
+ geomData(id) {
318
+ const geom = this.#geoms.find((g) => g.id === id)
319
+ if (!geom) return []
320
+ const stat = geom.stat ?? 'identity'
321
+ if (stat === 'identity') return this.#rawData
322
+ const mergedChannels = { ...this.#channels, ...geom.channels }
323
+ return applyGeomStat(this.#rawData, { stat, channels: mergedChannels }, this.#helpers)
324
+ }
325
+
326
+ label(field) {
327
+ return this.#labels?.[field] ?? field
328
+ }
329
+
330
+ format(field) {
331
+ return resolveFormat(field, this.#helpers)
332
+ }
333
+ tooltip() {
334
+ return resolveTooltip(this.#helpers)
335
+ }
336
+ geomComponent(type) {
337
+ return resolveGeom(type, this.#helpers)
338
+ }
339
+ preset() {
340
+ return resolvePreset(this.#presetName, this.#helpers)
341
+ }
342
+
343
+ get margin() {
344
+ return this.#effectiveMargin
345
+ }
346
+ get innerWidth() {
347
+ return this.#innerWidth
348
+ }
349
+ get innerHeight() {
350
+ return this.#innerHeight
351
+ }
352
+ get mode() {
353
+ return this.#mode
354
+ }
355
+ get chartPreset() {
356
+ return this.#chartPreset
357
+ }
358
+ get hovered() {
359
+ return this.#hovered
360
+ }
361
+
362
+ setHovered(data) {
363
+ this.#hovered = data
364
+ }
365
+ clearHovered() {
366
+ this.#hovered = null
367
+ }
368
+
369
+ applyZoom(transform) {
370
+ this.#zoomTransform = transform
371
+ }
372
+ resetZoom() {
373
+ this.#zoomTransform = null
374
+ }
375
+ }