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