@rokkit/chart 1.3.13 → 1.3.15

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.
@@ -202,7 +202,10 @@
202
202
  })
203
203
 
204
204
  onDestroy(() => {
205
- cancelAnimationFrame(rafId)
205
+ // onDestroy is the one lifecycle hook that also runs during SSR, where
206
+ // cancelAnimationFrame is undefined — guard it so the component can be
207
+ // server-rendered / prerendered (rafId is only ever set in the browser).
208
+ if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(rafId)
206
209
  clearInterval(reducedInterval)
207
210
  })
208
211
 
package/dist/Plot.svelte CHANGED
@@ -58,6 +58,13 @@
58
58
  onselect?: (detail: unknown) => void
59
59
  selectable?: boolean
60
60
  selected?: Row[]
61
+ /** Data-coordinate crossing point of the axes: `[xCross, yCross]`. Drives
62
+ * quadrant mode — where the origin sits relative to the domain makes a chart
63
+ * 1- / 2- / 4-quadrant. Omit a component to leave that axis auto (crosses at 0
64
+ * when the domain spans it, else edge-pinned). Also settable via `spec.axisOrigin`. */
65
+ axisOrigin?: [number | undefined, number | undefined]
66
+ /** Nudge an edge-pinned axis outward by N px (Q1 only). Also via `spec.axisOffset`. */
67
+ axisOffset?: number
61
68
  /** Animate marks on data/flip changes (opt-out for e.g. AnimatedPlot, which
62
69
  * tweens its own frames). Enabled one frame after the width settles so the
63
70
  * initial layout paints un-animated. Default `true`. */
@@ -94,6 +101,8 @@
94
101
  onselect = undefined,
95
102
  selectable = false,
96
103
  selected = $bindable([]),
104
+ axisOrigin = undefined,
105
+ axisOffset = undefined,
97
106
  animate = true,
98
107
  children
99
108
  }: Props = $props()
@@ -142,6 +151,8 @@
142
151
  colorScheme: spec?.colorScheme,
143
152
  colorMidpoint: spec?.colorMidpoint,
144
153
  orientation: orientation ?? spec?.orientation,
154
+ axisOrigin: spec?.axisOrigin ?? axisOrigin,
155
+ axisOffset: spec?.axisOffset ?? axisOffset,
145
156
  sort: spec?.sort,
146
157
  continuousCategory: spec?.continuousCategory,
147
158
  chartPreset,
