@rokkit/chart 1.3.7 → 1.3.9

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 (162) hide show
  1. package/dist/AnimatedPlot.svelte +26 -8
  2. package/dist/AnimatedPlot.svelte.d.ts +5 -0
  3. package/dist/Chart.svelte +51 -65
  4. package/dist/FacetPlot.svelte +1 -1
  5. package/dist/Plot/Axis.svelte +19 -10
  6. package/dist/Plot/Root.svelte +67 -46
  7. package/dist/Plot/Root.svelte.d.ts +29 -2
  8. package/dist/Plot/Timeline.svelte +39 -13
  9. package/dist/Plot/index.d.ts +5 -5
  10. package/dist/Plot/index.js +5 -5
  11. package/dist/Plot.svelte +95 -178
  12. package/dist/Plot.svelte.d.ts +4 -0
  13. package/dist/PlotState.svelte.d.ts +29 -1
  14. package/dist/PlotState.svelte.js +163 -19
  15. package/dist/PlotSurface.svelte +162 -0
  16. package/dist/PlotSurface.svelte.d.ts +25 -0
  17. package/dist/charts/AreaChart.svelte +12 -2
  18. package/dist/charts/AreaChart.svelte.d.ts +6 -0
  19. package/dist/charts/BarChart.svelte +40 -4
  20. package/dist/charts/BarChart.svelte.d.ts +15 -1
  21. package/dist/charts/BoxPlot.svelte +7 -3
  22. package/dist/charts/BoxPlot.svelte.d.ts +2 -0
  23. package/dist/charts/BubbleChart.svelte +37 -4
  24. package/dist/charts/BubbleChart.svelte.d.ts +12 -1
  25. package/dist/charts/LineChart.svelte +4 -2
  26. package/dist/charts/LineChart.svelte.d.ts +1 -0
  27. package/dist/charts/PieChart.svelte +5 -3
  28. package/dist/charts/PieChart.svelte.d.ts +1 -0
  29. package/dist/charts/ScatterPlot.svelte +35 -4
  30. package/dist/charts/ScatterPlot.svelte.d.ts +11 -1
  31. package/dist/charts/ViolinPlot.svelte +7 -3
  32. package/dist/charts/ViolinPlot.svelte.d.ts +2 -0
  33. package/dist/examples/BarChartExample.svelte +1 -1
  34. package/dist/geoms/Arc.svelte +21 -54
  35. package/dist/geoms/Arc.svelte.d.ts +4 -4
  36. package/dist/geoms/Area.svelte +45 -44
  37. package/dist/geoms/Area.svelte.d.ts +8 -1
  38. package/dist/geoms/Bar.svelte +33 -45
  39. package/dist/geoms/Bar.svelte.d.ts +9 -0
  40. package/dist/geoms/Box.svelte +103 -61
  41. package/dist/geoms/Box.svelte.d.ts +12 -3
  42. package/dist/geoms/Candlestick.svelte +26 -75
  43. package/dist/geoms/Candlestick.svelte.d.ts +2 -0
  44. package/dist/geoms/Heatmap.svelte +21 -66
  45. package/dist/geoms/Heatmap.svelte.d.ts +2 -0
  46. package/dist/geoms/Hexbin.svelte +20 -100
  47. package/dist/geoms/Hexbin.svelte.d.ts +2 -0
  48. package/dist/geoms/Jitter.svelte +67 -0
  49. package/dist/geoms/Jitter.svelte.d.ts +17 -0
  50. package/dist/geoms/Line.svelte +27 -31
  51. package/dist/geoms/Line.svelte.d.ts +5 -0
  52. package/dist/geoms/Point.svelte +31 -60
  53. package/dist/geoms/Point.svelte.d.ts +5 -1
  54. package/dist/geoms/Ribbon.svelte +30 -124
  55. package/dist/geoms/Ribbon.svelte.d.ts +2 -0
  56. package/dist/geoms/Rule.svelte +117 -0
  57. package/dist/geoms/Rule.svelte.d.ts +19 -0
  58. package/dist/geoms/Violin.svelte +36 -31
  59. package/dist/geoms/Violin.svelte.d.ts +10 -3
  60. package/dist/geoms/Waterfall.svelte +25 -79
  61. package/dist/geoms/Waterfall.svelte.d.ts +2 -0
  62. package/dist/geoms/lib/GeomState.svelte.d.ts +51 -0
  63. package/dist/geoms/lib/GeomState.svelte.js +79 -0
  64. package/dist/geoms/lib/aesthetics.d.ts +36 -0
  65. package/dist/geoms/lib/aesthetics.js +45 -0
  66. package/dist/geoms/lib/areas.d.ts +6 -2
  67. package/dist/geoms/lib/areas.js +20 -12
  68. package/dist/geoms/lib/bars.d.ts +18 -3
  69. package/dist/geoms/lib/bars.js +80 -101
  70. package/dist/geoms/lib/marks/arc.d.ts +24 -0
  71. package/dist/geoms/lib/marks/arc.js +28 -0
  72. package/dist/geoms/lib/marks/area.d.ts +23 -0
  73. package/dist/geoms/lib/marks/area.js +38 -0
  74. package/dist/geoms/lib/marks/bar.d.ts +16 -0
  75. package/dist/geoms/lib/marks/bar.js +43 -0
  76. package/dist/geoms/lib/marks/box.d.ts +14 -0
  77. package/dist/geoms/lib/marks/box.js +30 -0
  78. package/dist/geoms/lib/marks/candlestick.d.ts +27 -0
  79. package/dist/geoms/lib/marks/candlestick.js +52 -0
  80. package/dist/geoms/lib/marks/heatmap.d.ts +24 -0
  81. package/dist/geoms/lib/marks/heatmap.js +41 -0
  82. package/dist/geoms/lib/marks/hexbin.d.ts +29 -0
  83. package/dist/geoms/lib/marks/hexbin.js +62 -0
  84. package/dist/geoms/lib/marks/jitter.d.ts +21 -0
  85. package/dist/geoms/lib/marks/jitter.js +36 -0
  86. package/dist/geoms/lib/marks/line.d.ts +26 -0
  87. package/dist/geoms/lib/marks/line.js +25 -0
  88. package/dist/geoms/lib/marks/point.d.ts +25 -0
  89. package/dist/geoms/lib/marks/point.js +58 -0
  90. package/dist/geoms/lib/marks/ribbon.d.ts +31 -0
  91. package/dist/geoms/lib/marks/ribbon.js +77 -0
  92. package/dist/geoms/lib/marks/violin.d.ts +13 -0
  93. package/dist/geoms/lib/marks/violin.js +31 -0
  94. package/dist/geoms/lib/marks/waterfall.d.ts +25 -0
  95. package/dist/geoms/lib/marks/waterfall.js +58 -0
  96. package/dist/index.d.ts +25 -12
  97. package/dist/index.js +26 -13
  98. package/dist/lib/brewing/marks/arcs.js +7 -1
  99. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  100. package/dist/lib/brewing/marks/boxes.js +32 -11
  101. package/dist/lib/brewing/marks/lines.d.ts +4 -1
  102. package/dist/lib/brewing/marks/lines.js +18 -15
  103. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  104. package/dist/lib/brewing/marks/swarm.js +125 -0
  105. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  106. package/dist/lib/brewing/marks/violins.js +41 -25
  107. package/dist/lib/brewing/stats.d.ts +3 -1
  108. package/dist/lib/brewing/stats.js +28 -11
  109. package/dist/lib/plot/scales.js +12 -7
  110. package/dist/lib/plot/types.d.ts +9 -1
  111. package/dist/lib/plot/types.js +3 -1
  112. package/dist/lib/preset.d.ts +11 -1
  113. package/dist/lib/preset.js +16 -4
  114. package/package.json +1 -1
  115. package/dist/Plot/Arc.svelte +0 -30
  116. package/dist/Plot/Arc.svelte.d.ts +0 -18
  117. package/dist/Plot/Area.svelte +0 -54
  118. package/dist/Plot/Area.svelte.d.ts +0 -14
  119. package/dist/Plot/Bar.svelte +0 -69
  120. package/dist/Plot/Bar.svelte.d.ts +0 -11
  121. package/dist/Plot/Line.svelte +0 -54
  122. package/dist/Plot/Line.svelte.d.ts +0 -13
  123. package/dist/Plot/Point.svelte +0 -58
  124. package/dist/Plot/Point.svelte.d.ts +0 -13
  125. package/dist/lib/brewer.d.ts +0 -9
  126. package/dist/lib/brewer.js +0 -25
  127. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +0 -8
  128. package/dist/lib/brewing/BoxBrewer.svelte.js +0 -14
  129. package/dist/lib/brewing/CartesianBrewer.svelte.d.ts +0 -8
  130. package/dist/lib/brewing/CartesianBrewer.svelte.js +0 -21
  131. package/dist/lib/brewing/PieBrewer.svelte.d.ts +0 -8
  132. package/dist/lib/brewing/PieBrewer.svelte.js +0 -14
  133. package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +0 -9
  134. package/dist/lib/brewing/QuartileBrewer.svelte.js +0 -51
  135. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +0 -8
  136. package/dist/lib/brewing/ViolinBrewer.svelte.js +0 -14
  137. package/dist/lib/brewing/axes.svelte.d.ts +0 -66
  138. package/dist/lib/brewing/axes.svelte.js +0 -270
  139. package/dist/lib/brewing/bars.svelte.d.ts +0 -56
  140. package/dist/lib/brewing/bars.svelte.js +0 -201
  141. package/dist/lib/brewing/brewer.svelte.d.ts +0 -124
  142. package/dist/lib/brewing/brewer.svelte.js +0 -277
  143. package/dist/lib/brewing/dimensions.svelte.d.ts +0 -35
  144. package/dist/lib/brewing/dimensions.svelte.js +0 -56
  145. package/dist/lib/brewing/index.svelte.d.ts +0 -118
  146. package/dist/lib/brewing/index.svelte.js +0 -205
  147. package/dist/lib/brewing/legends.svelte.d.ts +0 -48
  148. package/dist/lib/brewing/legends.svelte.js +0 -137
  149. package/dist/lib/brewing/marks/areas.d.ts +0 -31
  150. package/dist/lib/brewing/marks/areas.js +0 -72
  151. package/dist/lib/brewing/marks/bars.d.ts +0 -1
  152. package/dist/lib/brewing/marks/bars.js +0 -49
  153. package/dist/lib/brewing/scales.svelte.d.ts +0 -24
  154. package/dist/lib/brewing/scales.svelte.js +0 -82
  155. package/dist/lib/context.d.ts +0 -13
  156. package/dist/lib/context.js +0 -131
  157. package/dist/lib/plot/chartProps.d.ts +0 -177
  158. package/dist/lib/plot/chartProps.js +0 -76
  159. package/dist/lib/scales.svelte.d.ts +0 -35
  160. package/dist/lib/scales.svelte.js +0 -151
  161. package/dist/lib/ticks.d.ts +0 -36
  162. package/dist/lib/ticks.js +0 -46
