@rokkit/chart 1.7.0 → 1.8.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 +13 -0
- package/dist/Plot/DataTable.svelte +54 -0
- package/dist/Plot/DataTable.svelte.d.ts +12 -0
- package/dist/Plot/SpecGeoms.svelte +78 -0
- package/dist/Plot/SpecGeoms.svelte.d.ts +11 -0
- package/dist/Plot.svelte +47 -151
- package/dist/Plot.svelte.d.ts +5 -0
- package/dist/PlotState.svelte.d.ts +115 -71
- package/dist/PlotState.svelte.js +174 -547
- package/dist/SparkState.svelte.d.ts +3 -1
- package/dist/SparkState.svelte.js +11 -41
- package/dist/geoms/Arc.svelte +1 -1
- package/dist/geoms/Bar.svelte +23 -32
- package/dist/geoms/Contour.svelte +88 -0
- package/dist/geoms/Contour.svelte.d.ts +19 -0
- package/dist/geoms/Hull.svelte +89 -0
- package/dist/geoms/Hull.svelte.d.ts +17 -0
- package/dist/geoms/Region.svelte +107 -0
- package/dist/geoms/Region.svelte.d.ts +27 -0
- package/dist/geoms/Rule.svelte +88 -26
- package/dist/geoms/Rule.svelte.d.ts +8 -0
- package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
- package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
- package/dist/geoms/lib/bars.js +13 -20
- package/dist/geoms/lib/grouping.d.ts +35 -0
- package/dist/geoms/lib/grouping.js +59 -0
- package/dist/geoms/lib/marks/arc.d.ts +1 -0
- package/dist/geoms/lib/marks/contour.d.ts +26 -0
- package/dist/geoms/lib/marks/contour.js +67 -0
- package/dist/geoms/lib/marks/hull.d.ts +26 -0
- package/dist/geoms/lib/marks/hull.js +36 -0
- package/dist/geoms/lib/marks/waterfall.js +15 -20
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lib/abline.d.ts +41 -0
- package/dist/lib/abline.js +51 -0
- package/dist/lib/brewing/marks/arcs.d.ts +6 -1
- package/dist/lib/brewing/marks/arcs.js +9 -1
- package/dist/lib/brewing/polar.js +8 -3
- package/dist/lib/hull.d.ts +18 -0
- package/dist/lib/hull.js +64 -0
- package/dist/lib/plot/domains.d.ts +61 -0
- package/dist/lib/plot/domains.js +130 -0
- package/dist/lib/plot/running.d.ts +23 -0
- package/dist/lib/plot/running.js +32 -0
- package/dist/lib/plot/spec.d.ts +59 -0
- package/dist/lib/plot/spec.js +131 -0
- package/dist/lib/plot/stacking.d.ts +32 -0
- package/dist/lib/plot/stacking.js +34 -0
- package/dist/lib/plot/types.d.ts +8 -1
- package/dist/lib/plot/types.js +5 -1
- package/dist/lib/preset.d.ts +2 -0
- package/dist/lib/preset.js +76 -41
- package/dist/lib/region.d.ts +63 -0
- package/dist/lib/region.js +127 -0
- package/dist/state/AestheticState.svelte.d.ts +43 -0
- package/dist/state/AestheticState.svelte.js +84 -0
- package/dist/state/AxisState.svelte.d.ts +26 -0
- package/dist/state/AxisState.svelte.js +56 -0
- package/dist/state/ChannelState.svelte.d.ts +34 -0
- package/dist/state/ChannelState.svelte.js +67 -0
- package/dist/state/GeomRegistry.svelte.d.ts +44 -0
- package/dist/state/GeomRegistry.svelte.js +86 -0
- package/dist/state/InteractionState.svelte.d.ts +30 -0
- package/dist/state/InteractionState.svelte.js +69 -0
- package/dist/state/OrientationState.svelte.d.ts +34 -0
- package/dist/state/OrientationState.svelte.js +64 -0
- package/dist/state/PlotConfig.svelte.d.ts +179 -0
- package/dist/state/PlotConfig.svelte.js +195 -0
- package/dist/state/PlotFrame.svelte.d.ts +17 -0
- package/dist/state/PlotFrame.svelte.js +18 -0
- package/dist/state/ScaleState.svelte.d.ts +36 -0
- package/dist/state/ScaleState.svelte.js +121 -0
- package/package.json +5 -3
package/dist/PlotState.svelte.js
CHANGED
|
@@ -1,613 +1,240 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
import { PlotConfig } from './state/PlotConfig.svelte.js'
|
|
2
|
+
import { GeomRegistry } from './state/GeomRegistry.svelte.js'
|
|
3
|
+
import { PlotFrame } from './state/PlotFrame.svelte.js'
|
|
4
|
+
import { ChannelState } from './state/ChannelState.svelte.js'
|
|
5
|
+
import { OrientationState } from './state/OrientationState.svelte.js'
|
|
6
|
+
import { InteractionState } from './state/InteractionState.svelte.js'
|
|
7
|
+
import { ScaleState } from './state/ScaleState.svelte.js'
|
|
8
|
+
import { AestheticState } from './state/AestheticState.svelte.js'
|
|
9
|
+
import { AxisState } from './state/AxisState.svelte.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The state a `PlotChart` publishes on the `'plot-state'` context — a COMPOSITION of job
|
|
13
|
+
* classes (`./state/`), each owning one concern and reading only the ones built before it:
|
|
14
|
+
*
|
|
15
|
+
* config → geoms → frame → channels → orientation → interaction → scales → aesthetics → axes
|
|
16
|
+
*
|
|
17
|
+
* It derives nothing itself. Everything below the constructor is the contract geoms, axes, the
|
|
18
|
+
* legend and the tooltip read (`GEOM_CONTRACT` in SparkState.svelte.js is the geom-facing part),
|
|
19
|
+
* kept as one-line delegations so no consumer changed when the monolith was taken apart. New
|
|
20
|
+
* code can depend on a job directly — `plotState.scales`, `plotState.interactionState` — instead.
|
|
21
|
+
*/
|
|
23
22
|
export class PlotState {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#mode = $state('light')
|
|
43
|
-
#chartPreset = $state(defaultPreset)
|
|
44
|
-
#hovered = $state(null)
|
|
45
|
-
#selected = $state(new SvelteSet())
|
|
46
|
-
#onselect = $state(undefined)
|
|
47
|
-
#selectable = $state(false)
|
|
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)
|
|
56
|
-
|
|
57
|
-
axisOrigin = $state([undefined, undefined])
|
|
58
|
-
#axisOffset = $state(0)
|
|
59
|
-
|
|
60
|
-
#zoomTransform = $state(null)
|
|
23
|
+
/** The plot's inputs — the only thing `update()` writes. */
|
|
24
|
+
config
|
|
25
|
+
/** The mounted geoms and the rows each draws. */
|
|
26
|
+
geoms
|
|
27
|
+
/** The drawing area: size less margin. */
|
|
28
|
+
frame
|
|
29
|
+
/** Which field feeds each aesthetic. Suffixed: `channels` is the contract's raw map. */
|
|
30
|
+
channelState
|
|
31
|
+
/** Which way the chart reads. Suffixed: `orientation` is the contract's string. */
|
|
32
|
+
orientationState
|
|
33
|
+
/** Hover, selection, zoom. */
|
|
34
|
+
interactionState
|
|
35
|
+
/** The x / y position scales. */
|
|
36
|
+
scales
|
|
37
|
+
/** Palette, patterns, symbols, continuous colour. */
|
|
38
|
+
aesthetics
|
|
39
|
+
/** Where the axes cross. */
|
|
40
|
+
axes
|
|
61
41
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
42
|
+
constructor(config = {}) {
|
|
43
|
+
this.config = new PlotConfig(config)
|
|
44
|
+
this.geoms = new GeomRegistry(this.config, 'geom')
|
|
45
|
+
this.frame = new PlotFrame(this.config)
|
|
46
|
+
this.channelState = new ChannelState(this.config, this.geoms)
|
|
47
|
+
this.orientationState = new OrientationState(this.config, this.channelState, this.geoms)
|
|
48
|
+
this.interactionState = new InteractionState(this.config, config.selected)
|
|
49
|
+
this.scales = new ScaleState({
|
|
50
|
+
config: this.config,
|
|
51
|
+
geoms: this.geoms,
|
|
52
|
+
channels: this.channelState,
|
|
53
|
+
orientation: this.orientationState,
|
|
54
|
+
frame: this.frame,
|
|
55
|
+
interaction: this.interactionState
|
|
56
|
+
})
|
|
57
|
+
this.aesthetics = new AestheticState(this.config, this.channelState)
|
|
58
|
+
this.axes = new AxisState(this.config, this.scales, this.frame)
|
|
80
59
|
}
|
|
81
60
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// model (see docs/backlog/2026-08-17-chart-aesthetics-unification.md §2).
|
|
86
|
-
#sharedColorValues() {
|
|
87
|
-
const fields = []
|
|
88
|
-
const addField = (f) => {
|
|
89
|
-
if (f && !isLiteralColor(f) && !fields.includes(f)) fields.push(f)
|
|
90
|
-
}
|
|
91
|
-
addField(this.#channels.color)
|
|
92
|
-
addField(this.#channels.fill)
|
|
93
|
-
for (const g of this.#geoms) {
|
|
94
|
-
addField(g.channels?.color)
|
|
95
|
-
addField(g.channels?.fill)
|
|
96
|
-
}
|
|
97
|
-
// `includes` dedup rather than a Set, matching `addField` above. These are
|
|
98
|
-
// category values feeding one palette/legend — a domain of dozens, not
|
|
99
|
-
// rows — so the linear scan costs nothing and the function stays free of
|
|
100
|
-
// a collection the reactivity linter has to be told to ignore.
|
|
101
|
-
const values = []
|
|
102
|
-
for (const f of fields) {
|
|
103
|
-
for (const v of distinct(this.#data, f)) if (!values.includes(v)) values.push(v)
|
|
104
|
-
}
|
|
105
|
-
return values
|
|
61
|
+
/** Re-apply inputs. Which omitted fields keep and which reset: `CONFIG_FIELDS`. */
|
|
62
|
+
update(config) {
|
|
63
|
+
this.config.update(config)
|
|
106
64
|
}
|
|
107
65
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return this.#mergeGeomChannels(tc, firstGeom)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
#resolveXType(rawXType, yType) {
|
|
116
|
-
const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
|
|
117
|
-
return hasBandGeom && rawXType === 'continuous' && yType === 'continuous' ? 'band' : rawXType
|
|
66
|
+
// ─── config ────────────────────────────────────────────────────────────────
|
|
67
|
+
/** The rows — the same objects every identity geom is handed, so `indexOf(row)` works. */
|
|
68
|
+
get data() {
|
|
69
|
+
return this.config.data
|
|
118
70
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
#sortedBandDomain(datasets) {
|
|
123
|
-
if (!this.#sort) return null
|
|
124
|
-
const xf = this.#effectiveChannels.x
|
|
125
|
-
const yf = this.#effectiveChannels.y
|
|
126
|
-
if (!xf || !yf) return null
|
|
127
|
-
// Keyed accumulator, function-local and discarded before returning — a
|
|
128
|
-
// SvelteMap would register a reactive signal per category on every
|
|
129
|
-
// derivation for a collection nothing outside this call can observe.
|
|
130
|
-
// Not a plain object either: keys are raw data values (numbers, Dates),
|
|
131
|
-
// which object keys would coerce to strings.
|
|
132
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
133
|
-
const totals = new Map()
|
|
134
|
-
for (const ds of datasets) {
|
|
135
|
-
for (const d of ds) {
|
|
136
|
-
const key = d[xf]
|
|
137
|
-
totals.set(key, (totals.get(key) ?? 0) + (Number(d[yf]) || 0))
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const dir = this.#sort === 'asc' ? 1 : -1
|
|
141
|
-
return [...totals.keys()].sort((a, b) => dir * ((totals.get(a) ?? 0) - (totals.get(b) ?? 0)))
|
|
71
|
+
/** The caller's channel map, unmerged. `channelState.effective` is the merged one. */
|
|
72
|
+
get channels() {
|
|
73
|
+
return this.config.channels
|
|
142
74
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (this.#orientationOverride) return this.#orientationOverride
|
|
146
|
-
const xField = this.#effectiveChannels.x
|
|
147
|
-
const yField = this.#effectiveChannels.y
|
|
148
|
-
if (!xField || !yField) return 'none'
|
|
149
|
-
const rawXType = inferFieldType(this.#data, xField)
|
|
150
|
-
const yType = inferFieldType(this.#data, yField)
|
|
151
|
-
// Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
|
|
152
|
-
return inferOrientation(this.#resolveXType(rawXType, yType), yType)
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// True when the x-channel is the categorical (band) axis — a string category, or a numeric
|
|
156
|
-
// category a bar/box/violin/jitter geom bands (incl. a race's rank via continuousCategory).
|
|
157
|
-
// Only such charts flip; a plain scatter (both continuous, no banding geom) is false → the
|
|
158
|
-
// orientation is a no-op for the scales.
|
|
159
|
-
#bandIsX = $derived.by(() => {
|
|
160
|
-
const x = this.#effectiveChannels.x
|
|
161
|
-
const y = this.#effectiveChannels.y
|
|
162
|
-
if (!x || !y) return false
|
|
163
|
-
// Resolved type: a bar/box/violin/jitter geom bands a numeric x (e.g. year, week),
|
|
164
|
-
// so those NUMERIC categories are flippable too — not just string categories.
|
|
165
|
-
return this.#resolveXType(inferFieldType(this.#data, x), inferFieldType(this.#data, y)) === 'band'
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
// Flip = render a category-on-x chart horizontally: the category (x) axis stands up on the
|
|
169
|
-
// vertical screen and the value (y) axis runs along the horizontal screen. x/y channels are
|
|
170
|
-
// unchanged — only the screen mapping rotates.
|
|
171
|
-
#flipped = $derived(this.orientation === 'horizontal' && this.#bandIsX)
|
|
172
|
-
|
|
173
|
-
colorScaleType = $derived.by(() => {
|
|
174
|
-
const field = this.#effectiveChannels.color
|
|
175
|
-
if (!field) return 'categorical'
|
|
176
|
-
return inferColorScaleType(this.#data, field, {
|
|
177
|
-
colorScale: this.#colorSpec,
|
|
178
|
-
colorMidpoint: this.#colorMidpoint
|
|
179
|
-
})
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
// Continuous color scale (sequential or diverging) — null for categorical.
|
|
183
|
-
continuousColorScale = $derived.by(() => {
|
|
184
|
-
const field = this.#effectiveChannels.color
|
|
185
|
-
if (!field || this.colorScaleType === 'categorical') return null
|
|
186
|
-
const opts = {
|
|
187
|
-
colorScheme: this.#colorScheme,
|
|
188
|
-
colorDomain: this.#colorDomain,
|
|
189
|
-
colorMidpoint: this.#colorMidpoint
|
|
190
|
-
}
|
|
191
|
-
if (this.colorScaleType === 'diverging') {
|
|
192
|
-
return buildDivergingScale(this.#data, field, opts)
|
|
193
|
-
}
|
|
194
|
-
return buildSequentialScale(this.#data, field, opts)
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
xScale = $derived.by(() => {
|
|
198
|
-
const field = this.#effectiveChannels.x
|
|
199
|
-
if (!field) return null
|
|
200
|
-
const datasets =
|
|
201
|
-
this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
|
|
202
|
-
// includeZero applies to the VALUE axis. When flipped, x is the category axis, not
|
|
203
|
-
// the value axis, so it must NOT include zero.
|
|
204
|
-
const includeZero = this.orientation === 'horizontal' && !this.#flipped
|
|
205
|
-
// Force scaleBand when x is the categorical axis (incl. flipped, where numeric categories
|
|
206
|
-
// must stay a band). Exception: a CONTINUOUS category axis (a bar race's tweened rank) is
|
|
207
|
-
// kept LINEAR so fractional positions tween smoothly — buildBars positions it directly.
|
|
208
|
-
const hasBandGeom = this.#geoms.some((g) => CATEGORICAL_X.has(g.type))
|
|
209
|
-
const bandX =
|
|
210
|
-
hasBandGeom &&
|
|
211
|
-
!this.#continuousCategory &&
|
|
212
|
-
(this.orientation !== 'horizontal' || this.#flipped)
|
|
213
|
-
// Flip: the category (x) axis stands up on the vertical screen → range over height.
|
|
214
|
-
const range = this.#flipped ? [this.#innerHeight, 0] : undefined
|
|
215
|
-
// Sort the band domain by aggregated value when requested (bars by size); an explicit
|
|
216
|
-
// xDomain override wins.
|
|
217
|
-
const domain = this.#xDomain ?? (bandX ? this.#sortedBandDomain(datasets) : null) ?? undefined
|
|
218
|
-
const base = buildUnifiedXScale(datasets, field, this.#innerWidth, {
|
|
219
|
-
domain,
|
|
220
|
-
includeZero,
|
|
221
|
-
band: bandX,
|
|
222
|
-
range,
|
|
223
|
-
// A continuous category axis (bar-race rank) uses a deliberately padded domain —
|
|
224
|
-
// don't nice() it back to whole numbers.
|
|
225
|
-
nice: !this.#continuousCategory
|
|
226
|
-
})
|
|
227
|
-
return this.#zoomTransform && typeof base?.bandwidth !== 'function'
|
|
228
|
-
? this.#zoomTransform.rescaleX(base)
|
|
229
|
-
: base
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
// For box/violin geoms, compute y domain from iqr_min/iqr_max instead of raw y values.
|
|
233
|
-
#resolveBoxDomain() {
|
|
234
|
-
const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
|
|
235
|
-
if (!boxGeom) return null
|
|
236
|
-
const boxData = this.geomData(boxGeom.id)
|
|
237
|
-
const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
|
|
238
|
-
const flatOutliers = boxData.flatMap((d) => d.outliers ?? []).filter(isValid)
|
|
239
|
-
const mins = [...boxData.map((d) => d.iqr_min).filter(isValid), ...flatOutliers]
|
|
240
|
-
const maxs = [...boxData.map((d) => d.iqr_max).filter(isValid), ...flatOutliers]
|
|
241
|
-
return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
|
|
75
|
+
get mode() {
|
|
76
|
+
return this.config.mode
|
|
242
77
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
#resolveStackDomain(field) {
|
|
246
|
-
const stackGeom = this.#geoms.find(
|
|
247
|
-
(g) => g.options?.stack || g.options?.position === 'stack' || g.options?.position === 'fill'
|
|
248
|
-
)
|
|
249
|
-
if (!stackGeom) return null
|
|
250
|
-
// position='fill' normalizes each column to 100% → the value domain is [0,1].
|
|
251
|
-
if (stackGeom.options?.position === 'fill') return [0, 1]
|
|
252
|
-
const xField = this.#effectiveChannels.x
|
|
253
|
-
const stackData = this.geomData(stackGeom.id)
|
|
254
|
-
if (!xField || stackData.length === 0) return null
|
|
255
|
-
// Mirror buildStackedBars/subBandFields: stack dimension is the first
|
|
256
|
-
// non-x field among [color, pattern]. Summing all raw rows (stat=identity)
|
|
257
|
-
// would overcount when multiple rows share the same (x, stack) key.
|
|
258
|
-
const colorField = isLiteralColor(this.#effectiveChannels.color)
|
|
259
|
-
? null
|
|
260
|
-
: this.#effectiveChannels.color
|
|
261
|
-
const fillField = isLiteralColor(this.#effectiveChannels.fill)
|
|
262
|
-
? null
|
|
263
|
-
: this.#effectiveChannels.fill
|
|
264
|
-
const patternField = this.#effectiveChannels.pattern
|
|
265
|
-
// Mirror buildStackedBars/subBandFields (group=fill first, then color, then pattern).
|
|
266
|
-
const stackField =
|
|
267
|
-
[fillField, colorField, patternField].find((f) => f && f !== xField) ?? (fillField ?? colorField)
|
|
268
|
-
// No grouping field → buildStackedBars falls back to buildBars (individual bars, no
|
|
269
|
-
// actual stacking), so there's no stacked total to size to. Bail to the normal value
|
|
270
|
-
// extent; otherwise every row collapses to the same cKey below and the lookup's set()
|
|
271
|
-
// overwrites, shrinking the domain to the last row per x — and the bars overflow.
|
|
272
|
-
if (!stackField) return null
|
|
273
|
-
const lookup = new SvelteMap()
|
|
274
|
-
for (const d of stackData) {
|
|
275
|
-
const xVal = d[xField]
|
|
276
|
-
const cKey = stackField ? String(d[stackField]) : '_'
|
|
277
|
-
if (!lookup.has(xVal)) lookup.set(xVal, new SvelteMap())
|
|
278
|
-
lookup.get(xVal).set(cKey, Number(d[field]) || 0)
|
|
279
|
-
}
|
|
280
|
-
const totals = new SvelteMap()
|
|
281
|
-
for (const [xVal, colorMap] of lookup) {
|
|
282
|
-
totals.set(
|
|
283
|
-
xVal,
|
|
284
|
-
[...colorMap.values()].reduce((s, v) => s + v, 0)
|
|
285
|
-
)
|
|
286
|
-
}
|
|
287
|
-
return [0, Math.max(0, ...totals.values())]
|
|
78
|
+
get chartPreset() {
|
|
79
|
+
return this.config.chartPreset
|
|
288
80
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
#resolveWaterfallDomain(field) {
|
|
293
|
-
const geom = this.#geoms.find((g) => g.type === 'waterfall')
|
|
294
|
-
if (!geom) return null
|
|
295
|
-
const data = this.geomData(geom.id)
|
|
296
|
-
if (data.length === 0) return null
|
|
297
|
-
const totalField = geom.options?.totalField
|
|
298
|
-
let cumulative = 0
|
|
299
|
-
let min = 0
|
|
300
|
-
let max = 0
|
|
301
|
-
for (const d of data) {
|
|
302
|
-
if (totalField && d[totalField]) {
|
|
303
|
-
min = Math.min(min, 0, cumulative)
|
|
304
|
-
max = Math.max(max, 0, cumulative)
|
|
305
|
-
} else {
|
|
306
|
-
const start = cumulative
|
|
307
|
-
cumulative += Number(d[field]) || 0
|
|
308
|
-
min = Math.min(min, start, cumulative)
|
|
309
|
-
max = Math.max(max, start, cumulative)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return [min, max]
|
|
81
|
+
/** A continuous category axis (a bar race's tweened rank) rather than a band. */
|
|
82
|
+
get continuousCategory() {
|
|
83
|
+
return this.config.continuousCategory
|
|
313
84
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (!field) return null
|
|
318
|
-
const datasets =
|
|
319
|
-
this.#geoms.length > 0 ? this.#geoms.map((g) => this.geomData(g.id)) : [this.#rawData]
|
|
320
|
-
// includeZero applies to the VALUE axis: vertical charts (value on y) and flipped
|
|
321
|
-
// charts (value still y, but now the horizontal screen axis) both want a 0 baseline.
|
|
322
|
-
const includeZero = this.orientation === 'vertical' || this.#flipped
|
|
323
|
-
const yDomain =
|
|
324
|
-
this.#yDomain ??
|
|
325
|
-
this.#resolveBoxDomain() ??
|
|
326
|
-
this.#resolveStackDomain(field) ??
|
|
327
|
-
this.#resolveWaterfallDomain(field)
|
|
328
|
-
// Flip: the value (y) axis runs along the horizontal screen → range over width.
|
|
329
|
-
const range = this.#flipped ? [0, this.#innerWidth] : undefined
|
|
330
|
-
const base = buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero, range })
|
|
331
|
-
return this.#zoomTransform ? this.#zoomTransform.rescaleY(base) : base
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
// Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
|
|
335
|
-
// If the color channel is a CSS literal (e.g. '#4a90d9'), return a singleton map
|
|
336
|
-
// keyed by null so all marks pick it up via the fallback path.
|
|
337
|
-
// If a colorDomain is provided (e.g. from FacetPlot for cross-panel consistency),
|
|
338
|
-
// use it instead of deriving distinct values from the local panel data.
|
|
339
|
-
colors = $derived.by(() => {
|
|
340
|
-
const field = this.#effectiveChannels.color
|
|
341
|
-
if (isLiteralColor(field)) {
|
|
342
|
-
/** @type {Map<unknown, { fill: string, stroke: string }>} */
|
|
343
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
344
|
-
const literal = new Map([[null, { fill: field, stroke: field }]])
|
|
345
|
-
return literal
|
|
346
|
-
}
|
|
347
|
-
const values = this.#colorDomain ?? this.#sharedColorValues()
|
|
348
|
-
// No color channel but data exists → use first preset color for single-series rendering.
|
|
349
|
-
// This prevents geoms from falling back to gray (#888) on charts with no fill channel.
|
|
350
|
-
if (values.length === 0 && this.#data.length > 0) return assignColors([null], this.#mode, this.#chartPreset)
|
|
351
|
-
return assignColors(values, this.#mode, this.#chartPreset)
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
// Patterns: Map<patternKey, patternName> — only populated when a pattern channel is set
|
|
355
|
-
// and the pattern field is categorical (continuous fields can't be discretely patterned).
|
|
356
|
-
patterns = $derived.by(() => {
|
|
357
|
-
const pf = this.#effectiveChannels.pattern
|
|
358
|
-
if (!pf) return new SvelteMap()
|
|
359
|
-
if (inferFieldType(this.#data, pf) === 'continuous') return new SvelteMap()
|
|
360
|
-
return assignPatterns(distinct(this.#data, pf))
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
// Symbols: Map<symbolKey, shapeName> — only populated when a symbol channel is set.
|
|
364
|
-
symbols = $derived.by(() => {
|
|
365
|
-
const sf = this.#effectiveChannels.symbol
|
|
366
|
-
if (!sf) return new SvelteMap()
|
|
367
|
-
return assignSymbols(distinct(this.#data, sf), this.#chartPreset)
|
|
368
|
-
})
|
|
369
|
-
|
|
370
|
-
// Expose effective channel fields for consumers (e.g. Legend).
|
|
371
|
-
// Returns null for literal CSS colors since they don't map to a data field.
|
|
372
|
-
colorField = $derived(
|
|
373
|
-
isLiteralColor(this.#effectiveChannels.color) ? null : this.#effectiveChannels.color
|
|
374
|
-
)
|
|
375
|
-
// Fill field (interior aesthetic), parallel to colorField. Null for a literal CSS color.
|
|
376
|
-
fillField = $derived(
|
|
377
|
-
isLiteralColor(this.#effectiveChannels.fill) ? null : this.#effectiveChannels.fill
|
|
378
|
-
)
|
|
379
|
-
patternField = $derived(this.#effectiveChannels.pattern)
|
|
380
|
-
symbolField = $derived(this.#effectiveChannels.symbol)
|
|
381
|
-
|
|
382
|
-
// Set of geom types currently registered (used by Legend to pick swatch style)
|
|
383
|
-
geomTypes = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
|
|
384
|
-
|
|
385
|
-
xAxisY = $derived.by(() => {
|
|
386
|
-
if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
|
|
387
|
-
const crossVal = this.axisOrigin[1]
|
|
388
|
-
if (crossVal !== undefined) return this.yScale(crossVal)
|
|
389
|
-
const domain = this.yScale.domain?.()
|
|
390
|
-
/* v8 ignore start -- unreachable: yScale is either null (caught by the guard
|
|
391
|
-
above) or a d3 scale from buildUnifiedYScale, whose every return path is a
|
|
392
|
-
scaleLinear or scaleBand — both always expose domain() returning an array.
|
|
393
|
-
`ignore next` does not fire on a single-line `if (...) return`. */
|
|
394
|
-
if (!domain) return this.#innerHeight
|
|
395
|
-
/* v8 ignore stop */
|
|
396
|
-
// Auto quadrant: place x-axis at y=0 when domain spans zero (no offset)
|
|
397
|
-
if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.yScale(0)
|
|
398
|
-
// Q1-only: axis at bottom edge, optionally with offset
|
|
399
|
-
const base = this.yScale(domain[0])
|
|
400
|
-
return this.#axisOffset ? base + this.#axisOffset : base
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
yAxisX = $derived.by(() => {
|
|
404
|
-
if (!this.xScale || typeof this.xScale !== 'function') return 0
|
|
405
|
-
const crossVal = this.axisOrigin[0]
|
|
406
|
-
if (crossVal !== undefined) return this.xScale(crossVal)
|
|
407
|
-
const domain = this.xScale.domain?.()
|
|
408
|
-
if (!domain || typeof this.xScale.bandwidth === 'function') return 0
|
|
409
|
-
// Auto quadrant: place y-axis at x=0 when domain spans zero (no offset)
|
|
410
|
-
if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.xScale(0)
|
|
411
|
-
// Q1-only: axis at left edge, optionally with offset
|
|
412
|
-
const base = this.xScale(domain[0])
|
|
413
|
-
return this.#axisOffset ? base - this.#axisOffset : base
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
constructor(config = {}) {
|
|
417
|
-
this.#rawData = config.data ?? []
|
|
418
|
-
this.#data = config.data ?? []
|
|
419
|
-
this.#channels = config.channels ?? {}
|
|
420
|
-
this.#labels = config.labels ?? {}
|
|
421
|
-
this.#helpers = config.helpers ?? {}
|
|
422
|
-
this.#presetName = config.preset
|
|
423
|
-
this.#colorMidpoint = config.colorMidpoint
|
|
424
|
-
this.#colorSpec = config.colorScale
|
|
425
|
-
this.#colorScheme = config.colorScheme
|
|
426
|
-
this.#colorDomain = config.colorDomain
|
|
427
|
-
this.#xDomain = config.xDomain
|
|
428
|
-
this.#yDomain = config.yDomain
|
|
429
|
-
this.#width = config.width ?? 600
|
|
430
|
-
this.#height = config.height ?? 400
|
|
431
|
-
this.#mode = config.mode ?? 'light'
|
|
432
|
-
this.#chartPreset = config.chartPreset ?? defaultPreset
|
|
433
|
-
this.#onselect = config.onselect
|
|
434
|
-
this.#selectable = config.selectable ?? false
|
|
435
|
-
if (config.selected) this.#selected = new SvelteSet(config.selected)
|
|
436
|
-
this.#axisOffset = config.axisOffset ?? 0
|
|
437
|
-
this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
|
|
438
|
-
this.#marginOverride = config.margin ?? undefined
|
|
439
|
-
this.#orientationOverride = config.orientation ?? undefined
|
|
440
|
-
this.#continuousCategory = config.continuousCategory ?? false
|
|
441
|
-
this.#sort = config.sort ?? undefined
|
|
85
|
+
/** Pin the axis crossing directly — also reachable through `update({ axisOrigin })`. */
|
|
86
|
+
get axisOrigin() {
|
|
87
|
+
return this.config.axisOrigin
|
|
442
88
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (config.height !== undefined) this.#height = config.height
|
|
461
|
-
if (config.mode !== undefined) this.#mode = config.mode
|
|
462
|
-
if (config.chartPreset !== undefined) this.#chartPreset = config.chartPreset
|
|
463
|
-
if (config.onselect !== undefined) this.#onselect = config.onselect
|
|
464
|
-
if (config.selectable !== undefined) this.#selectable = config.selectable
|
|
465
|
-
if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
|
|
466
|
-
this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
|
|
467
|
-
this.#marginOverride = config.margin ?? undefined
|
|
468
|
-
this.#orientationOverride = config.orientation ?? undefined
|
|
469
|
-
this.#continuousCategory = config.continuousCategory ?? false
|
|
470
|
-
this.#sort = config.sort ?? undefined
|
|
89
|
+
set axisOrigin(value) {
|
|
90
|
+
this.config.axisOrigin = value
|
|
91
|
+
}
|
|
92
|
+
label(field) {
|
|
93
|
+
return this.config.label(field)
|
|
94
|
+
}
|
|
95
|
+
format(field) {
|
|
96
|
+
return this.config.format(field)
|
|
97
|
+
}
|
|
98
|
+
tooltip() {
|
|
99
|
+
return this.config.tooltip()
|
|
100
|
+
}
|
|
101
|
+
geomComponent(type) {
|
|
102
|
+
return this.config.geomComponent(type)
|
|
103
|
+
}
|
|
104
|
+
preset() {
|
|
105
|
+
return this.config.resolvedPreset()
|
|
471
106
|
}
|
|
472
107
|
|
|
108
|
+
// ─── geoms ─────────────────────────────────────────────────────────────────
|
|
473
109
|
registerGeom(config) {
|
|
474
|
-
|
|
475
|
-
this.#geoms = [...this.#geoms, { id, ...config }]
|
|
476
|
-
return id
|
|
110
|
+
return this.geoms.register(config)
|
|
477
111
|
}
|
|
478
|
-
|
|
479
112
|
updateGeom(id, config) {
|
|
480
|
-
|
|
481
|
-
// called from a geom's $effect (which would otherwise track #geoms as a dependency)
|
|
482
|
-
this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
|
|
113
|
+
this.geoms.update(id, config)
|
|
483
114
|
}
|
|
484
|
-
|
|
485
115
|
unregisterGeom(id) {
|
|
486
|
-
this
|
|
116
|
+
this.geoms.unregister(id)
|
|
487
117
|
}
|
|
488
|
-
|
|
489
118
|
geomData(id) {
|
|
490
|
-
|
|
491
|
-
if (!geom) return []
|
|
492
|
-
const stat = geom.stat ?? 'identity'
|
|
493
|
-
if (stat === 'identity') return this.#rawData
|
|
494
|
-
// Strip explicit `undefined` values before spreading: a geom that omits a channel
|
|
495
|
-
// to inherit it from the container sends `{ x: undefined }`, not `{}`, which would
|
|
496
|
-
// otherwise become an own property that clobbers the container's value below (see
|
|
497
|
-
// SparkState.geomData for the fuller writeup — same bug, same fix, both classes).
|
|
498
|
-
const geomChannels = Object.fromEntries(
|
|
499
|
-
Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
|
|
500
|
-
)
|
|
501
|
-
const mergedChannels = { ...this.#channels, ...geomChannels }
|
|
502
|
-
return applyGeomStat(this.#rawData, { stat, channels: mergedChannels }, this.#helpers)
|
|
119
|
+
return this.geoms.data(id)
|
|
503
120
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return this
|
|
121
|
+
/** The legend picks a swatch style from these. */
|
|
122
|
+
get geomTypes() {
|
|
123
|
+
return this.geoms.types
|
|
507
124
|
}
|
|
508
125
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
tooltip() {
|
|
513
|
-
return resolveTooltip(this.#helpers)
|
|
126
|
+
// ─── frame ─────────────────────────────────────────────────────────────────
|
|
127
|
+
get margin() {
|
|
128
|
+
return this.frame.margin
|
|
514
129
|
}
|
|
515
|
-
|
|
516
|
-
return
|
|
130
|
+
get innerWidth() {
|
|
131
|
+
return this.frame.innerWidth
|
|
517
132
|
}
|
|
518
|
-
|
|
519
|
-
return
|
|
133
|
+
get innerHeight() {
|
|
134
|
+
return this.frame.innerHeight
|
|
520
135
|
}
|
|
521
136
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// array separately, so their elements are not ===. Same content, aligned identity.
|
|
527
|
-
return this.#rawData
|
|
137
|
+
// ─── channels ──────────────────────────────────────────────────────────────
|
|
138
|
+
/** Null for a literal CSS colour — it maps to no field. */
|
|
139
|
+
get colorField() {
|
|
140
|
+
return this.channelState.colorField
|
|
528
141
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
142
|
+
get fillField() {
|
|
143
|
+
return this.channelState.fillField
|
|
144
|
+
}
|
|
145
|
+
get patternField() {
|
|
146
|
+
return this.channelState.patternField
|
|
147
|
+
}
|
|
148
|
+
get symbolField() {
|
|
149
|
+
return this.channelState.symbolField
|
|
532
150
|
}
|
|
533
151
|
|
|
534
|
-
// ───
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
get isFlipped() {
|
|
538
|
-
return this.#flipped
|
|
152
|
+
// ─── orientation ───────────────────────────────────────────────────────────
|
|
153
|
+
get orientation() {
|
|
154
|
+
return this.orientationState.orientation
|
|
539
155
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return this.#continuousCategory
|
|
156
|
+
/** Rendered horizontally: the band axis stands up the screen. Channels are unchanged. */
|
|
157
|
+
get isFlipped() {
|
|
158
|
+
return this.orientationState.flipped
|
|
544
159
|
}
|
|
545
|
-
|
|
546
|
-
// the two screen axes swap. Geoms compute u = xScale(d[x]), v = yScale(d[y]) then
|
|
547
|
-
// `const { x, y } = plotState.place(u, v)`.
|
|
160
|
+
/** Channel space to screen: `const { x, y } = plotState.place(xScale(d[x]), yScale(d[y]))`. */
|
|
548
161
|
place(u, v) {
|
|
549
|
-
return this
|
|
162
|
+
return this.orientationState.place(u, v)
|
|
550
163
|
}
|
|
551
|
-
|
|
552
|
-
//
|
|
164
|
+
|
|
165
|
+
// ─── scales ────────────────────────────────────────────────────────────────
|
|
166
|
+
get xScale() {
|
|
167
|
+
return this.scales.x
|
|
168
|
+
}
|
|
169
|
+
get yScale() {
|
|
170
|
+
return this.scales.y
|
|
171
|
+
}
|
|
172
|
+
/** The categorical scale, whichever screen axis it is on. */
|
|
553
173
|
get bandScale() {
|
|
554
|
-
return this
|
|
174
|
+
return this.scales.band
|
|
555
175
|
}
|
|
176
|
+
/** The value scale, whichever screen axis it is on. */
|
|
556
177
|
get valueScale() {
|
|
557
|
-
return this
|
|
178
|
+
return this.scales.value
|
|
558
179
|
}
|
|
559
|
-
|
|
560
|
-
|
|
180
|
+
|
|
181
|
+
// ─── aesthetics ────────────────────────────────────────────────────────────
|
|
182
|
+
get colors() {
|
|
183
|
+
return this.aesthetics.colors
|
|
561
184
|
}
|
|
562
|
-
get
|
|
563
|
-
return this
|
|
185
|
+
get patterns() {
|
|
186
|
+
return this.aesthetics.patterns
|
|
564
187
|
}
|
|
565
|
-
get
|
|
566
|
-
return this
|
|
188
|
+
get symbols() {
|
|
189
|
+
return this.aesthetics.symbols
|
|
567
190
|
}
|
|
568
|
-
get
|
|
569
|
-
return this
|
|
191
|
+
get colorScaleType() {
|
|
192
|
+
return this.aesthetics.colorScaleType
|
|
570
193
|
}
|
|
571
|
-
get
|
|
572
|
-
return this
|
|
194
|
+
get continuousColorScale() {
|
|
195
|
+
return this.aesthetics.continuousColorScale
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── axes ──────────────────────────────────────────────────────────────────
|
|
199
|
+
get xAxisY() {
|
|
200
|
+
return this.axes.xAxisY
|
|
201
|
+
}
|
|
202
|
+
get yAxisX() {
|
|
203
|
+
return this.axes.yAxisX
|
|
573
204
|
}
|
|
205
|
+
|
|
206
|
+
// ─── interaction ───────────────────────────────────────────────────────────
|
|
574
207
|
get hovered() {
|
|
575
|
-
return this
|
|
208
|
+
return this.interactionState.hovered
|
|
576
209
|
}
|
|
577
210
|
get interactive() {
|
|
578
|
-
return
|
|
211
|
+
return this.interactionState.interactive
|
|
579
212
|
}
|
|
580
213
|
get selectedRows() {
|
|
581
|
-
return
|
|
214
|
+
return this.interactionState.selectedRows
|
|
582
215
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
this.#hovered = data
|
|
216
|
+
setHovered(row) {
|
|
217
|
+
this.interactionState.setHovered(row)
|
|
586
218
|
}
|
|
587
219
|
clearHovered() {
|
|
588
|
-
this
|
|
220
|
+
this.interactionState.clearHovered()
|
|
589
221
|
}
|
|
590
222
|
isSelected(row) {
|
|
591
|
-
return this
|
|
223
|
+
return this.interactionState.isSelected(row)
|
|
592
224
|
}
|
|
593
225
|
setSelected(rows) {
|
|
594
|
-
this
|
|
226
|
+
this.interactionState.setSelected(rows)
|
|
595
227
|
}
|
|
596
228
|
clearSelected() {
|
|
597
|
-
this
|
|
229
|
+
this.interactionState.clearSelected()
|
|
598
230
|
}
|
|
599
231
|
handleSelect(detail) {
|
|
600
|
-
this
|
|
601
|
-
if (this.#selectable && detail?.datum !== undefined) {
|
|
602
|
-
if (this.#selected.has(detail.datum)) this.#selected.delete(detail.datum)
|
|
603
|
-
else this.#selected.add(detail.datum)
|
|
604
|
-
}
|
|
232
|
+
this.interactionState.handleSelect(detail)
|
|
605
233
|
}
|
|
606
|
-
|
|
607
234
|
applyZoom(transform) {
|
|
608
|
-
this
|
|
235
|
+
this.interactionState.applyZoom(transform)
|
|
609
236
|
}
|
|
610
237
|
resetZoom() {
|
|
611
|
-
this
|
|
238
|
+
this.interactionState.resetZoom()
|
|
612
239
|
}
|
|
613
240
|
}
|