@@ -41,6 +41,13 @@ type Props = {
41
41
  onselect?: (detail: unknown) => void;
42
42
  selectable?: boolean;
43
43
  selected?: Row[];
44
+ /** Data-coordinate crossing point of the axes: `[xCross, yCross]`. Drives
45
+ * quadrant mode — where the origin sits relative to the domain makes a chart
46
+ * 1- / 2- / 4-quadrant. Omit a component to leave that axis auto (crosses at 0
47
+ * when the domain spans it, else edge-pinned). Also settable via `spec.axisOrigin`. */
48
+ axisOrigin?: [number | undefined, number | undefined];
49
+ /** Nudge an edge-pinned axis outward by N px (Q1 only). Also via `spec.axisOffset`. */
50
+ axisOffset?: number;
44
51
  /** Animate marks on data/flip changes (opt-out for e.g. AnimatedPlot, which
45
52
  * tweens its own frames). Enabled one frame after the width settles so the
46
53
  * initial layout paints un-animated. Default `true`. */
@@ -425,6 +425,7 @@ export class PlotState {
425
425
  this.#selectable = config.selectable ?? false
426
426
  if (config.selected) this.#selected = new SvelteSet(config.selected)
427
427
  this.#axisOffset = config.axisOffset ?? 0
428
+ this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
428
429
  this.#marginOverride = config.margin ?? undefined
429
430
  this.#orientationOverride = config.orientation ?? undefined
430
431
  this.#continuousCategory = config.continuousCategory ?? false
@@ -453,6 +454,7 @@ export class PlotState {
453
454
  if (config.onselect !== undefined) this.#onselect = config.onselect
454
455
  if (config.selectable !== undefined) this.#selectable = config.selectable
455
456
  if (config.axisOffset !== undefined) this.#axisOffset = config.axisOffset
457
+ this.axisOrigin = config.axisOrigin ?? [undefined, undefined]
456
458
  this.#marginOverride = config.margin ?? undefined
457
459
  this.#orientationOverride = config.orientation ?? undefined
458
460
  this.#continuousCategory = config.continuousCategory ?? false
@@ -3,6 +3,18 @@
3
3
  import { line as d3line, area as d3area, curveCatmullRom } from 'd3-shape'
4
4
  import PatternDef from './patterns/PatternDef.svelte'
5
5
  import { PATTERNS } from './patterns/patterns.js'
6
+ import { resolveHighlight } from './lib/highlight.js'
7
+ import { computeTrend } from './lib/trend.js'
8
+
9
+ type HighlightSelector =
10
+ | 'first'
11
+ | 'last'
12
+ | 'min'
13
+ | 'max'
14
+ | number
15
+ | ((row: { x: number; y: number }, i: number) => boolean)
16
+
17
+ type TrendMethod = string | number | { type: string; [k: string]: unknown }
6
18
 
7
19
  type Props = {
8
20
  data?: number[] | Record<string, unknown>[]
@@ -15,6 +27,9 @@
15
27
  height?: number
16
28
  min?: number
17
29
  max?: number
30
+ baseline?: number
31
+ highlight?: HighlightSelector | HighlightSelector[]
32
+ trend?: TrendMethod | TrendMethod[]
18
33
  }
19
34
 
20
35
  let {
@@ -27,7 +42,10 @@
27
42
  width = 80,
28
43
  height = 24,
29
44
  min = undefined,
30
- max = undefined
45
+ max = undefined,
46
+ baseline = undefined,
47
+ highlight = undefined,
48
+ trend = undefined
31
49
  }: Props = $props()
32
50
 
33
51
  const values = $derived(
@@ -36,8 +54,24 @@
36
54
  )
37
55
  )
38
56
 
39
- const yMin = $derived(min ?? Math.min(...values))
40
- const yMax = $derived(max ?? Math.max(...values))
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
+ const hasNegative = $derived(values.some((v) => v < 0))
61
+ // Bars with negative values are meaningless when measured from the pixel bottom, so a
62
+ // bar sparkline auto-anchors at 0 when any value is negative. Everything else is opt-in.
63
+ const effectiveBaseline = $derived(baseline ?? (type === 'bar' && hasNegative ? 0 : undefined))
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
+ )
41
75
 
42
76
  const xScale = $derived(
43
77
  scaleLinear()
@@ -46,6 +80,60 @@
46
80
  )
47
81
  const yScale = $derived(scaleLinear().domain([yMin, yMax]).range([height, 0]))
48
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
+
87
+ const highlightSelectors = $derived(
88
+ Array.isArray(highlight)
89
+ ? highlight
90
+ : highlight === null || highlight === undefined
91
+ ? []
92
+ : [highlight]
93
+ )
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))
105
+ )
106
+
107
+ const trendMethods = $derived(
108
+ Array.isArray(trend) ? trend : trend === null || trend === undefined ? [] : [trend]
109
+ )
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
+
49
137
  const linePath = $derived.by(() => {
50
138
  const gen = d3line<number>()
51
139
  .x((_, i) => xScale(i))
@@ -54,15 +142,40 @@
54
142
  return gen(values)
55
143
  })
56
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
+
57
149
  const areaPath = $derived.by(() => {
58
150
  const gen = d3area<number>()
59
151
  .x((_, i) => xScale(i))
60
- .y0(height)
152
+ .y0(baselineY ?? height)
61
153
  .y1((v) => yScale(v))
62
154
  if (curve === 'smooth') gen.curve(curveCatmullRom)
63
155
  return gen(values)
64
156
  })
