@rokkit/chart 1.3.15 → 1.4.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.
Files changed (47) hide show
  1. package/dist/Plot.svelte +25 -9
  2. package/dist/PlotState.svelte.d.ts +1 -0
  3. package/dist/PlotState.svelte.js +19 -8
  4. package/dist/Spark.svelte +165 -0
  5. package/dist/Spark.svelte.d.ts +31 -0
  6. package/dist/SparkState.svelte.d.ts +138 -0
  7. package/dist/SparkState.svelte.js +331 -0
  8. package/dist/Sparkline.svelte +62 -237
  9. package/dist/Sparkline.svelte.d.ts +2 -0
  10. package/dist/charts/RadarChart.svelte +106 -0
  11. package/dist/charts/RadarChart.svelte.d.ts +43 -0
  12. package/dist/geoms/Area.svelte +24 -9
  13. package/dist/geoms/Area.svelte.d.ts +2 -0
  14. package/dist/geoms/Bar.svelte +9 -16
  15. package/dist/geoms/Highlight.svelte +61 -40
  16. package/dist/geoms/Line.svelte +10 -9
  17. package/dist/geoms/Point.svelte +9 -15
  18. package/dist/geoms/Radar.svelte +352 -0
  19. package/dist/geoms/Radar.svelte.d.ts +42 -0
  20. package/dist/geoms/Trend.svelte +46 -23
  21. package/dist/geoms/lib/aesthetics.d.ts +14 -0
  22. package/dist/geoms/lib/aesthetics.js +21 -0
  23. package/dist/geoms/lib/areas.d.ts +9 -2
  24. package/dist/geoms/lib/areas.js +53 -8
  25. package/dist/geoms/lib/marks/area.d.ts +3 -0
  26. package/dist/geoms/lib/marks/area.js +11 -2
  27. package/dist/geoms/lib/marks/radar.d.ts +68 -0
  28. package/dist/geoms/lib/marks/radar.js +153 -0
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.js +7 -1
  31. package/dist/lib/brewing/marks/boxes.js +19 -30
  32. package/dist/lib/brewing/polar.d.ts +444 -0
  33. package/dist/lib/brewing/polar.js +645 -0
  34. package/dist/lib/highlight.js +84 -33
  35. package/dist/lib/plot/scales.js +8 -5
  36. package/dist/lib/preset.d.ts +1 -0
  37. package/dist/lib/preset.js +2 -1
  38. package/dist/lib/select.d.ts +25 -11
  39. package/dist/lib/select.js +14 -7
  40. package/dist/lib/trend.js +55 -39
  41. package/package.json +1 -1
  42. package/dist/examples/BarChartExample.svelte +0 -78
  43. package/dist/examples/BarChartExample.svelte.d.ts +0 -18
  44. package/dist/lib/brewing/types.d.ts +0 -162
  45. package/dist/lib/brewing/types.js +0 -73
  46. package/dist/lib/xscale.d.ts +0 -11
  47. 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
+ }
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
- import { scaleLinear } from 'd3-scale'
3
- import { line as d3line, area as d3area, curveCatmullRom } from 'd3-shape'
4
- import PatternDef from './patterns/PatternDef.svelte'
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
- const rawMin = $derived(Math.min(...values))
66
- const rawMax = $derived(Math.max(...values))
67
- // Explicit min/max win; otherwise extend the auto domain to include the baseline so it
68
- // (and the full negative extent) stay on-canvas.
69
- const yMin = $derived(
70
- min ?? (effectiveBaseline !== undefined ? Math.min(rawMin, effectiveBaseline) : rawMin)
71
- )
72
- const yMax = $derived(
73
- max ?? (effectiveBaseline !== undefined ? Math.max(rawMax, effectiveBaseline) : rawMax)
74
- )
75
-
76
- const xScale = $derived(
77
- scaleLinear()
78
- .domain([0, values.length - 1])
79
- .range([0, width])
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 }))
80
73
  )
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)
74
+ const patternChannel = $derived(pattern !== undefined ? 'pattern' : undefined)
86
75
 
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
@@ -91,222 +84,54 @@
91
84
  ? []
92
85
  : [highlight]
93
86
  )