@@ -17,6 +17,9 @@ import { assignSymbols } from './lib/brewing/marks/points.js'
17
17
 
18
18
  let nextId = 0
19
19
 
20
+ // Geoms that treat the x channel as categorical (band scale), even for numeric x.
21
+ const CATEGORICAL_X = new Set(['bar', 'box', 'violin', 'jitter'])
22
+
20
23
  export class PlotState {
21
24
  #data = $state([])
22
25
  #rawData = $state([])
@@ -43,6 +46,13 @@ export class PlotState {
43
46
  #onselect = $state(undefined)
44
47
  #selectable = $state(false)
45
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)
46
56
 
47
57
  axisOrigin = $state([undefined, undefined])
48
58
  #axisOffset = $state(0)
@@ -53,29 +63,78 @@ export class PlotState {
53
63
  #innerWidth = $derived(this.#width - this.#effectiveMargin.left - this.#effectiveMargin.right)
54
64
  #innerHeight = $derived(this.#height - this.#effectiveMargin.top - this.#effectiveMargin.bottom)
55
65
 
56
- // Effective channels: prefer top-level channels; fall back to first geom's channels
57
- // for the declarative API where no spec is provided.
66
+ // Effective channels: prefer top-level channels PER FIELD, falling back to the
67
+ // first geom's channels. Merged per-field (not all-or-nothing) so a composable
68
+ // <Plot.Root x y> that omits `color` still picks up a geom's color channel
69
+ // (e.g. <Plot.Box color=x>) — otherwise `colors` has no per-category entries and
70
+ // every mark falls back to gray.
58
71
  #mergeGeomChannels(tc, geom) {
59
72
  return {
60
73
  x: tc.x ?? geom.channels?.x,
61
74
  y: tc.y ?? geom.channels?.y,
62
75
  color: tc.color ?? geom.channels?.color,
76
+ fill: tc.fill ?? geom.channels?.fill,
63
77
  pattern: tc.pattern ?? geom.channels?.pattern,
64
78
  symbol: tc.symbol ?? geom.channels?.symbol
65
79
  }
66
80
  }
67
81
 
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
+ const seen = new Set()
98
+ const values = []
99
+ for (const f of fields) {
100
+ for (const v of distinct(this.#data, f)) {
101
+ if (!seen.has(v)) {
102
+ seen.add(v)
103
+ values.push(v)
104
+ }
105
+ }
106
+ }
107
+ return values
108
+ }
109
+
68
110
  #effectiveChannels = $derived.by(() => {
69
111
  const tc = this.#channels
70
- if (tc.x && tc.y) return tc
71
112
  const firstGeom = this.#geoms[0]
72
113
  if (!firstGeom) return tc
73
114
  return this.#mergeGeomChannels(tc, firstGeom)
74
115
  })
