@rokkit/chart 1.3.7 → 1.3.8

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 (76) hide show
  1. package/dist/AnimatedPlot.svelte +26 -8
  2. package/dist/AnimatedPlot.svelte.d.ts +5 -0
  3. package/dist/Chart.svelte +1 -1
  4. package/dist/FacetPlot.svelte +1 -1
  5. package/dist/Plot/Axis.svelte +19 -10
  6. package/dist/Plot/Root.svelte +64 -46
  7. package/dist/Plot/Root.svelte.d.ts +27 -2
  8. package/dist/Plot/Timeline.svelte +39 -13
  9. package/dist/Plot/index.d.ts +4 -4
  10. package/dist/Plot/index.js +4 -4
  11. package/dist/Plot.svelte +95 -178
  12. package/dist/Plot.svelte.d.ts +4 -0
  13. package/dist/PlotState.svelte.d.ts +16 -0
  14. package/dist/PlotState.svelte.js +118 -16
  15. package/dist/PlotSurface.svelte +162 -0
  16. package/dist/PlotSurface.svelte.d.ts +25 -0
  17. package/dist/charts/AreaChart.svelte +4 -1
  18. package/dist/charts/AreaChart.svelte.d.ts +1 -0
  19. package/dist/charts/BarChart.svelte +32 -3
  20. package/dist/charts/BarChart.svelte.d.ts +10 -1
  21. package/dist/charts/BoxPlot.svelte +4 -2
  22. package/dist/charts/BoxPlot.svelte.d.ts +1 -0
  23. package/dist/charts/BubbleChart.svelte +34 -3
  24. package/dist/charts/BubbleChart.svelte.d.ts +11 -1
  25. package/dist/charts/LineChart.svelte +1 -1
  26. package/dist/charts/PieChart.svelte +1 -1
  27. package/dist/charts/ScatterPlot.svelte +32 -3
  28. package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
  29. package/dist/charts/ViolinPlot.svelte +4 -2
  30. package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
  31. package/dist/examples/BarChartExample.svelte +1 -1
  32. package/dist/geoms/Arc.svelte +3 -2
  33. package/dist/geoms/Area.svelte +2 -2
  34. package/dist/geoms/Bar.svelte +17 -6
  35. package/dist/geoms/Box.svelte +131 -76
  36. package/dist/geoms/Box.svelte.d.ts +7 -0
  37. package/dist/geoms/Candlestick.svelte +17 -11
  38. package/dist/geoms/Jitter.svelte +82 -0
  39. package/dist/geoms/Jitter.svelte.d.ts +15 -0
  40. package/dist/geoms/Line.svelte +1 -1
  41. package/dist/geoms/Point.svelte +7 -1
  42. package/dist/geoms/Rule.svelte +113 -0
  43. package/dist/geoms/Rule.svelte.d.ts +16 -0
  44. package/dist/geoms/Violin.svelte +30 -6
  45. package/dist/geoms/Violin.svelte.d.ts +5 -0
  46. package/dist/geoms/Waterfall.svelte +16 -9
  47. package/dist/geoms/lib/areas.d.ts +6 -2
  48. package/dist/geoms/lib/areas.js +17 -11
  49. package/dist/geoms/lib/bars.d.ts +18 -3
  50. package/dist/geoms/lib/bars.js +70 -96
  51. package/dist/index.d.ts +24 -4
  52. package/dist/index.js +25 -5
  53. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
  54. package/dist/lib/brewing/marks/arcs.js +7 -1
  55. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  56. package/dist/lib/brewing/marks/boxes.js +32 -11
  57. package/dist/lib/brewing/marks/lines.d.ts +4 -1
  58. package/dist/lib/brewing/marks/lines.js +18 -15
  59. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  60. package/dist/lib/brewing/marks/swarm.js +125 -0
  61. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  62. package/dist/lib/brewing/marks/violins.js +41 -25
  63. package/dist/lib/brewing/stats.d.ts +3 -1
  64. package/dist/lib/brewing/stats.js +28 -11
  65. package/dist/lib/plot/scales.js +12 -7
  66. package/dist/lib/plot/types.d.ts +9 -1
  67. package/dist/lib/plot/types.js +3 -1
  68. package/package.json +1 -1
  69. package/dist/Plot/Area.svelte +0 -54
  70. package/dist/Plot/Area.svelte.d.ts +0 -14
  71. package/dist/Plot/Bar.svelte +0 -69
  72. package/dist/Plot/Bar.svelte.d.ts +0 -11
  73. package/dist/Plot/Line.svelte +0 -54
  74. package/dist/Plot/Line.svelte.d.ts +0 -13
  75. package/dist/Plot/Point.svelte +0 -58
  76. package/dist/Plot/Point.svelte.d.ts +0 -13
package/dist/Plot.svelte CHANGED
@@ -1,14 +1,13 @@
1
1
  <script lang="ts">
2
- import { setContext, getContext, untrack } from 'svelte'
2
+ import { getContext } from 'svelte'
3
3
  import type { Snippet } from 'svelte'
4
- import { PlotState } from './PlotState.svelte.js'
5
4
  import { defaultPreset } from './lib/preset.js'
6
5
  import type { PlotSpec, PlotHelpers } from './lib/plot/types.js'
6
+ import PlotSurface from './PlotSurface.svelte'
7
7
  import Axis from './Plot/Axis.svelte'
8
8
  import Grid from './Plot/Grid.svelte'
9
9
  import Legend from './Plot/Legend.svelte'
10
10
  import Tooltip from './Plot/Tooltip.svelte'
11
- import DefinePatterns from './patterns/DefinePatterns.svelte'
12
11
  import Bar from './geoms/Bar.svelte'
13
12
  import Line from './geoms/Line.svelte'
14
13
  import Area from './geoms/Area.svelte'
@@ -57,6 +56,10 @@
57
56
  onselect?: (detail: unknown) => void
58
57
  selectable?: boolean
59
58
  selected?: Row[]
59
+ /** Animate marks on data/flip changes (opt-out for e.g. AnimatedPlot, which
60
+ * tweens its own frames). Enabled one frame after the width settles so the
61
+ * initial layout paints un-animated. Default `true`. */
62
+ animate?: boolean
60
63
  children?: Snippet
61
64
  }
62
65
 
@@ -66,7 +69,7 @@
66
69
  helpers = {},
67
70
  width = 600,
68
71
  height = 400,
69
- mode = 'light',
72
+ mode = undefined,
70
73
  grid = true,
71
74
  axes = true,
72
75
  margin = undefined,
@@ -88,29 +91,13 @@
88
91
  onselect = undefined,
89
92
  selectable = false,
90
93
  selected = $bindable([]),
94
+ animate = true,
91
95
  children
92
96
  }: Props = $props()
93
97
 
94
98
  const chartPresetCtx = getContext<ChartPresetCtx | undefined>('chart-preset')
95
99
  const chartPreset = $derived(chartPresetCtx?.current ?? defaultPreset)
96
100
 
97
- // Responsive width: observe container and use actual pixel width for scale calculations
98
- let svgEl = $state<SVGSVGElement | null>(null)
99
- let containerEl = $state<HTMLDivElement | null>(null)
100
- let observedWidth = $state(0)
101
-
102
- $effect(() => {
103
- if (!containerEl) return
104
- const ro = new ResizeObserver((entries) => {
105
- const w = Math.floor(entries[0].contentRect.width)
106
- if (w > 0) observedWidth = w
107
- })
108
- ro.observe(containerEl)
109
- return () => ro.disconnect()
110
- })
111
-
112
- const effectiveWidth = $derived(observedWidth > 0 ? observedWidth : (spec?.width ?? width))
113
- const svgHeight = $derived(spec?.height ?? height)
114
101
  const gridValue = $derived(spec?.grid ?? grid)
115
102
  const showGrid = $derived(gridValue !== false)
116
103
  const gridLines = $derived(
@@ -123,18 +110,22 @@
123
110
  // Accessible data table — screen reader fallback
124
111
  const tableData = $derived(spec?.data ?? data)
125
112
  const tableColumns = $derived.by(() => {
126
- const cols = [spec?.x, spec?.y, spec?.color ?? spec?.fill].filter(
127
- (c): c is string => Boolean(c)
128
- )
113
+ // Dedupe: a channel may repeat (e.g. x === color), and the table's keyed each
114
+ // requires unique column names.
115
+ const cols = [...new Set(
116
+ [spec?.x, spec?.y, spec?.color ?? spec?.fill].filter((c): c is string => Boolean(c))
117
+ )]
129
118
  if (cols.length > 0) return cols
130
119
  const first = tableData[0]
131
120
  return first ? Object.keys(first) : []
132
121
  })
133
122
 
123
+ // Config for the shared PlotSurface (which owns PlotState, the responsive width, context,
124
+ // animation, patterns, and zoom). `width` is the fallback — PlotSurface observes the container.
134
125
  function buildPlotConfig() {
135
126
  return {
136
127
  data: spec?.data ?? data,
137
- width: effectiveWidth,
128
+ width: spec?.width ?? width,
138
129
  height: spec?.height ?? height,
139
130
  mode,
140
131
  margin,
@@ -148,60 +139,14 @@
148
139
  colorScheme: spec?.colorScheme,
149
140
  colorMidpoint: spec?.colorMidpoint,
150
141
  orientation: spec?.orientation,
142
+ sort: spec?.sort,
143
+ continuousCategory: spec?.continuousCategory,
151
144
  chartPreset,
152
145
  onselect,
153
146
  selectable
154
147
  }
155
148
  }
156
149
 
157
- // Create PlotState with initial values and provide as context.
158
- // untrack() suppresses "captures initial value" warnings — intentional:
159
- // the $effect below handles all subsequent reactive updates.
160
- const plotState = untrack(() => new PlotState(buildPlotConfig()))
161
- setContext('plot-state', plotState)
162
-
163
- // Keep state in sync when reactive config changes
164
- $effect(() => {
165
- plotState.update(buildPlotConfig())
166
- })
167
-
168
- // external control → state
169
- $effect(() => {
170
- plotState.setSelected(selected)
171
- })
172
- // clicks (state) → prop, guarded to avoid an update loop
173
- $effect(() => {
174
- const rows = plotState.selectedRows
175
- const same = rows.length === selected.length && rows.every((r, i) => r === selected[i])
176
- if (!same) selected = rows
177
- })
178
-
179
- $effect(() => {
180
- if (!zoom || !svgEl) return
181
- // d3-zoom/d3-selection are client-only; load them dynamically so they are
182
- // not bundled into (and tree-shaken-as-unused from) the SSR/main bundle.
183
- let cancelled = false
184
- let detach: (() => void) | undefined
185
- Promise.all([import('d3-zoom'), import('d3-selection')]).then(
186
- ([{ zoom: d3Zoom }, { select }]) => {
187
- if (cancelled || !svgEl) return
188
- const zoomBehavior = d3Zoom<SVGSVGElement, unknown>()
189
- .scaleExtent([1, 8])
190
- .on('zoom', (event) => {
191
- plotState.applyZoom(event.transform)
192
- })
193
- const sel = select(svgEl)
194
- sel.call(zoomBehavior)
195
- detach = () => sel.on('.zoom', null)
196
- }
197
- )
198
- return () => {
199
- cancelled = true
200
- detach?.()
201
- plotState.resetZoom()
202
- }
203
- })
204
-
205
150
  // Geoms from spec (spec-driven API)
206
151
  const specGeoms = $derived(spec?.geoms ?? [])
207
152
 
@@ -229,111 +174,100 @@
229
174
  }
230
175
  </script>
231
176
 
232
- <div class="plot-root" data-plot-root data-mode={mode} bind:this={containerEl}>
177
+ <div class="plot-root">
233
178
  {#if chartTitle}
234
179
  <div class="plot-title" data-plot-title>{chartTitle}</div>
235
180
  {/if}
236
181
 
237
- <svg
238
- bind:this={svgEl}
239
- width={effectiveWidth}
240
- height={svgHeight}
241
- viewBox="0 0 {effectiveWidth} {svgHeight}"
242
- role="img"
243
- aria-label={chartTitle || 'Chart visualization'}
244
- style:cursor={zoom ? 'grab' : undefined}
182
+ <!-- The shared root shell (PlotState, responsive width, context, animation, patterns, zoom).
183
+ The batteries below (grid/axes/geoms/overlays) render inside its canvas via context. -->
184
+ <PlotSurface
185
+ config={buildPlotConfig()}
186
+ {animate}
187
+ {zoom}
188
+ ariaLabel={chartTitle || 'Chart visualization'}
189
+ summary={chartSummary}
190
+ bind:selected
245
191
  >
246
- {#if chartSummary}
247
- <desc>{chartSummary}</desc>
192
+ <!-- Grid (behind everything) -->
193
+ {#if showGrid}
194
+ <Grid lines={gridLines} {xTicks} {yTicks} />
248
195
  {/if}
249
- <!-- SVG pattern defs -->
250
- <DefinePatterns />
251
196
 
252
- <g
253
- class="plot-canvas"
254
- transform="translate({plotState.margin.left}, {plotState.margin.top})"
255
- data-plot-canvas
256
- >
257
- <!-- Grid (behind everything) -->
258
- {#if showGrid}
259
- <Grid lines={gridLines} {xTicks} {yTicks} />
197
+ <!-- Declarative children (geom components) -->
198
+ {@render children?.()}
199
+
200
+ <!-- Spec-driven geoms -->
201
+ {#each specGeoms as geomSpec (geomSpec.type)}
202
+ {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
203
+ {#if GeomComponent}
204
+ <GeomComponent
205
+ x={geomSpec.x ?? spec?.x}
206
+ y={geomSpec.y ?? spec?.y}
207
+ color={geomSpec.color ?? spec?.color}
208
+ fill={geomSpec.fill ?? spec?.fill}
209
+ pattern={geomSpec.pattern}
210
+ symbol={geomSpec.symbol}
211
+ stat={geomSpec.stat}
212
+ label={geomSpec.label}
213
+ options={{
214
+ ...(spec?.stack !== undefined ? { stack: spec.stack } : {}),
215
+ ...(spec?.orientation !== undefined ? { orientation: spec.orientation } : {}),
216
+ ...(geomSpec.options ?? {})
217
+ }}
218
+ />
260
219
  {/if}
220
+ {/each}
261
221
 
262
- <!-- Declarative children (geom components) -->
263
- {@render children?.()}
222
+ <!-- Axes -->
223
+ {#if axes}
224
+ <Axis type="x" label={spec?.labels?.[spec?.x ?? ''] ?? ''} format={xFormat} ticks={xTicks} {minorTicks} />
225
+ <Axis type="y" label={spec?.labels?.[spec?.y ?? ''] ?? ''} format={yFormat} ticks={yTicks} {minorTicks} />
226
+ {/if}
264
227
 
265
- <!-- Spec-driven geoms -->
266
- {#each specGeoms as geomSpec (geomSpec.type)}
267
- {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
268
- {#if GeomComponent}
269
- <GeomComponent
270
- x={geomSpec.x ?? spec?.x}
271
- y={geomSpec.y ?? spec?.y}
272
- color={geomSpec.color ?? spec?.color}
273
- fill={geomSpec.fill ?? spec?.fill}
274
- pattern={geomSpec.pattern}
275
- symbol={geomSpec.symbol}
276
- stat={geomSpec.stat}
277
- label={geomSpec.label}
278
- options={{
279
- ...(spec?.stack !== undefined ? { stack: spec.stack } : {}),
280
- ...(spec?.orientation !== undefined ? { orientation: spec.orientation } : {}),
281
- ...(geomSpec.options ?? {})
282
- }}
283
- />
284
- {/if}
285
- {/each}
228
+ {#if trend !== null && trend !== undefined}
229
+ <Trend x={overlayX} y={overlayY} {trend} />
230
+ {/if}
286
231
 
287
- <!-- Axes -->
288
- {#if axes}
289
- <Axis type="x" label={spec?.labels?.[spec?.x ?? ''] ?? ''} format={xFormat} ticks={xTicks} {minorTicks} />
290
- <Axis type="y" label={spec?.labels?.[spec?.y ?? ''] ?? ''} format={yFormat} ticks={yTicks} {minorTicks} />
291
- {/if}
232
+ {#if (highlight !== null && highlight !== undefined) || selectable || selected.length}
233
+ <Highlight x={overlayX} y={overlayY} {highlight} {label} />
234
+ {/if}
292
235
 
293
- {#if trend !== null && trend !== undefined}
294
- <Trend x={overlayX} y={overlayY} {trend} />
236
+ <!-- HTML overlays — outside the svg but INSIDE the plot-state context. -->
237
+ {#snippet overlay()}
238
+ {#if showLegend}
239
+ <Legend labels={spec?.labels ?? {}} />
295
240
  {/if}
296
241
 
297
- {#if (highlight !== null && highlight !== undefined) || selectable || selected.length}
298
- <Highlight x={overlayX} y={overlayY} {highlight} {label} />
242
+ {#if tooltip}
243
+ <Tooltip {tooltip} />
299
244
  {/if}
300
- </g>
301
- </svg>
302
245
 
303
- <!-- Legend (HTML, outside SVG) -->
304
- {#if showLegend}
305
- <Legend labels={spec?.labels ?? {}} />
306
- {/if}
307
-
308
- <!-- Tooltip (HTML, fixed-position overlay) -->
309
- {#if tooltip}
310
- <Tooltip {tooltip} />
311
- {/if}
312
-
313
- <!-- Accessible data table — visually hidden, announces data to screen readers -->
314
- {#if tableData.length > 0 && tableColumns.length > 0}
315
- <table class="plot-sr-table" aria-label={chartTitle || 'Chart data'}>
316
- {#if chartTitle}
317
- <caption>{chartTitle}</caption>
318
- {/if}
319
- <thead>
320
- <tr>
321
- {#each tableColumns as col (col)}
322
- <th scope="col">{col}</th>
323
- {/each}
324
- </tr>
325
- </thead>
326
- <tbody>
327
- {#each tableData as row, i (i)}
328
- <tr>
329
- {#each tableColumns as col (col)}
330
- <td>{row[col] ?? ''}</td>
246
+ {#if tableData.length > 0 && tableColumns.length > 0}
247
+ <table class="plot-sr-table" aria-label={chartTitle || 'Chart data'}>
248
+ {#if chartTitle}
249
+ <caption>{chartTitle}</caption>
250
+ {/if}
251
+ <thead>
252
+ <tr>
253
+ {#each tableColumns as col (col)}
254
+ <th scope="col">{col}</th>
255
+ {/each}
256
+ </tr>
257
+ </thead>
258
+ <tbody>
259
+ {#each tableData as row, i (i)}
260
+ <tr>
261
+ {#each tableColumns as col (col)}
262
+ <td>{row[col] ?? ''}</td>
263
+ {/each}
264
+ </tr>
331
265
  {/each}
332
- </tr>
333
- {/each}
334
- </tbody>
335
- </table>
336
- {/if}
266
+ </tbody>
267
+ </table>
268
+ {/if}
269
+ {/snippet}
270
+ </PlotSurface>
337
271
  </div>
338
272
 
339
273
  <style>
@@ -343,23 +277,6 @@
343
277
  height: auto;
344
278
  }
345
279
 
346
- [data-plot-root][data-mode='light'] {
347
- color: rgba(var(--color-surface-700, 59, 65, 77), 1);
348
- }
349
-
350
- [data-plot-root][data-mode='dark'] {
351
- color: rgba(var(--color-surface-200, 224, 224, 224), 1);
352
- }
353
-
354
- svg {
355
- display: block;
356
- overflow: visible;
357
- }
358
-
359
- .plot-canvas {
360
- pointer-events: all;
361
- }
362
-
363
280
  .plot-title {
364
281
  font-size: 14px;
365
282
  font-weight: 600;
@@ -39,6 +39,10 @@ type Props = {
39
39
  onselect?: (detail: unknown) => void;
40
40
  selectable?: boolean;
41
41
  selected?: Row[];
42
+ /** Animate marks on data/flip changes (opt-out for e.g. AnimatedPlot, which
43
+ * tweens its own frames). Enabled one frame after the width settles so the
44
+ * initial layout paints un-animated. Default `true`. */
45
+ animate?: boolean;
42
46
  children?: Snippet;
43
47
  };
44
48
  declare const Plot: import("svelte").Component<Props, {}, "selected">;
@@ -41,6 +41,22 @@ export class PlotState {
41
41
  symbols: any;
42
42
  };
43
43
  get data(): any[];
44
+ /** @returns {{ x?: string, y?: string, color?: string, pattern?: string, symbol?: string }} */
45
+ get channels(): {
46
+ x?: string;
47
+ y?: string;
48
+ color?: string;
49
+ pattern?: string;
50
+ symbol?: string;
51
+ };
52
+ get isFlipped(): boolean;
53
+ get continuousCategory(): boolean;
54
+ place(u: any, v: any): {
55
+ x: any;
56
+ y: any;
57
+ };
58
+ get bandScale(): any;
59
+ get valueScale(): any;
44
60
  get margin(): {
45
61
  top: number;
46
62
  right: number;
@@ -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,8 +63,11 @@ 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,
@@ -67,15 +80,32 @@ export class PlotState {
67
80
 
68
81
  #effectiveChannels = $derived.by(() => {
69
82
  const tc = this.#channels
70
- if (tc.x && tc.y) return tc
71
83
  const firstGeom = this.#geoms[0]
72
84
  if (!firstGeom) return tc
73
85
  return this.#mergeGeomChannels(tc, firstGeom)
74
86
  })
75
87
 
76
88
  #resolveXType(rawXType, yType) {
77
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
78
- return hasBarGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
89
+ const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
90
+ return hasBandGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
91
+ }
92
+
93
+ // Category order sorted by aggregated value (sum of the value channel per category).
94
+ // Drives "sort bars by size" for the band axis + its ticks. Returns null when not sorting.
95
+ #sortedBandDomain(datasets) {
96
+ if (!this.#sort) return null
97
+ const xf = this.#effectiveChannels.x
98
+ const yf = this.#effectiveChannels.y
99
+ if (!xf || !yf) return null
100
+ const totals = new Map()
101
+ for (const ds of datasets) {
102
+ for (const d of ds) {
103
+ const key = d[xf]
104
+ totals.set(key, (totals.get(key) ?? 0) + (Number(d[yf]) || 0))
105
+ }
106
+ }
107
+ const dir = this.#sort === 'asc' ? 1 : -1
108
+ return [...totals.keys()].sort((a, b) => dir * ((totals.get(a) ?? 0) - (totals.get(b) ?? 0)))
79
109
  }
80
110
 
81
111
  orientation = $derived.by(() => {
@@ -89,6 +119,24 @@ export class PlotState {
89
119
  return inferOrientation(this.#resolveXType(rawXType, yType), yType)
90
120
  })
91
121
 
122
+ // True when the x-channel is the categorical (band) axis — a string category, or a numeric
123
+ // category a bar/box/violin/jitter geom bands (incl. a race's rank via continuousCategory).
124
+ // Only such charts flip; a plain scatter (both continuous, no banding geom) is false → the
125
+ // orientation is a no-op for the scales.
126
+ #bandIsX = $derived.by(() => {
127
+ const x = this.#effectiveChannels.x
128
+ const y = this.#effectiveChannels.y
129
+ if (!x || !y) return false
130
+ // Resolved type: a bar/box/violin/jitter geom bands a numeric x (e.g. year, week),
131
+ // so those NUMERIC categories are flippable too — not just string categories.
132
+ return this.#resolveXType(inferFieldType(this.#data, x), inferFieldType(this.#data, y)) === 'band'
133
+ })
134
+
135
+ // Flip = render a category-on-x chart horizontally: the category (x) axis stands up on the
136
+ // vertical screen and the value (y) axis runs along the horizontal screen. x/y channels are
137
+ // unchanged — only the screen mapping rotates.
138
+ #flipped = $derived(this.orientation === 'horizontal' && this.#bandIsX)
139
+
92
140
  colorScaleType = $derived.by(() => {
93
141
  const field = this.#effectiveChannels.color
94
142
  if (!field) return 'categorical'
@@ -118,15 +166,30 @@ export class PlotState {
118
166
  if (!field) return null
119
167
  const datasets =
120
168
  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'
169
+ // includeZero applies to the VALUE axis. When flipped, x is the category axis, not
170
+ // the value axis, so it must NOT include zero.
171
+ const includeZero = this.orientation === 'horizontal' && !this.#flipped
172
+ // Force scaleBand when x is the categorical axis (incl. flipped, where numeric categories
173
+ // must stay a band). Exception: a CONTINUOUS category axis (a bar race's tweened rank) is
174
+ // kept LINEAR so fractional positions tween smoothly — buildBars positions it directly.
175
+ const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
176
+ const bandX =
177
+ hasBandGeom &&
178
+ !this.#continuousCategory &&
179
+ (this.orientation !== 'horizontal' || this.#flipped)
180
+ // Flip: the category (x) axis stands up on the vertical screen → range over height.
181
+ const range = this.#flipped ? [this.#innerHeight, 0] : undefined
182
+ // Sort the band domain by aggregated value when requested (bars by size); an explicit
183
+ // xDomain override wins.
184
+ const domain = this.#xDomain ?? (bandX ? this.#sortedBandDomain(datasets) : null) ?? undefined
126
185
  const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
127
- domain: this.#xDomain,
186
+ domain,
128
187
  includeZero,
129
- band: bandX
188
+ band: bandX,
189
+ range,
190
+ // A continuous category axis (bar-race rank) uses a deliberately padded domain —
191
+ // don't nice() it back to whole numbers.
192
+ nice: !this.#continuousCategory
130
193
  })
131
194
  return this.#zoomTransform && typeof base?.bandwidth !== 'function'
132
195
  ? this.#zoomTransform.rescaleX(base)
@@ -139,8 +202,9 @@ export class PlotState {
139
202
  if (!boxGeom) return null
140
203
  const boxData = this.geomData(boxGeom.id)
141
204
  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)
205
+ const flatOutliers = boxData.flatMap((d) => d.outliers ?? []).filter(isValid)
206
+ const mins = [...boxData.map((d) => d.iqr_min).filter(isValid), ...flatOutliers]
207
+ const maxs = [...boxData.map((d) => d.iqr_max).filter(isValid), ...flatOutliers]
144
208
  return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
145
209
  }
146
210
 
@@ -181,9 +245,13 @@ export class PlotState {
181
245
  if (!field) return null
182
246
  const datasets =
183
247
  this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
184
- const includeZero = this.orientation === 'vertical'
248
+ // includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
249
+ // charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
250
+ const includeZero = this.orientation === 'vertical' || this.#flipped
185
251
  const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
186
- const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
252
+ // Flip: the value (y) axis runs along the horizontal screen → range over width.
253
+ const range = this.#flipped ? [0, this.#innerWidth] : undefined
254
+ const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
187
255
  return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
188
256
  })
189
257
 
@@ -283,6 +351,8 @@ export class PlotState {
283
351
  this.#axisOffset = config.axisOffset ?? 0
284
352
  this.#marginOverride = config.margin ?? undefined
285
353
  this.#orientationOverride = config.orientation ?? undefined
354
+ this.#continuousCategory = config.continuousCategory ?? false
355
+ this.#sort = config.sort ?? undefined
286
356
  }
287
357
 
288
358
  update(config) {
@@ -309,6 +379,8 @@ export class PlotState {
309
379
  if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
310
380
  this.#marginOverride = config.margin ?? undefined
311
381
  this.#orientationOverride = config.orientation ?? undefined
382
+ this.#continuousCategory = config.continuousCategory ?? false
383
+ this.#sort = config.sort ?? undefined
312
384
  }
313
385
 
314
386
  registerGeom(config) {
@@ -360,6 +432,36 @@ export class PlotState {
360
432
  // array separately, so their elements are not ===. Same content, aligned identity.
361
433
  return this.#rawData
362
434
  }
435
+ /** @returns {{ x?: string, y?: string, color?: string, pattern?: string, symbol?: string }} */
436
+ get channels() {
437
+ return this.#channels
438
+ }
439
+
440
+ // ─── Orientation helpers (horizontal / axis-flip) ──────────────────────────
441
+ // True when the chart is rendered horizontally (category axis stood up on the
442
+ // vertical screen, value axis along the horizontal screen). x/y channels unchanged.
443
+ get isFlipped() {
444
+ return this.#flipped
445
+ }
446
+ // True when the category (x) axis is a continuous position scale (kept linear) rather than a
447
+ // band — a bar-chart race's tweened rank. buildBars uses continuous positioning for it.
448
+ get continuousCategory() {
449
+ return this.#continuousCategory
450
+ }
451
+ // Map abstract (x-channel, y-channel) scale outputs to screen coords. When flipped,
452
+ // the two screen axes swap. Geoms compute u = xScale(d[x]), v = yScale(d[y]) then
453
+ // `const { x, y } = plotState.place(u, v)`.
454
+ place(u, v) {
455
+ return this.#flipped ? { x: v, y: u } : { x: u, y: v }
456
+ }
457
+ // The categorical (band) scale and the continuous (value) scale, regardless of
458
+ // orientation — their ranges are already oriented to the correct screen axis.
459
+ get bandScale() {
460
+ return this.#bandIsX ? this.xScale : this.yScale
461
+ }
462
+ get valueScale() {
463
+ return this.#bandIsX ? this.yScale : this.xScale
464
+ }
363
465
  get margin() {
364
466
  return this.#effectiveMargin
365
467
  }