94
- const highlightIndices = $derived.by(() => {
95
- const seen = new Set<number>()
96
- for (const sel of highlightSelectors) {
97
- for (const i of resolveHighlight(rows, sel, { y: 'y' })) seen.add(i)
98
- }
99
- return [...seen]
100
- })
101
- const highlightMarks = $derived(
102
- highlightIndices
103
- .map((i) => ({ i, cx: xScale(i), cy: yScale(values[i]) }))
104
- .filter((m) => Number.isFinite(m.cx) && Number.isFinite(m.cy))
87
+ // `resolveHighlight` returns number[], so the union is a flatMap and the Set is
88
+ // built from that iterable — no incremental .add() to make reactive.
89
+ const highlightIndices = $derived(
90
+ new Set<number>(highlightSelectors.flatMap((sel) => resolveHighlight(rows, sel, { y: 'y' })))
105
91
  )
106
-
107
- const trendMethods = $derived(
108
- Array.isArray(trend) ? trend : trend === null || trend === undefined ? [] : [trend]
92
+ const highlightPredicate = $derived(
93
+ highlightIndices.size > 0
94
+ ? (_row: Record<string, unknown>, i: number) => highlightIndices.has(i)
95
+ : undefined
109
96
  )
110
- const typeOf = (m: TrendMethod) => {
111
- const t = typeof m === 'string' ? m : typeof m === 'number' ? 'value' : (m?.type ?? '')
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
97
 
98
+ // `color` names a design-token family (e.g. 'primary'), not a data field — resolved to a
99
+ // literal CSS color string once here and handed to each geom's own color/fill channel.
100
+ // `isLiteralColor` (geoms/lib/brewing/colors.js) recognises the rgb(...)/rgba(...) shape and
101
+ // paints it directly on every mark, bypassing the categorical color scale a data-field
102
+ // channel would otherwise be grouped through.
181
103
  const strokeColor = $derived(`rgb(var(--color-${color}-500, 100,116,139))`)
182
104
  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
105
  </script>
187
106
 
188
- <svg {width} {height} style="overflow: visible; display: block; --spark-area-fill: {fillColor};">
189
- {#if patternMarks}
190
- <defs>
191
- <PatternDef id={patternId} marks={patternMarks} stroke={strokeColor} />
192
- </defs>
193
- {/if}
194
-
107
+ <Spark
108
+ data={rows}
109
+ x="x"
110
+ y="y"
111
+ {width}
112
+ {height}
113
+ {min}
114
+ {max}
115
+ baseline={effectiveBaseline}
116
+ {pattern}
117
+ {label}
118
+ >
195
119
  {#if type === 'line'}
196
- <path
197
- d={linePath}
198
- fill="none"
199
- stroke={strokeColor}
200
- stroke-width="1.5"
201
- stroke-linejoin="round"
202
- stroke-linecap="round"
203
- />
120
+ <Line x="x" y="y" color={strokeColor} options={{ curve, strokeWidth: 1.5 }} />
204
121
  {:else if type === 'area'}
205
- {#if baselineY !== null}
206
- <path
207
- d={areaAbovePath}
208
- data-plot-area
209
- data-plot-area-sign="above"
210
- fill={patternMarks ? `url(#${patternId})` : undefined}
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"
122
+ <Area
123
+ x="x"
124
+ y="y"
125
+ fill={fillColor}
126
+ pattern={patternChannel}
127
+ alpha={1}
128
+ options={{ curve, baseline: effectiveBaseline }}
228
129
  />
130
+ <Line x="x" y="y" color={strokeColor} options={{ curve, strokeWidth: 1.5 }} />
229
131
  {:else if type === 'bar'}
230
- {#each values as v, i (i)}
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}
132
+ <Bar x="x" y="y" fill={strokeColor} pattern={patternChannel} />
252
133
  {/if}
253
134
 
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>
272
- {/if}
273
- </svg>
274
-
275
- <style>
276
- [data-plot-area] {
277
- fill: var(--spark-area-fill, rgba(var(--color-primary-300), 0.25));
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>
135
+ <Trend x="x" y="y" {trend} />
136
+ <Highlight x="x" y="y" highlight={highlightPredicate} />
137
+ </Spark>