75
116
 
76
117
  #resolveXType(rawXType, yType) {
77
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
78
- return hasBarGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
118
+ const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
119
+ return hasBandGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
120
+ }
121
+
122
+ // Category order sorted by aggregated value (sum of the value channel per category).
123
+ // Drives "sort bars by size" for the band axis + its ticks. Returns null when not sorting.
124
+ #sortedBandDomain(datasets) {
125
+ if (!this.#sort) return null
126
+ const xf = this.#effectiveChannels.x
127
+ const yf = this.#effectiveChannels.y
128
+ if (!xf || !yf) return null
129
+ const totals = new Map()
130
+ for (const ds of datasets) {
131
+ for (const d of ds) {
132
+ const key = d[xf]
133
+ totals.set(key, (totals.get(key) ?? 0) + (Number(d[yf]) || 0))
134
+ }
135
+ }
136
+ const dir = this.#sort === 'asc' ? 1 : -1
137
+ return [...totals.keys()].sort((a, b) => dir * ((totals.get(a) ?? 0) - (totals.get(b) ?? 0)))
79
138
  }
80
139
 
81
140
  orientation = $derived.by(() => {
@@ -89,6 +148,24 @@ export class PlotState {
89
148
  return inferOrientation(this.#resolveXType(rawXType, yType), yType)
90
149
  })
91
150
 
151
+ // True when the x-channel is the categorical (band) axis — a string category, or a numeric
152
+ // category a bar/box/violin/jitter geom bands (incl. a race's rank via continuousCategory).
153
+ // Only such charts flip; a plain scatter (both continuous, no banding geom) is false → the
154
+ // orientation is a no-op for the scales.
155
+ #bandIsX = $derived.by(() => {
156
+ const x = this.#effectiveChannels.x
157
+ const y = this.#effectiveChannels.y
158
+ if (!x || !y) return false
159
+ // Resolved type: a bar/box/violin/jitter geom bands a numeric x (e.g. year, week),
160
+ // so those NUMERIC categories are flippable too — not just string categories.
161
+ return this.#resolveXType(inferFieldType(this.#data, x), inferFieldType(this.#data, y)) === 'band'
162
+ })
163
+
164
+ // Flip = render a category-on-x chart horizontally: the category (x) axis stands up on the
165
+ // vertical screen and the value (y) axis runs along the horizontal screen. x/y channels are
166
+ // unchanged — only the screen mapping rotates.
167
+ #flipped = $derived(this.orientation === 'horizontal' && this.#bandIsX)
168
+
92
169
  colorScaleType = $derived.by(() => {
93
170
  const field = this.#effectiveChannels.color
94
171
  if (!field) return 'categorical'
@@ -118,15 +195,30 @@ export class PlotState {
118
195
  if (!field) return null
119
196
  const datasets =
120
197
  this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
121
- const includeZero = this.orientation === 'horizontal'
122
- // For vertical bar charts, force scaleBand even when X values are numeric (e.g. year).
123
- // Horizontal bar charts keep X as a continuous value axis.
124
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
125
- const bandX = hasBarGeom && this.orientation !== 'horizontal'
198
+ // includeZero applies to the VALUE axis. When flipped, x is the category axis, not
199
+ // the value axis, so it must NOT include zero.
200
+ const includeZero = this.orientation === 'horizontal' && !this.#flipped
201
+ // Force scaleBand when x is the categorical axis (incl. flipped, where numeric categories
202
+ // must stay a band). Exception: a CONTINUOUS category axis (a bar race's tweened rank) is
203
+ // kept LINEAR so fractional positions tween smoothly — buildBars positions it directly.
204
+ const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
205
+ const bandX =
206
+ hasBandGeom &&
207
+ !this.#continuousCategory &&
208
+ (this.orientation !== 'horizontal' || this.#flipped)
209
+ // Flip: the category (x) axis stands up on the vertical screen → range over height.
210
+ const range = this.#flipped ? [this.#innerHeight, 0] : undefined
211
+ // Sort the band domain by aggregated value when requested (bars by size); an explicit
212
+ // xDomain override wins.
213
+ const domain = this.#xDomain ?? (bandX ? this.#sortedBandDomain(datasets) : null) ?? undefined
126
214
  const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
127
- domain: this.#xDomain,
215
+ domain,
128
216
  includeZero,
129
- band: bandX
217
+ band: bandX,
218
+ range,
219
+ // A continuous category axis (bar-race rank) uses a deliberately padded domain —
220
+ // don't nice() it back to whole numbers.
221
+ nice: !this.#continuousCategory
130
222
  })
131
223
  return this.#zoomTransform && typeof base?.bandwidth !== 'function'
132
224
  ? this.#zoomTransform.rescaleX(base)
@@ -139,15 +231,20 @@ export class PlotState {
139
231
  if (!boxGeom) return null
140
232
  const boxData = this.geomData(boxGeom.id)
141
233
  const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
142
- const mins = boxData.map((d) => d.iqr_min).filter(isValid)
143
- const maxs = boxData.map((d) => d.iqr_max).filter(isValid)
234
+ const flatOutliers = boxData.flatMap((d) => d.outliers ?? []).filter(isValid)
235
+ const mins = [...boxData.map((d) => d.iqr_min).filter(isValid), ...flatOutliers]
236
+ const maxs = [...boxData.map((d) => d.iqr_max).filter(isValid), ...flatOutliers]
144
237
  return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
145
238
  }
146
239
 
147
240
  // For stacked bars, compute y domain from per-x column totals.
148
241
  #resolveStackDomain(field) {
149
- const stackGeom = this.#geoms.find((g) => g.options?.stack)
242
+ const stackGeom = this.#geoms.find(
243
+ (g) => g.options?.stack || g.options?.position === 'stack' || g.options?.position === 'fill'
244
+ )
150
245
  if (!stackGeom) return null
246
+ // position='fill' normalizes each column to 100% → the value domain is [0,1].
247
+ if (stackGeom.options?.position === 'fill') return [0, 1]
151
248
  const xField = this.#effectiveChannels.x
152
249
  const stackData = this.geomData(stackGeom.id)
153
250
  if (!xField || stackData.length === 0) return null
@@ -157,8 +254,13 @@ export class PlotState {
157
254
  const colorField = isLiteralColor(this.#effectiveChannels.color)
158
255
  ? null
159
256
  : this.#effectiveChannels.color
257
+ const fillField = isLiteralColor(this.#effectiveChannels.fill)
258
+ ? null
259
+ : this.#effectiveChannels.fill
160
260
  const patternField = this.#effectiveChannels.pattern
161
- const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
261
+ // Mirror buildStackedBars/subBandFields (group=fill first, then color, then pattern).
262
+ const stackField =
263
+ [fillField, colorField, patternField].find((f) => f && f !== xField) ?? (fillField ?? colorField)
162
264
  const lookup = new SvelteMap()
163
265
  for (const d of stackData) {
164
266
  const xVal = d[xField]
@@ -181,9 +283,13 @@ export class PlotState {
181
283
  if (!field) return null
182
284
  const datasets =
183
285
  this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
184
- const includeZero = this.orientation === 'vertical'
286
+ // includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
287
+ // charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
288
+ const includeZero = this.orientation === 'vertical' || this.#flipped
185
289
  const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
186
- const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
290
+ // Flip: the value (y) axis runs along the horizontal screen → range over width.
291
+ const range = this.#flipped ? [0, this.#innerWidth] : undefined
292
+ const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
187
293
  return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
188
294
  })
189
295
 
@@ -200,7 +306,7 @@ export class PlotState {
200
306
  const literal = new Map([[null, { fill: field, stroke: field }]])
201
307
  return literal
202
308
  }
203
- const values = this.#colorDomain ?? distinct(this.#data, field)
309
+ const values = this.#colorDomain ?? this.#sharedColorValues()
204
310
  // No color channel but data exists → use first preset color for single-series rendering.
205
311
  // This prevents geoms from falling back to gray (#888) on charts with no fill channel.
206
312
  if (values.length === 0 && this.#data.length > 0) return assignColors([null], this.#mode, this.#chartPreset)
@@ -228,6 +334,10 @@ export class PlotState {
228
334
  colorField = $derived(
229
335
  isLiteralColor(this.#effectiveChannels.color) ? null : this.#effectiveChannels.color
230
336
  )
337
+ // Fill field (interior aesthetic), parallel to colorField. Null for a literal CSS color.
338
+ fillField = $derived(
339
+ isLiteralColor(this.#effectiveChannels.fill) ? null : this.#effectiveChannels.fill
340
+ )
231
341
  patternField = $derived(this.#effectiveChannels.pattern)
232
342
  symbolField = $derived(this.#effectiveChannels.symbol)
233
343
 
@@ -283,6 +393,8 @@ export class PlotState {
283
393
  this.#axisOffset = config.axisOffset ?? 0
284
394
  this.#marginOverride = config.margin ?? undefined
285
395
  this.#orientationOverride = config.orientation ?? undefined
396
+ this.#continuousCategory = config.continuousCategory ?? false
397
+ this.#sort = config.sort ?? undefined
286
398
  }
287
399
 
288
400
  update(config) {
@@ -309,6 +421,8 @@ export class PlotState {
309
421
  if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
310
422
  this.#marginOverride = config.margin ?? undefined
311
423
  this.#orientationOverride = config.orientation ?? undefined
424
+ this.#continuousCategory = config.continuousCategory ?? false
425
+ this.#sort = config.sort ?? undefined
312
426
  }
313
427
 
314
428
  registerGeom(config) {
@@ -360,6 +474,36 @@ export class PlotState {
360
474
  // array separately, so their elements are not ===. Same content, aligned identity.
361
475
  return this.#rawData
362
476
  }
477
+ /** @returns {{ x?: string, y?: string, color?: string, fill?: string, pattern?: string, symbol?: string }} */
478
+ get channels() {
479
+ return this.#channels
480
+ }
481
+
482
+ // ─── Orientation helpers (horizontal / axis-flip) ──────────────────────────
483
+ // True when the chart is rendered horizontally (category axis stood up on the
484
+ // vertical screen, value axis along the horizontal screen). x/y channels unchanged.
485
+ get isFlipped() {
486
+ return this.#flipped
487
+ }
488
+ // True when the category (x) axis is a continuous position scale (kept linear) rather than a
489
+ // band — a bar-chart race's tweened rank. buildBars uses continuous positioning for it.
490
+ get continuousCategory() {
491
+ return this.#continuousCategory
492
+ }
493
+ // Map abstract (x-channel, y-channel) scale outputs to screen coords. When flipped,
494
+ // the two screen axes swap. Geoms compute u = xScale(d[x]), v = yScale(d[y]) then
495
+ // `const { x, y } = plotState.place(u, v)`.
496
+ place(u, v) {
497
+ return this.#flipped ? { x: v, y: u } : { x: u, y: v }
498
+ }
499
+ // The categorical (band) scale and the continuous (value) scale, regardless of
500
+ // orientation — their ranges are already oriented to the correct screen axis.
501
+ get bandScale() {
502
+ return this.#bandIsX ? this.xScale : this.yScale
503
+ }
504
+ get valueScale() {
505
+ return this.#bandIsX ? this.yScale : this.xScale
506
+ }
363
507
  get margin() {
364
508
  return this.#effectiveMargin
365
509
  }
@@ -0,0 +1,162 @@
1
+ <script lang="ts">
2
+ import { setContext, untrack } from 'svelte'
3
+ import type { Snippet } from 'svelte'
4
+ import { PlotState } from './PlotState.svelte.js'
5
+ import DefinePatterns from './patterns/DefinePatterns.svelte'
6
+
7
+ type Row = Record<string, unknown>
8
+
9
+ type Props = {
10
+ /** Full PlotState config. `config.width` is the initial/fallback width — PlotSurface
11
+ * owns the responsive width via a ResizeObserver and overrides it. */
12
+ config?: Record<string, unknown>
13
+ /** Animate marks on data/flip changes (data-plot-animate). Enabled one frame after the
14
+ * width settles so the initial layout paints un-animated. */
15
+ animate?: boolean
16
+ /** Enable d3-zoom pan/zoom on the svg. */
17
+ zoom?: boolean
18
+ ariaLabel?: string
19
+ /** SVG <desc> for screen readers. */
20
+ summary?: string
21
+ /** Two-way selection (row references), synced with the PlotState selection. */
22
+ selected?: Row[]
23
+ /** Canvas content (geoms/grid/axes) — rendered inside the svg. */
24
+ children?: Snippet
25
+ /** HTML overlays (legend/tooltip/a11y table) — rendered outside the svg but INSIDE the
26
+ * plot-state context, so they can read plotState. */
27
+ overlay?: Snippet
28
+ }
29
+
30
+ let {
31
+ config = {},
32
+ animate = true,
33
+ zoom = false,
34
+ ariaLabel = 'Chart visualization',
35
+ summary = '',
36
+ selected = $bindable([]),
37
+ children,
38
+ overlay
39
+ }: Props = $props()
40
+
41
+ // ── Responsive width: observe the container, use its pixel width so the chart fills the stage.
42
+ let containerEl = $state<HTMLDivElement | null>(null)
43
+ let svgEl = $state<SVGSVGElement | null>(null)
44
+ let observedWidth = $state(0)
45
+ $effect(() => {
46
+ if (!containerEl) return
47
+ const ro = new ResizeObserver((entries) => {
48
+ const w = Math.floor(entries[0].contentRect.width)
49
+ if (w > 0) observedWidth = w
50
+ })
51
+ ro.observe(containerEl)
52
+ return () => ro.disconnect()
53
+ })
54
+ const fallbackWidth = $derived(Number(config.width) || 600)
55
+ const effectiveWidth = $derived(observedWidth > 0 ? observedWidth : fallbackWidth)
56
+ const svgHeight = $derived(Number(config.height) || 400)
57
+ const mode = $derived(config.mode as 'light' | 'dark' | undefined)
58
+
59
+ // ── The single PlotState + context. config.width is overridden with the observed width.
60
+ const fullConfig = () => ({ ...config, width: effectiveWidth })
61
+ const plotState = untrack(() => new PlotState(fullConfig()))
62
+ setContext('plot-state', plotState)
63
+ $effect(() => {
64
+ plotState.update(fullConfig())
65
+ })
66
+
67
+ // ── Selection sync (external prop ↔ PlotState). Guarded to avoid an update loop.
68
+ $effect(() => {
69
+ plotState.setSelected(selected)
70
+ })
71
+ $effect(() => {
72
+ const rows = plotState.selectedRows
73
+ const same = rows.length === selected.length && rows.every((r, i) => r === selected[i])
74
+ if (!same) selected = rows
75
+ })
76
+
77
+ // ── Enable data-change / flip animation one frame after the width first settles, so the
78
+ // initial (possibly reflowed) layout paints without animating.
79
+ let animateReady = $state(false)
80
+ $effect(() => {
81
+ if (!animate || animateReady || observedWidth <= 0) return
82
+ const raf = requestAnimationFrame(() => {
83
+ animateReady = true
84
+ })
85
+ return () => cancelAnimationFrame(raf)
86
+ })
87
+
88
+ // ── Pan/zoom (d3-zoom/d3-selection are client-only → dynamic import).
89
+ $effect(() => {
90
+ if (!zoom || !svgEl) return
91
+ let cancelled = false
92
+ let detach: (() => void) | undefined
93
+ Promise.all([import('d3-zoom'), import('d3-selection')]).then(
94
+ ([{ zoom: d3Zoom }, { select }]) => {
95
+ if (cancelled || !svgEl) return
96
+ const zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
97
+ .scaleExtent([1, 8])
98
+ .on('zoom', (event) => plotState.applyZoom(event.transform))
99
+ const sel = select(svgEl)
100
+ sel.call(zoomBehavior)
101
+ detach = () => sel.on('.zoom', null)
102
+ }
103
+ )
104
+ return () => {
105
+ cancelled = true
106
+ detach?.()
107
+ plotState.resetZoom()
108
+ }
109
+ })
110
+ </script>
111
+
112
+ <div class="plot-surface" bind:this={containerEl}>
113
+ <svg
114
+ bind:this={svgEl}
115
+ width={effectiveWidth}
116
+ height={svgHeight}
117
+ viewBox="0 0 {effectiveWidth} {svgHeight}"
118
+ role="img"
119
+ aria-label={ariaLabel}
120
+ data-plot-root
121
+ data-mode={mode}
122
+ data-plot-animate={animate && animateReady ? '' : undefined}
123
+ style:cursor={zoom ? 'grab' : undefined}
124
+ >
125
+ {#if summary}
126
+ <desc>{summary}</desc>
127
+ {/if}
128
+ <!-- SVG <pattern> defs for texture fills (Bar/Area/Arc/Box/Violin `pattern` channel) -->
129
+ <DefinePatterns />
130
+ <g
131
+ class="plot-canvas"
132
+ transform="translate({plotState.margin.left}, {plotState.margin.top})"
133
+ data-plot-canvas
134
+ >
135
+ {@render children?.()}
136
+ </g>
137
+ </svg>
138
+ <!-- HTML overlays (legend/tooltip/a11y table) — inside the context, outside the svg. -->
139
+ {@render overlay?.()}
140
+ </div>
141
+
142
+ <style>
143
+ .plot-surface {
144
+ width: 100%;
145
+ }
146
+ .plot-surface svg {
147
+ display: block;
148
+ width: 100%;
149
+ height: auto;
150
+ overflow: visible;
151
+ }
152
+ .plot-canvas {
153
+ pointer-events: all;
154
+ }
155
+ /* Mode token-lock colour (only when data-mode is set explicitly). */
156
+ [data-plot-root][data-mode='light'] {
157
+ color: rgba(var(--color-surface-700, 59, 65, 77), 1);
158
+ }
159
+ [data-plot-root][data-mode='dark'] {
160
+ color: rgba(var(--color-surface-200, 224, 224, 224), 1);
161
+ }
162
+ </style>
@@ -0,0 +1,25 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Row = Record<string, unknown>;
3
+ type Props = {
4
+ /** Full PlotState config. `config.width` is the initial/fallback width — PlotSurface
5
+ * owns the responsive width via a ResizeObserver and overrides it. */
6
+ config?: Record<string, unknown>;
7
+ /** Animate marks on data/flip changes (data-plot-animate). Enabled one frame after the
8
+ * width settles so the initial layout paints un-animated. */
9
+ animate?: boolean;
10
+ /** Enable d3-zoom pan/zoom on the svg. */
11
+ zoom?: boolean;
12
+ ariaLabel?: string;
13
+ /** SVG <desc> for screen readers. */
14
+ summary?: string;
15
+ /** Two-way selection (row references), synced with the PlotState selection. */
16
+ selected?: Row[];
17
+ /** Canvas content (geoms/grid/axes) — rendered inside the svg. */
18
+ children?: Snippet;
19
+ /** HTML overlays (legend/tooltip/a11y table) — rendered outside the svg but INSIDE the
20
+ * plot-state context, so they can read plotState. */
21
+ overlay?: Snippet;
22
+ };
23
+ declare const PlotSurface: import("svelte").Component<Props, {}, "selected">;
24
+ type PlotSurface = ReturnType<typeof PlotSurface>;
25
+ export default PlotSurface;
@@ -14,12 +14,18 @@
14
14
  stat?: string
15
15
  curve?: 'linear' | 'smooth' | 'step'
16
16
  pattern?: string
17
+ /** @deprecated use `position="stack"`. */
17
18
  stack?: boolean
19
+ /** Multi-series arrangement: 'stack' | 'fill' | 'identity' (default). */
20
+ position?: 'stack' | 'fill' | 'identity'
21
+ /** Fixed area opacity 0–1. */
22
+ alpha?: number
18
23
  width?: number
19
24
  height?: number
20
25
  mode?: 'light' | 'dark'
21
26
  grid?: boolean | 'x' | 'y' | 'both'
22
27
  legend?: boolean
28
+ tooltip?: boolean | ((data: Row) => string)
23
29
  highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean)
24
30
  trend?: Method | Method[]
25
31
  xFormat?: Format
@@ -41,11 +47,14 @@
41
47
  curve = undefined,
42
48
  pattern = undefined,
43
49
  stack = false,
50
+ position = undefined,
51
+ alpha = undefined,
44
52
  width = 600,
45
53
  height = 400,
46
- mode = 'light',
54
+ mode = undefined,
47
55
  grid = true,
48
56
  legend = false,
57
+ tooltip = false,
49
58
  highlight = undefined,
50
59
  trend = undefined,
51
60
  xFormat = undefined,
@@ -68,6 +77,7 @@
68
77
  {mode}
69
78
  {grid}
70
79
  {legend}
80
+ {tooltip}
71
81
  {highlight}
72
82
  {trend}
73
83
  {xFormat}
@@ -79,5 +89,5 @@
79
89
  {selectable}
80
90
  bind:selected
81
91
  >
82
- <Area {x} {y} color={fill} {pattern} {stat} options={{ curve, stack }} />
92
+ <Area {x} {y} color={fill} {pattern} {stat} {position} {alpha} options={{ curve, stack }} />
83
93
  </Plot>
@@ -12,12 +12,18 @@ type Props = {
12
12
  stat?: string;
13
13
  curve?: 'linear' | 'smooth' | 'step';
14
14
  pattern?: string;
15
+ /** @deprecated use `position="stack"`. */
15
16
  stack?: boolean;
17
+ /** Multi-series arrangement: 'stack' | 'fill' | 'identity' (default). */
18
+ position?: 'stack' | 'fill' | 'identity';
19
+ /** Fixed area opacity 0–1. */
20
+ alpha?: number;
16
21
  width?: number;
17
22
  height?: number;
18
23
  mode?: 'light' | 'dark';
19
24
  grid?: boolean | 'x' | 'y' | 'both';
20
25
  legend?: boolean;
26
+ tooltip?: boolean | ((data: Row) => string);
21
27
  highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean);
22
28
  trend?: Method | Method[];
23
29
  xFormat?: Format;
@@ -4,6 +4,7 @@
4
4
 
5
5
  type Row = Record<string, unknown>
6
6
  type Format = (v: unknown) => string
7
+ type Method = string | number | { type: string; [k: string]: unknown }
7
8
 
8
9
  type Props = {
9
10
  data?: Row[]
@@ -17,14 +18,24 @@
17
18
  grid?: boolean
18
19
  legend?: boolean
19
20
  stat?: string
21
+ /** @deprecated use `position="stack"`. */
20
22
  stack?: boolean
23
+ /** Multi-series arrangement: 'stack' | 'dodge' (default) | 'fill' | 'identity'. */
24
+ position?: 'stack' | 'dodge' | 'fill' | 'identity'
25
+ /** Fixed bar opacity 0–1. */
26
+ alpha?: number
21
27
  label?: boolean | string | ((data: Row) => unknown)
22
28
  tooltip?: boolean | ((data: Row) => string)
29
+ highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean)
30
+ trend?: Method | Method[]
23
31
  xFormat?: Format
24
32
  yFormat?: Format
25
33
  xTicks?: number
26
34
  yTicks?: number
27
35
  minorTicks?: boolean
36
+ onselect?: (detail: unknown) => void
37
+ selectable?: boolean
38
+ selected?: Row[]
28
39
  }
29
40
 
30
41
  let {
@@ -35,21 +46,46 @@
35
46
  pattern = undefined,
36
47
  width = 600,
37
48
  height = 400,
38
- mode = 'light',
49
+ mode = undefined,
39
50
  grid = true,
40
51
  legend = false,
41
52
  stat = 'identity',
42
53
  stack = false,
54
+ position = undefined,
55
+ alpha = undefined,
43
56
  label = false,
44
57
  tooltip = false,
58
+ highlight = undefined,
59
+ trend = undefined,
45
60
  xFormat = undefined,
46
61
  yFormat = undefined,
47
62
  xTicks = undefined,
48
63
  yTicks = undefined,
49
- minorTicks = false
64
+ minorTicks = false,
65
+ onselect = undefined,
66
+ selectable = false,
67
+ selected = $bindable([])
50
68
  }: Props = $props()
51
69
  </script>
52
70
 
53
- <Plot {data} {width} {height} {mode} {grid} {legend} {tooltip} {xFormat} {yFormat} {xTicks} {yTicks} {minorTicks}>
54
- <Bar {x} {y} color={fill} {pattern} {label} {stat} options={{ stack }} />
71
+ <Plot
72
+ {data}
73
+ {width}
74
+ {height}
75
+ {mode}
76
+ {grid}
77
+ {legend}
78
+ {tooltip}
79
+ {highlight}
80
+ {trend}
81
+ {xFormat}
82
+ {yFormat}
83
+ {xTicks}
84
+ {yTicks}
85
+ {minorTicks}
86
+ {onselect}
87
+ {selectable}
88
+ bind:selected
89
+ >
90
+ <Bar {x} {y} color={fill} {pattern} {label} {stat} {position} {alpha} options={{ stack }} />
55
91
  </Plot>
@@ -1,5 +1,9 @@
1
1
  type Row = Record<string, unknown>;
2
2
  type Format = (v: unknown) => string;
3
+ type Method = string | number | {
4
+ type: string;
5
+ [k: string]: unknown;
6
+ };
3
7
  type Props = {
4
8
  data?: Row[];
5
9
  x?: string;
@@ -12,15 +16,25 @@ type Props = {
12
16
  grid?: boolean;
13
17
  legend?: boolean;
14
18
  stat?: string;
19
+ /** @deprecated use `position="stack"`. */
15
20
  stack?: boolean;
21
+ /** Multi-series arrangement: 'stack' | 'dodge' (default) | 'fill' | 'identity'. */
22
+ position?: 'stack' | 'dodge' | 'fill' | 'identity';
23
+ /** Fixed bar opacity 0–1. */
24
+ alpha?: number;
16
25
  label?: boolean | string | ((data: Row) => unknown);
17
26
  tooltip?: boolean | ((data: Row) => string);
27
+ highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean);
28
+ trend?: Method | Method[];
18
29
  xFormat?: Format;
19
30
  yFormat?: Format;
20
31
  xTicks?: number;
21
32
  yTicks?: number;
22
33
  minorTicks?: boolean;
34
+ onselect?: (detail: unknown) => void;
35
+ selectable?: boolean;
36
+ selected?: Row[];
23
37
  };
24
- declare const BarChart: import("svelte").Component<Props, {}, "">;
38
+ declare const BarChart: import("svelte").Component<Props, {}, "selected">;
25
39
  type BarChart = ReturnType<typeof BarChart>;
26
40
  export default BarChart;