@rokkit/chart 1.3.15 → 1.4.0
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/Plot.svelte +25 -9
- package/dist/PlotState.svelte.d.ts +1 -0
- package/dist/PlotState.svelte.js +8 -1
- package/dist/Spark.svelte +165 -0
- package/dist/Spark.svelte.d.ts +31 -0
- package/dist/SparkState.svelte.d.ts +138 -0
- package/dist/SparkState.svelte.js +331 -0
- package/dist/Sparkline.svelte +59 -232
- package/dist/Sparkline.svelte.d.ts +2 -0
- package/dist/charts/RadarChart.svelte +106 -0
- package/dist/charts/RadarChart.svelte.d.ts +43 -0
- package/dist/geoms/Area.svelte +17 -2
- package/dist/geoms/Area.svelte.d.ts +2 -0
- package/dist/geoms/Bar.svelte +2 -9
- package/dist/geoms/Highlight.svelte +2 -7
- package/dist/geoms/Line.svelte +2 -8
- package/dist/geoms/Point.svelte +2 -8
- package/dist/geoms/Radar.svelte +352 -0
- package/dist/geoms/Radar.svelte.d.ts +42 -0
- package/dist/geoms/lib/aesthetics.d.ts +14 -0
- package/dist/geoms/lib/aesthetics.js +21 -0
- package/dist/geoms/lib/areas.d.ts +9 -2
- package/dist/geoms/lib/areas.js +53 -8
- package/dist/geoms/lib/marks/area.d.ts +3 -0
- package/dist/geoms/lib/marks/area.js +11 -2
- package/dist/geoms/lib/marks/radar.d.ts +68 -0
- package/dist/geoms/lib/marks/radar.js +153 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -1
- package/dist/lib/brewing/marks/boxes.js +19 -30
- package/dist/lib/brewing/polar.d.ts +433 -0
- package/dist/lib/brewing/polar.js +584 -0
- package/dist/lib/plot/scales.js +8 -5
- package/dist/lib/preset.d.ts +1 -0
- package/dist/lib/preset.js +2 -1
- package/dist/lib/select.d.ts +3 -3
- package/dist/lib/select.js +1 -1
- package/package.json +1 -1
- package/dist/examples/BarChartExample.svelte +0 -78
- package/dist/examples/BarChartExample.svelte.d.ts +0 -18
- package/dist/lib/brewing/types.d.ts +0 -162
- package/dist/lib/brewing/types.js +0 -73
- package/dist/lib/xscale.d.ts +0 -11
- package/dist/lib/xscale.js +0 -31
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { untrack } from 'svelte'
|
|
2
|
+
import { buildUnifiedXScale, buildUnifiedYScale } from './lib/plot/scales.js'
|
|
3
|
+
import { defaultPreset } from './lib/preset.js'
|
|
4
|
+
import { applyGeomStat } from './lib/plot/stat.js'
|
|
5
|
+
import { distinct, assignColors } from './lib/brewing/colors.js'
|
|
6
|
+
import { PATTERNS } from './patterns/patterns.js'
|
|
7
|
+
|
|
8
|
+
let nextGeomId = 0
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The full set of members a geom reads off its `'plot-state'` context — derived by
|
|
12
|
+
* scanning every geom for `plotState.*` / `plot.*` / `state.*` reads (`geoms/*.svelte`,
|
|
13
|
+
* `geoms/lib/**`, including the `marks/*.js` adapters). `SparkState` must implement
|
|
14
|
+
* every one of these: a `<Spark>` composes the SAME geom components a `<Plot>` does,
|
|
15
|
+
* and no geom branches on which container it's inside — so a member missing here is a
|
|
16
|
+
* member that throws inside a `<Spark>` at runtime, not at the point that grew it.
|
|
17
|
+
*
|
|
18
|
+
* This is the ONE place to update when that surface legitimately changes. Adding a
|
|
19
|
+
* geom (or a new context read inside an existing one) that needs a `PlotState` member
|
|
20
|
+
* means adding it here too. `spec/spark-contract.spec.js` asserts `SparkState` and
|
|
21
|
+
* `PlotState` both satisfy this exact list and agree on each member's kind (function /
|
|
22
|
+
* Map / array / null / primitive). If that test goes red after you add a member here,
|
|
23
|
+
* that is the signal working as intended — it means `SparkState` is missing the
|
|
24
|
+
* member, not that the test is wrong. Do not "fix" a red conformance test by deleting
|
|
25
|
+
* the entry you just added.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately excluded — read only by `Plot`'s chrome (`Axis`/`Legend`/`Tooltip`/
|
|
28
|
+
* `Grid`) or by `PlotSurface`'s own wiring, never by a geom: `xAxisY`, `yAxisX`,
|
|
29
|
+
* `hovered`, `mode`, `colorScaleType`, `geomTypes`, `colorField`, `patternField`,
|
|
30
|
+
* `symbolField`, `format`, `label`, `tooltip`, `margin`, `bandScale`, `valueScale`,
|
|
31
|
+
* `preset`, `geomComponent`. A `<Spark>` has no axis, legend, tooltip or grid to
|
|
32
|
+
* drive, so none of these ever apply.
|
|
33
|
+
*
|
|
34
|
+
* Also excluded: `selectedRows` / `isSelected` / `setSelected` / `clearSelected` /
|
|
35
|
+
* `applyZoom` / `resetZoom`. `geoms/Highlight.svelte` is the one geom that reads
|
|
36
|
+
* `state?.selectedRows`, but it does so defensively (`?? []`) — multi-select and zoom
|
|
37
|
+
* are explicit non-goals for a spark (see
|
|
38
|
+
* docs/superpowers/specs/2026-08-20-spark-plot-geom-architecture-design.md), so their
|
|
39
|
+
* absence degrades Highlight to "nothing selected", not a runtime error. That's a
|
|
40
|
+
* different failure mode than every member below, where absence throws.
|
|
41
|
+
*/
|
|
42
|
+
export const GEOM_CONTRACT = [
|
|
43
|
+
// ─── Scales + dimensions ───────────────────────────────────────────────────
|
|
44
|
+
'xScale',
|
|
45
|
+
'yScale',
|
|
46
|
+
'innerWidth',
|
|
47
|
+
'innerHeight',
|
|
48
|
+
// ─── Geom lifecycle ────────────────────────────────────────────────────────
|
|
49
|
+
'registerGeom',
|
|
50
|
+
'updateGeom',
|
|
51
|
+
'unregisterGeom',
|
|
52
|
+
'geomData',
|
|
53
|
+
// ─── Data ──────────────────────────────────────────────────────────────────
|
|
54
|
+
'data',
|
|
55
|
+
'channels',
|
|
56
|
+
// ─── Aesthetics ────────────────────────────────────────────────────────────
|
|
57
|
+
'colors',
|
|
58
|
+
'patterns',
|
|
59
|
+
'symbols',
|
|
60
|
+
'chartPreset',
|
|
61
|
+
// ─── Orientation ───────────────────────────────────────────────────────────
|
|
62
|
+
'place',
|
|
63
|
+
'isFlipped',
|
|
64
|
+
'orientation',
|
|
65
|
+
// ─── Colour typing ─────────────────────────────────────────────────────────
|
|
66
|
+
'continuousCategory',
|
|
67
|
+
'continuousColorScale',
|
|
68
|
+
// ─── Interactivity ─────────────────────────────────────────────────────────
|
|
69
|
+
'interactive',
|
|
70
|
+
'handleSelect',
|
|
71
|
+
'setHovered',
|
|
72
|
+
'clearHovered'
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* SparkState — the lean, PlotState-compatible context that lets a sparkline compose
|
|
77
|
+
* real geoms (`<Spark><Line /></Spark>`) instead of Sparkline's own hand-rolled
|
|
78
|
+
* line/area/bar rendering. It publishes on the same 'plot-state' context key as
|
|
79
|
+
* PlotState, so geoms (Line/Area/Bar) read scales, channels and data through the
|
|
80
|
+
* identical interface and never know whether they're inside a spark or a full Plot.
|
|
81
|
+
*
|
|
82
|
+
* An 80×24 glyph has no use for most of what PlotState carries, so this is a thin
|
|
83
|
+
* composition of the same pure scale modules PlotState uses — not a re-implementation
|
|
84
|
+
* of PlotState's scope. It publishes the same registerGeom/updateGeom/unregisterGeom/
|
|
85
|
+
* geomData lifecycle as PlotState (see those methods below), at smaller scope, so
|
|
86
|
+
* GeomState — and therefore any geom component — drives a spark unchanged. Deliberately
|
|
87
|
+
* omitted, permanently (a <Spark> container is a later slice, but none of these ever
|
|
88
|
+
* apply to an 80×24 glyph):
|
|
89
|
+
* - zoom/pan transforms — a spark is a static glyph, never panned or zoomed.
|
|
90
|
+
* - crossfilter/selection — nothing to click or brush at this size.
|
|
91
|
+
* - facets — a spark is always a single small panel, never a grid of panels.
|
|
92
|
+
* - axis/legend chrome and margins — width/height ARE the inner box.
|
|
93
|
+
*
|
|
94
|
+
* This class also carries the rest of the surface a geom reads off `plot-state`:
|
|
95
|
+
* `colors` — a real categorical palette, built from the same brewing helpers PlotState
|
|
96
|
+
* uses, keyed off `channels.color ?? channels.fill` — plus a set of members geoms read
|
|
97
|
+
* but a spark has no use for (symbols/isFlipped/orientation/continuousCategory/
|
|
98
|
+
* continuousColorScale/interactive/place/setHovered/clearHovered/handleSelect). Those are
|
|
99
|
+
* kept present and explicit — empty Maps, false/null/identity, no-op methods — rather
|
|
100
|
+
* than simply absent, so a geom never needs a spark-specific branch and a later
|
|
101
|
+
* conformance test can pin the full contract. `patterns` is the one exception with a real,
|
|
102
|
+
* non-empty case — see its own doc comment below for why a literal name is the only
|
|
103
|
+
* sensible spark case.
|
|
104
|
+
*
|
|
105
|
+
* `data` deliberately holds ONE array (`$state.raw`, not a proxying `$state`), unlike
|
|
106
|
+
* PlotState's `#data`/`#rawData` pair. That split gave PlotState's data two distinct
|
|
107
|
+
* proxy identities for the same source array, breaking identity-based lookups like
|
|
108
|
+
* `plotState.data.indexOf(row)` (see journal 2026-08-13). Geoms will later read rows
|
|
109
|
+
* from this same array, so its identity must be exactly what the caller passed in.
|
|
110
|
+
*/
|
|
111
|
+
export class SparkState {
|
|
112
|
+
#data = $state.raw([])
|
|
113
|
+
#channels = $state({})
|
|
114
|
+
#width = $state(80)
|
|
115
|
+
#height = $state(24)
|
|
116
|
+
#min = $state(undefined)
|
|
117
|
+
#max = $state(undefined)
|
|
118
|
+
#baseline = $state(undefined)
|
|
119
|
+
#pattern = $state(undefined)
|
|
120
|
+
#geoms = $state([])
|
|
121
|
+
|
|
122
|
+
// Mirrors Sparkline's current yMin/yMax logic: an explicit min/max wins; otherwise
|
|
123
|
+
// the domain is the raw data extent, widened to include the baseline when one is
|
|
124
|
+
// set (so a below-baseline dip or an above-baseline spike stays on-canvas).
|
|
125
|
+
// Non-numeric/missing y values coerce to NaN and are filtered out before min/max —
|
|
126
|
+
// otherwise one bad row poisons the whole domain to NaN (silent blank render).
|
|
127
|
+
// A single distinct finite value (flat line) deliberately falls through to a
|
|
128
|
+
// zero-extent domain ([v, v]); d3 renders that as a flat mid-scale line, which is
|
|
129
|
+
// the correct flat-line behaviour, not a bug to be guarded against later.
|
|
130
|
+
#yDomain = $derived.by(() => {
|
|
131
|
+
const field = this.#channels.y
|
|
132
|
+
const values = this.#data.map((d) => Number(d[field])).filter((v) => Number.isFinite(v))
|
|
133
|
+
const rawMin = values.length > 0 ? Math.min(...values) : 0
|
|
134
|
+
const rawMax = values.length > 0 ? Math.max(...values) : 0
|
|
135
|
+
const min =
|
|
136
|
+
this.#min ?? (this.#baseline !== undefined ? Math.min(rawMin, this.#baseline) : rawMin)
|
|
137
|
+
const max =
|
|
138
|
+
this.#max ?? (this.#baseline !== undefined ? Math.max(rawMax, this.#baseline) : rawMax)
|
|
139
|
+
return [min, max]
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// nice:false — a spark's rightmost point should reach the right edge of its box; the
|
|
143
|
+
// padding nice() adds for pretty tick labels only shrinks the glyph a sparkline has no
|
|
144
|
+
// ticks to label (mirrors the same reasoning as yScale's nice:false below).
|
|
145
|
+
xScale = $derived.by(() =>
|
|
146
|
+
buildUnifiedXScale([this.#data], this.#channels.x, this.#width, { nice: false })
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// nice:false — a spark's peak/trough should reach the edge of its box; the padding
|
|
150
|
+
// nice() adds for pretty tick labels only shrinks the glyph a sparkline has no ticks to label.
|
|
151
|
+
yScale = $derived.by(() =>
|
|
152
|
+
buildUnifiedYScale([this.#data], this.#channels.y, this.#height, {
|
|
153
|
+
domain: this.#yDomain,
|
|
154
|
+
nice: false
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Colors: Map<seriesKey, { fill, stroke }> for the one series a spark renders. Mirrors
|
|
159
|
+
// PlotState.colors at spark scope — no colorDomain override, no cross-geom union, no
|
|
160
|
+
// isLiteralColor handling, because a spark has exactly one geom's worth of channels
|
|
161
|
+
// and its color/fill channel always names a data field, never a literal CSS color.
|
|
162
|
+
// Falls back to a single-entry palette (a flat one-color glyph) when there's no
|
|
163
|
+
// color/fill channel at all, so a geom's `colors.get(key)` lookup is never empty-handed.
|
|
164
|
+
colors = $derived.by(() => {
|
|
165
|
+
const field = this.#channels.color ?? this.#channels.fill
|
|
166
|
+
const values = field ? distinct(this.#data, field) : [null]
|
|
167
|
+
return assignColors(values, 'light', defaultPreset)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
constructor(config = {}) {
|
|
171
|
+
this.update(config)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Re-callable: a later task's <Spark> container calls this from an `$effect` on
|
|
175
|
+
// every prop change, so it must fully re-apply config rather than merge deltas.
|
|
176
|
+
update(config = {}) {
|
|
177
|
+
this.#data = config.data ?? []
|
|
178
|
+
this.#channels = config.channels ?? {}
|
|
179
|
+
this.#width = config.width ?? 80
|
|
180
|
+
this.#height = config.height ?? 24
|
|
181
|
+
this.#min = config.min
|
|
182
|
+
this.#max = config.max
|
|
183
|
+
this.#baseline = config.baseline
|
|
184
|
+
this.#pattern = config.pattern
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get data() {
|
|
188
|
+
return this.#data
|
|
189
|
+
}
|
|
190
|
+
/** @returns {{ x?: string, y?: string }} */
|
|
191
|
+
get channels() {
|
|
192
|
+
return this.#channels
|
|
193
|
+
}
|
|
194
|
+
// Sparks have no margin — width/height ARE the inner box geoms draw into.
|
|
195
|
+
get innerWidth() {
|
|
196
|
+
return this.#width
|
|
197
|
+
}
|
|
198
|
+
get innerHeight() {
|
|
199
|
+
return this.#height
|
|
200
|
+
}
|
|
201
|
+
get chartPreset() {
|
|
202
|
+
return defaultPreset
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Inert members ──────────────────────────────────────────────────────────
|
|
206
|
+
// Every member below is read by some geom through the shared 'plot-state' context —
|
|
207
|
+
// e.g. Line.svelte calls `plotState.patterns.get(key)` and `plotState.place(u, v)`
|
|
208
|
+
// unconditionally, with no branch for "am I inside a Plot or a Spark?" Kept present
|
|
209
|
+
// and explicit (not simply absent) so that contract holds for a spark too.
|
|
210
|
+
|
|
211
|
+
// A spark never sets a pattern CHANNEL (there's no per-row field to group on), but a
|
|
212
|
+
// `<Spark pattern="diagonal">` prop names a single, literal pattern to texture the
|
|
213
|
+
// whole fill with. PlotState's `patterns` Map exists to solve "which of these several
|
|
214
|
+
// distinct field values gets which pattern from PATTERN_ORDER" — `assignPatterns`
|
|
215
|
+
// solves that BY INDEX. A spark is single-series: that grouping problem does not
|
|
216
|
+
// exist here, so there is nothing to assign an index to. "Texture this fill" is the
|
|
217
|
+
// only meaning `pattern` can have on a spark, and the value already IS the pattern
|
|
218
|
+
// name — so the Map self-maps it (`[[name, name]]`) instead of going through
|
|
219
|
+
// `assignPatterns`, which would silently substitute PATTERN_ORDER[0] regardless of
|
|
220
|
+
// what was asked for.
|
|
221
|
+
//
|
|
222
|
+
// This still routes through the exact shared mechanism PlotState uses, unchanged:
|
|
223
|
+
// `DefinePatterns` iterates `[key, patternName]` and renders `<pattern
|
|
224
|
+
// id={toPatternId(key)}>`; `buildAreas`/`buildBars` independently compute
|
|
225
|
+
// `toPatternId(row[patternField])` and fill with `url(#...)`. Both call `toPatternId`
|
|
226
|
+
// on the SAME value here (the literal name), so they agree by construction — no geom
|
|
227
|
+
// or pattern-consuming helper needs to know a spark is involved at all.
|
|
228
|
+
//
|
|
229
|
+
// An unset or unknown name (not a key of PATTERNS) falls through to an empty Map —
|
|
230
|
+
// same reasoning as `assignPatterns` never inventing a texture for a channel that
|
|
231
|
+
// isn't there: a typo'd pattern name must not silently resolve to SOME pattern, and
|
|
232
|
+
// `DefinePatterns` skips any Map entry whose name isn't a real `PATTERNS` key anyway
|
|
233
|
+
// (`patterns[patternName] ?? []`), so keeping a bogus entry out of the Map here is
|
|
234
|
+
// belt-and-braces, not load-bearing.
|
|
235
|
+
// A plain Map (not SvelteMap): this is a fresh value on every read, built from `#pattern`
|
|
236
|
+
// (a $state field), so reactivity is already covered by that read — nothing here needs
|
|
237
|
+
// its own reactive container.
|
|
238
|
+
get patterns() {
|
|
239
|
+
if (this.#pattern !== undefined && Object.hasOwn(PATTERNS, this.#pattern)) {
|
|
240
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
241
|
+
return new Map([[this.#pattern, this.#pattern]])
|
|
242
|
+
}
|
|
243
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
244
|
+
return new Map()
|
|
245
|
+
}
|
|
246
|
+
get symbols() {
|
|
247
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
248
|
+
return new Map()
|
|
249
|
+
}
|
|
250
|
+
// A spark has no axis to stand up or lie down — "flipped" describes rotating a
|
|
251
|
+
// category axis onto the screen's vertical, which doesn't exist at this size.
|
|
252
|
+
get isFlipped() {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
// Fixed for the same reason as isFlipped — there's no axis to be vertical/horizontal
|
|
256
|
+
// about. 'vertical' matches PlotState's own default for an unresolved orientation.
|
|
257
|
+
get orientation() {
|
|
258
|
+
return 'vertical'
|
|
259
|
+
}
|
|
260
|
+
// continuousCategory only matters for a banded x-axis kept linear so tweened
|
|
261
|
+
// positions stay smooth (a bar-chart race) — a spark has no band axis to keep linear.
|
|
262
|
+
get continuousCategory() {
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
// No sequential/diverging scale — `colors` above is always the categorical map, so a
|
|
266
|
+
// geom that checks continuousColorScale before falling back to `colors` takes the
|
|
267
|
+
// categorical path, same as it would for a plain categorical Plot.
|
|
268
|
+
get continuousColorScale() {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
// A spark has no onselect prop and no legend to drive off hover/click — nothing
|
|
272
|
+
// on it is ever clickable.
|
|
273
|
+
get interactive() {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
// Identity: a spark's screen is never flipped (see isFlipped above), so a geom's
|
|
277
|
+
// u/v scale outputs map straight through to x/y with no axis swap.
|
|
278
|
+
place(u, v) {
|
|
279
|
+
return { x: u, y: v }
|
|
280
|
+
}
|
|
281
|
+
// No-ops: geoms call these from hover/click handlers, which a spark never wires up
|
|
282
|
+
// (see `interactive` above) — there's no hover/selection state here to record.
|
|
283
|
+
setHovered() {}
|
|
284
|
+
clearHovered() {}
|
|
285
|
+
handleSelect() {}
|
|
286
|
+
|
|
287
|
+
// Mirrors PlotState.registerGeom at smaller scope: GeomState.register() (called
|
|
288
|
+
// from a geom's onMount) calls this once per mounted geom and keeps the returned id.
|
|
289
|
+
registerGeom(config) {
|
|
290
|
+
const id = `spark-geom-${nextGeomId++}`
|
|
291
|
+
this.#geoms = [...this.#geoms, { id, ...config }]
|
|
292
|
+
return id
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// untrack the read of #geoms, same as PlotState: GeomState.sync() calls this from
|
|
296
|
+
// a geom's `$effect`, which would otherwise track #geoms as a dependency and retrigger
|
|
297
|
+
// itself on every update (effect_update_depth_exceeded).
|
|
298
|
+
updateGeom(id, config) {
|
|
299
|
+
this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// No untrack needed here (unlike updateGeom above): this only ever runs from a geom's
|
|
303
|
+
// onDestroy, which Svelte always executes outside any reactive tracking scope — there's
|
|
304
|
+
// no enclosing effect for a #geoms read to register against, so no self-retrigger risk
|
|
305
|
+
// exists. Mirrors PlotState.unregisterGeom, which is untrack-free for the same reason.
|
|
306
|
+
unregisterGeom(id) {
|
|
307
|
+
this.#geoms = this.#geoms.filter((g) => g.id !== id)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Identity stat returns `this.#data` itself, not a copy — geoms look up rows via
|
|
311
|
+
// `plotState.data.indexOf(row)` (see e.g. Line.svelte's selectPoint), which only
|
|
312
|
+
// works if geomData's rows are the exact same object instances as `data`.
|
|
313
|
+
geomData(id) {
|
|
314
|
+
const geom = this.#geoms.find((g) => g.id === id)
|
|
315
|
+
if (!geom) return []
|
|
316
|
+
const stat = geom.stat ?? 'identity'
|
|
317
|
+
if (stat === 'identity') return this.#data
|
|
318
|
+
// Strip explicit `undefined` values before spreading: geom components always pass
|
|
319
|
+
// every channel key (e.g. Line.svelte's `channels: { x, y, color, fill, symbol }`),
|
|
320
|
+
// so a geom that omits x/y to inherit them from the container sends
|
|
321
|
+
// `{ x: undefined, y: undefined }`, not `{}`. Left unstripped, that `undefined`
|
|
322
|
+
// becomes an own property that overrides the container's value in the spread below,
|
|
323
|
+
// silently clobbering an inherited channel (applyGeomStat's primary-key lookup then
|
|
324
|
+
// finds nothing and falls back to identity with no warning).
|
|
325
|
+
const geomChannels = Object.fromEntries(
|
|
326
|
+
Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
|
|
327
|
+
)
|
|
328
|
+
const mergedChannels = { ...this.#channels, ...geomChannels }
|
|
329
|
+
return applyGeomStat(this.#data, { stat, channels: mergedChannels })
|
|
330
|
+
}
|
|
331
|
+
}
|
package/dist/Sparkline.svelte
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import Spark from './Spark.svelte'
|
|
3
|
+
import Line from './geoms/Line.svelte'
|
|
4
|
+
import Area from './geoms/Area.svelte'
|
|
5
|
+
import Bar from './geoms/Bar.svelte'
|
|
6
|
+
import Trend from './geoms/Trend.svelte'
|
|
7
|
+
import Highlight from './geoms/Highlight.svelte'
|
|
5
8
|
import { PATTERNS } from './patterns/patterns.js'
|
|
6
9
|
import { resolveHighlight } from './lib/highlight.js'
|
|
7
|
-
import { computeTrend } from './lib/trend.js'
|
|
8
10
|
|
|
9
11
|
type HighlightSelector =
|
|
10
12
|
| 'first'
|
|
@@ -30,6 +32,8 @@
|
|
|
30
32
|
baseline?: number
|
|
31
33
|
highlight?: HighlightSelector | HighlightSelector[]
|
|
32
34
|
trend?: TrendMethod | TrendMethod[]
|
|
35
|
+
/** Accessible name, forwarded to `Spark`. Omitted → generated from the series. */
|
|
36
|
+
label?: string
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
let {
|
|
@@ -45,45 +49,34 @@
|
|
|
45
49
|
max = undefined,
|
|
46
50
|
baseline = undefined,
|
|
47
51
|
highlight = undefined,
|
|
48
|
-
trend = undefined
|
|
52
|
+
trend = undefined,
|
|
53
|
+
label = undefined
|
|
49
54
|
}: Props = $props()
|
|
50
55
|
|
|
51
56
|
const values = $derived(
|
|
52
|
-
data.map((d) =>
|
|
53
|
-
field && typeof d === 'object' && d !== null ? Number(d[field]) : Number(d)
|
|
54
|
-
)
|
|
57
|
+
data.map((d) => (field && typeof d === 'object' && d !== null ? Number(d[field]) : Number(d)))
|
|
55
58
|
)
|
|
56
59
|
|
|
57
|
-
// Adapter to the pure geom utilities (they operate on {x,y} rows).
|
|
58
|
-
const rows = $derived(values.map((v, i) => ({ x: i, y: v })))
|
|
59
|
-
|
|
60
60
|
const hasNegative = $derived(values.some((v) => v < 0))
|
|
61
61
|
// Bars with negative values are meaningless when measured from the pixel bottom, so a
|
|
62
62
|
// bar sparkline auto-anchors at 0 when any value is negative. Everything else is opt-in.
|
|
63
63
|
const effectiveBaseline = $derived(baseline ?? (type === 'bar' && hasNegative ? 0 : undefined))
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
max ?? (effectiveBaseline !== undefined ? Math.max(rawMax, effectiveBaseline) : rawMax)
|
|
65
|
+
// Adapter to the shared geom pipeline: Spark/SparkState/geoms all read rows keyed by
|
|
66
|
+
// channel name, same as a full Plot — 'x'/'y' here. A geom's `pattern` prop is a
|
|
67
|
+
// data-field CHANNEL, not a literal name, so the literal pattern name is also written onto
|
|
68
|
+
// every row as an ordinary column, and that column name is what's handed to the geom below.
|
|
69
|
+
// `SparkState.patterns` (driven by `Spark`'s own `pattern` prop) maps the same literal name
|
|
70
|
+
// to itself, so both sides resolve `toPatternId(name)` in agreement — no new mechanism.
|
|
71
|
+
const rows = $derived.by(() =>
|
|
72
|
+
values.map((v, i) => (pattern !== undefined ? { x: i, y: v, pattern } : { x: i, y: v }))
|
|
74
73
|
)
|
|
74
|
+
const patternChannel = $derived(pattern !== undefined ? 'pattern' : undefined)
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
const yScale = $derived(scaleLinear().domain([yMin, yMax]).range([height, 0]))
|
|
82
|
-
|
|
83
|
-
// When no baseline is in effect, anchor at the pixel bottom — preserves the classic
|
|
84
|
-
// min-anchored sparkbar look (Math.min/abs below both collapse to the old formula).
|
|
85
|
-
const barAnchorY = $derived(effectiveBaseline !== undefined ? yScale(effectiveBaseline) : height)
|
|
86
|
-
|
|
76
|
+
// The Highlight geom's own `highlight` prop takes exactly ONE selector. Sparkline's contract
|
|
77
|
+
// additionally accepts an array with overlap dedup (e.g. ['last', 3] on a 4-point series
|
|
78
|
+
// name the same point) — resolved here into a single index-predicate, the one selector shape
|
|
79
|
+
// every geom already understands, so Highlight.svelte needs no change.
|
|
87
80
|
const highlightSelectors = $derived(
|
|
88
81
|
Array.isArray(highlight)
|
|
89
82
|
? highlight
|
|
@@ -96,217 +89,51 @@
|
|
|
96
89
|
for (const sel of highlightSelectors) {
|
|
97
90
|
for (const i of resolveHighlight(rows, sel, { y: 'y' })) seen.add(i)
|
|
98
91
|
}
|
|
99
|
-
return
|
|
92
|
+
return seen
|
|
100
93
|
})
|
|
101
|
-
const
|
|
102
|
-
highlightIndices
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
const highlightPredicate = $derived(
|
|
95
|
+
highlightIndices.size > 0
|
|
96
|
+
? (_row: Record<string, unknown>, i: number) => highlightIndices.has(i)
|
|
97
|
+
: undefined
|
|
105
98
|
)
|
|
106
99
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return t === 'mean' ? 'avg' : t
|
|
113
|
-
}
|
|
114
|
-
const trendPaths = $derived.by(() => {
|
|
115
|
-
const out: { d: string; type: string; i: number }[] = []
|
|
116
|
-
trendMethods.forEach((m, idx) => {
|
|
117
|
-
const res = computeTrend(rows, { x: 'x', y: 'y' }, m)
|
|
118
|
-
if (!res) return
|
|
119
|
-
if (res.kind === 'constant') {
|
|
120
|
-
const yy = yScale(res.value)
|
|
121
|
-
if (Number.isFinite(yy))
|
|
122
|
-
out.push({ d: `M0,${yy} L${width},${yy}`, type: typeOf(m), i: idx })
|
|
123
|
-
} else {
|
|
124
|
-
const pts = res.values
|
|
125
|
-
.map((v, i) => ({ vx: xScale(i), vy: yScale(v) }))
|
|
126
|
-
.filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
|
|
127
|
-
const gen = d3line<{ vx: number; vy: number }>()
|
|
128
|
-
.x((p) => p.vx)
|
|
129
|
-
.y((p) => p.vy)
|
|
130
|
-
const d = gen(pts)
|
|
131
|
-
if (d) out.push({ d, type: typeOf(m), i: idx })
|
|
132
|
-
}
|
|
133
|
-
})
|
|
134
|
-
return out
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
const linePath = $derived.by(() => {
|
|
138
|
-
const gen = d3line<number>()
|
|
139
|
-
.x((_, i) => xScale(i))
|
|
140
|
-
.y((v) => yScale(v))
|
|
141
|
-
if (curve === 'smooth') gen.curve(curveCatmullRom)
|
|
142
|
-
return gen(values)
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
// Area fill anchors at the baseline when one is in effect (so a mixed-sign
|
|
146
|
-
// area fills up above / down below the crossing), else at the pixel bottom.
|
|
147
|
-
const baselineY = $derived(effectiveBaseline !== undefined ? yScale(effectiveBaseline) : null)
|
|
148
|
-
|
|
149
|
-
const areaPath = $derived.by(() => {
|
|
150
|
-
const gen = d3area<number>()
|
|
151
|
-
.x((_, i) => xScale(i))
|
|
152
|
-
.y0(baselineY ?? height)
|
|
153
|
-
.y1((v) => yScale(v))
|
|
154
|
-
if (curve === 'smooth') gen.curve(curveCatmullRom)
|
|
155
|
-
return gen(values)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
// When a baseline is in effect, split the fill into above/below regions (each
|
|
159
|
-
// clamped at the baseline) so themes can colour positive vs negative fills.
|
|
160
|
-
const areaAbovePath = $derived.by(() => {
|
|
161
|
-
if (baselineY === null) return null
|
|
162
|
-
const gen = d3area<number>()
|
|
163
|
-
.x((_, i) => xScale(i))
|
|
164
|
-
.y0(baselineY)
|
|
165
|
-
.y1((v) => Math.min(yScale(v), baselineY))
|
|
166
|
-
if (curve === 'smooth') gen.curve(curveCatmullRom)
|
|
167
|
-
return gen(values)
|
|
168
|
-
})
|
|
169
|
-
const areaBelowPath = $derived.by(() => {
|
|
170
|
-
if (baselineY === null) return null
|
|
171
|
-
const gen = d3area<number>()
|
|
172
|
-
.x((_, i) => xScale(i))
|
|
173
|
-
.y0(baselineY)
|
|
174
|
-
.y1((v) => Math.max(yScale(v), baselineY))
|
|
175
|
-
if (curve === 'smooth') gen.curve(curveCatmullRom)
|
|
176
|
-
return gen(values)
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
const barWidth = $derived(Math.max(1, width / values.length - 1))
|
|
180
|
-
|
|
100
|
+
// `color` names a design-token family (e.g. 'primary'), not a data field — resolved to a
|
|
101
|
+
// literal CSS color string once here and handed to each geom's own color/fill channel.
|
|
102
|
+
// `isLiteralColor` (geoms/lib/brewing/colors.js) recognises the rgb(...)/rgba(...) shape and
|
|
103
|
+
// paints it directly on every mark, bypassing the categorical color scale a data-field
|
|
104
|
+
// channel would otherwise be grouped through.
|
|
181
105
|
const strokeColor = $derived(`rgb(var(--color-${color}-500, 100,116,139))`)
|
|
182
106
|
const fillColor = $derived(`rgba(var(--color-${color}-300), 0.25)`)
|
|
183
|
-
|
|
184
|
-
const patternId = 'sparkline-pattern'
|
|
185
|
-
const patternMarks = $derived(pattern ? (PATTERNS[pattern] ?? null) : null)
|
|
186
107
|
</script>
|
|
187
108
|
|
|
188
|
-
<
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
{
|
|
194
|
-
|
|
109
|
+
<Spark
|
|
110
|
+
data={rows}
|
|
111
|
+
x="x"
|
|
112
|
+
y="y"
|
|
113
|
+
{width}
|
|
114
|
+
{height}
|
|
115
|
+
{min}
|
|
116
|
+
{max}
|
|
117
|
+
baseline={effectiveBaseline}
|
|
118
|
+
{pattern}
|
|
119
|
+
{label}
|
|
120
|
+
>
|
|
195
121
|
{#if type === 'line'}
|
|
196
|
-
<
|
|
197
|
-
d={linePath}
|
|
198
|
-
fill="none"
|
|
199
|
-
stroke={strokeColor}
|
|
200
|
-
stroke-width="1.5"
|
|
201
|
-
stroke-linejoin="round"
|
|
202
|
-
stroke-linecap="round"
|
|
203
|
-
/>
|
|
122
|
+
<Line x="x" y="y" color={strokeColor} options={{ curve, strokeWidth: 1.5 }} />
|
|
204
123
|
{:else if type === 'area'}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
<path
|
|
213
|
-
d={areaBelowPath}
|
|
214
|
-
data-plot-area
|
|
215
|
-
data-plot-area-sign="below"
|
|
216
|
-
fill={patternMarks ? `url(#${patternId})` : undefined}
|
|
217
|
-
/>
|
|
218
|
-
{:else}
|
|
219
|
-
<path d={areaPath} data-plot-area fill={patternMarks ? `url(#${patternId})` : undefined} />
|
|
220
|
-
{/if}
|
|
221
|
-
<path
|
|
222
|
-
d={linePath}
|
|
223
|
-
fill="none"
|
|
224
|
-
stroke={strokeColor}
|
|
225
|
-
stroke-width="1.5"
|
|
226
|
-
stroke-linejoin="round"
|
|
227
|
-
stroke-linecap="round"
|
|
124
|
+
<Area
|
|
125
|
+
x="x"
|
|
126
|
+
y="y"
|
|
127
|
+
fill={fillColor}
|
|
128
|
+
pattern={patternChannel}
|
|
129
|
+
alpha={1}
|
|
130
|
+
options={{ curve, baseline: effectiveBaseline }}
|
|
228
131
|
/>
|
|
132
|
+
<Line x="x" y="y" color={strokeColor} options={{ curve, strokeWidth: 1.5 }} />
|
|
229
133
|
{:else if type === 'bar'}
|
|
230
|
-
|
|
231
|
-
{@const vy = yScale(v)}
|
|
232
|
-
{@const top = Math.min(vy, barAnchorY)}
|
|
233
|
-
{@const barHeight = Math.abs(vy - barAnchorY)}
|
|
234
|
-
<rect
|
|
235
|
-
x={xScale(i) - barWidth / 2}
|
|
236
|
-
y={top}
|
|
237
|
-
width={barWidth}
|
|
238
|
-
height={barHeight}
|
|
239
|
-
fill={strokeColor}
|
|
240
|
-
/>
|
|
241
|
-
{#if patternMarks}
|
|
242
|
-
<rect
|
|
243
|
-
x={xScale(i) - barWidth / 2}
|
|
244
|
-
y={top}
|
|
245
|
-
width={barWidth}
|
|
246
|
-
height={barHeight}
|
|
247
|
-
fill="url(#{patternId})"
|
|
248
|
-
pointer-events="none"
|
|
249
|
-
/>
|
|
250
|
-
{/if}
|
|
251
|
-
{/each}
|
|
252
|
-
{/if}
|
|
253
|
-
|
|
254
|
-
{#if trendPaths.length}
|
|
255
|
-
<g data-plot-geom="trend">
|
|
256
|
-
{#each trendPaths as p (p.i)}
|
|
257
|
-
<path d={p.d} data-plot-trend={p.type} />
|
|
258
|
-
{/each}
|
|
259
|
-
</g>
|
|
260
|
-
{/if}
|
|
261
|
-
|
|
262
|
-
{#if effectiveBaseline !== undefined}
|
|
263
|
-
<line x1={0} y1={barAnchorY} x2={width} y2={barAnchorY} data-plot-baseline />
|
|
264
|
-
{/if}
|
|
265
|
-
|
|
266
|
-
{#if highlightMarks.length}
|
|
267
|
-
<g data-plot-geom="highlight">
|
|
268
|
-
{#each highlightMarks as m (m.i)}
|
|
269
|
-
<circle cx={m.cx} cy={m.cy} data-plot-highlight />
|
|
270
|
-
{/each}
|
|
271
|
-
</g>
|
|
134
|
+
<Bar x="x" y="y" fill={strokeColor} pattern={patternChannel} />
|
|
272
135
|
{/if}
|
|
273
|
-
</svg>
|
|
274
136
|
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
stroke: none;
|
|
279
|
-
}
|
|
280
|
-
/* Positive vs negative regions of a baseline-anchored area — default to the
|
|
281
|
-
same fill; a theme can distinguish them via these vars. */
|
|
282
|
-
[data-plot-area][data-plot-area-sign='above'] {
|
|
283
|
-
fill: var(--chart-area-above-color, var(--spark-area-fill));
|
|
284
|
-
}
|
|
285
|
-
[data-plot-area][data-plot-area-sign='below'] {
|
|
286
|
-
fill: var(--chart-area-below-color, var(--spark-area-fill));
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
[data-plot-baseline] {
|
|
290
|
-
stroke: var(--chart-baseline-color, currentColor);
|
|
291
|
-
stroke-width: var(--chart-baseline-width, 1);
|
|
292
|
-
stroke-dasharray: var(--chart-baseline-dash, 4 4);
|
|
293
|
-
opacity: var(--chart-baseline-opacity, 0.5);
|
|
294
|
-
pointer-events: none;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
[data-plot-highlight] {
|
|
298
|
-
fill: var(--chart-highlight-color, rgb(var(--color-accent-500, 194 65 12)));
|
|
299
|
-
stroke: var(--chart-highlight-ring, none);
|
|
300
|
-
r: var(--chart-highlight-radius, 3);
|
|
301
|
-
pointer-events: none;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
[data-plot-trend] {
|
|
305
|
-
fill: none;
|
|
306
|
-
stroke: var(--chart-trend-color, currentColor);
|
|
307
|
-
stroke-width: var(--chart-trend-width, 1);
|
|
308
|
-
stroke-dasharray: var(--chart-trend-dash, 4 4);
|
|
309
|
-
opacity: var(--chart-trend-opacity, 0.7);
|
|
310
|
-
pointer-events: none;
|
|
311
|
-
}
|
|
312
|
-
</style>
|
|
137
|
+
<Trend x="x" y="y" {trend} />
|
|
138
|
+
<Highlight x="x" y="y" highlight={highlightPredicate} />
|
|
139
|
+
</Spark>
|