@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.
- package/dist/AnimatedPlot.svelte +26 -8
- package/dist/AnimatedPlot.svelte.d.ts +5 -0
- package/dist/Chart.svelte +1 -1
- package/dist/FacetPlot.svelte +1 -1
- package/dist/Plot/Axis.svelte +19 -10
- package/dist/Plot/Root.svelte +64 -46
- package/dist/Plot/Root.svelte.d.ts +27 -2
- package/dist/Plot/Timeline.svelte +39 -13
- package/dist/Plot/index.d.ts +4 -4
- package/dist/Plot/index.js +4 -4
- package/dist/Plot.svelte +95 -178
- package/dist/Plot.svelte.d.ts +4 -0
- package/dist/PlotState.svelte.d.ts +16 -0
- package/dist/PlotState.svelte.js +118 -16
- package/dist/PlotSurface.svelte +162 -0
- package/dist/PlotSurface.svelte.d.ts +25 -0
- package/dist/charts/AreaChart.svelte +4 -1
- package/dist/charts/AreaChart.svelte.d.ts +1 -0
- package/dist/charts/BarChart.svelte +32 -3
- package/dist/charts/BarChart.svelte.d.ts +10 -1
- package/dist/charts/BoxPlot.svelte +4 -2
- package/dist/charts/BoxPlot.svelte.d.ts +1 -0
- package/dist/charts/BubbleChart.svelte +34 -3
- package/dist/charts/BubbleChart.svelte.d.ts +11 -1
- package/dist/charts/LineChart.svelte +1 -1
- package/dist/charts/PieChart.svelte +1 -1
- package/dist/charts/ScatterPlot.svelte +32 -3
- package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
- package/dist/charts/ViolinPlot.svelte +4 -2
- package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
- package/dist/examples/BarChartExample.svelte +1 -1
- package/dist/geoms/Arc.svelte +3 -2
- package/dist/geoms/Area.svelte +2 -2
- package/dist/geoms/Bar.svelte +17 -6
- package/dist/geoms/Box.svelte +131 -76
- package/dist/geoms/Box.svelte.d.ts +7 -0
- package/dist/geoms/Candlestick.svelte +17 -11
- package/dist/geoms/Jitter.svelte +82 -0
- package/dist/geoms/Jitter.svelte.d.ts +15 -0
- package/dist/geoms/Line.svelte +1 -1
- package/dist/geoms/Point.svelte +7 -1
- package/dist/geoms/Rule.svelte +113 -0
- package/dist/geoms/Rule.svelte.d.ts +16 -0
- package/dist/geoms/Violin.svelte +30 -6
- package/dist/geoms/Violin.svelte.d.ts +5 -0
- package/dist/geoms/Waterfall.svelte +16 -9
- package/dist/geoms/lib/areas.d.ts +6 -2
- package/dist/geoms/lib/areas.js +17 -11
- package/dist/geoms/lib/bars.d.ts +18 -3
- package/dist/geoms/lib/bars.js +70 -96
- package/dist/index.d.ts +24 -4
- package/dist/index.js +25 -5
- package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
- package/dist/lib/brewing/marks/arcs.js +7 -1
- package/dist/lib/brewing/marks/boxes.d.ts +11 -2
- package/dist/lib/brewing/marks/boxes.js +32 -11
- package/dist/lib/brewing/marks/lines.d.ts +4 -1
- package/dist/lib/brewing/marks/lines.js +18 -15
- package/dist/lib/brewing/marks/swarm.d.ts +32 -0
- package/dist/lib/brewing/marks/swarm.js +125 -0
- package/dist/lib/brewing/marks/violins.d.ts +4 -20
- package/dist/lib/brewing/marks/violins.js +41 -25
- package/dist/lib/brewing/stats.d.ts +3 -1
- package/dist/lib/brewing/stats.js +28 -11
- package/dist/lib/plot/scales.js +12 -7
- package/dist/lib/plot/types.d.ts +9 -1
- package/dist/lib/plot/types.js +3 -1
- package/package.json +1 -1
- package/dist/Plot/Area.svelte +0 -54
- package/dist/Plot/Area.svelte.d.ts +0 -14
- package/dist/Plot/Bar.svelte +0 -69
- package/dist/Plot/Bar.svelte.d.ts +0 -11
- package/dist/Plot/Line.svelte +0 -54
- package/dist/Plot/Line.svelte.d.ts +0 -13
- package/dist/Plot/Point.svelte +0 -58
- 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 {
|
|
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 =
|
|
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
|
-
|
|
127
|
-
|
|
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:
|
|
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"
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
{#if
|
|
259
|
-
<
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
{
|
|
267
|
-
|
|
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
|
-
|
|
288
|
-
{
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
298
|
-
<
|
|
242
|
+
{#if tooltip}
|
|
243
|
+
<Tooltip {tooltip} />
|
|
299
244
|
{/if}
|
|
300
|
-
</g>
|
|
301
|
-
</svg>
|
|
302
245
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
</
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
package/dist/Plot.svelte.d.ts
CHANGED
|
@@ -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;
|
package/dist/PlotState.svelte.js
CHANGED
|
@@ -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
|
|
57
|
-
//
|
|
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
|
|
78
|
-
return
|
|
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
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
143
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|