65
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
+
66
179
  const barWidth = $derived(Math.max(1, width / values.length - 1))
67
180
 
68
181
  const strokeColor = $derived(`rgb(var(--color-${color}-500, 100,116,139))`)
@@ -72,7 +185,7 @@
72
185
  const patternMarks = $derived(pattern ? (PATTERNS[pattern] ?? null) : null)
73
186
  </script>
74
187
 
75
- <svg {width} {height} style="overflow: visible; display: block;">
188
+ <svg {width} {height} style="overflow: visible; display: block; --spark-area-fill: {fillColor};">
76
189
  {#if patternMarks}
77
190
  <defs>
78
191
  <PatternDef id={patternId} marks={patternMarks} stroke={strokeColor} />
@@ -89,7 +202,22 @@
89
202
  stroke-linecap="round"
90
203
  />
91
204
  {:else if type === 'area'}
92
- <path d={areaPath} fill={patternMarks ? `url(#${patternId})` : fillColor} stroke="none" />
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}
93
221
  <path
94
222
  d={linePath}
95
223
  fill="none"
@@ -100,23 +228,85 @@
100
228
  />
101
229
  {:else if type === 'bar'}
102
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)}
103
234
  <rect
104
235
  x={xScale(i) - barWidth / 2}
105
- y={yScale(v)}
236
+ y={top}
106
237
  width={barWidth}
107
- height={height - yScale(v)}
238
+ height={barHeight}
108
239
  fill={strokeColor}
109
240
  />
110
241
  {#if patternMarks}
111
242
  <rect
112
243
  x={xScale(i) - barWidth / 2}
113
- y={yScale(v)}
244
+ y={top}
114
245
  width={barWidth}
115
- height={height - yScale(v)}
246
+ height={barHeight}
116
247
  fill="url(#{patternId})"
117
248
  pointer-events="none"
118
249
  />
119
250
  {/if}
120
251
  {/each}
121
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>
272
+ {/if}
122
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>
@@ -1,4 +1,12 @@
1
1
  import { PATTERNS } from './patterns/patterns.js';
2
+ type HighlightSelector = 'first' | 'last' | 'min' | 'max' | number | ((row: {
3
+ x: number;
4
+ y: number;
5
+ }, i: number) => boolean);
6
+ type TrendMethod = string | number | {
7
+ type: string;
8
+ [k: string]: unknown;
9
+ };
2
10
  type Props = {
3
11
  data?: number[] | Record<string, unknown>[];
4
12
  field?: string;
@@ -10,6 +18,9 @@ type Props = {
10
18
  height?: number;
11
19
  min?: number;
12
20
  max?: number;
21
+ baseline?: number;
22
+ highlight?: HighlightSelector | HighlightSelector[];
23
+ trend?: TrendMethod | TrendMethod[];
13
24
  };
14
25
  declare const Sparkline: import("svelte").Component<Props, {}, "">;
15
26
  type Sparkline = ReturnType<typeof Sparkline>;
@@ -33,6 +33,7 @@ export type PlotSpec = {
33
33
  xLabel?: string | undefined;
34
34
  yLabel?: string | undefined;
35
35
  axisOrigin?: [number, number] | undefined;
36
+ axisOffset?: number | undefined;
36
37
  colorScale?: "categorical" | "sequential" | "diverging" | undefined;
37
38
  colorScheme?: string | undefined;
38
39
  colorMidpoint?: number | undefined;
@@ -33,6 +33,7 @@
33
33
  * @property {string} [xLabel]
34
34
  * @property {string} [yLabel]
35
35
  * @property {[number, number]} [axisOrigin]
36
+ * @property {number} [axisOffset]
36
37
  * @property {'categorical'|'sequential'|'diverging'} [colorScale]
37
38
  * @property {string} [colorScheme]
38
39
  * @property {number} [colorMidpoint]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {