@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,352 @@
1
+ <script lang="ts">
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import type { PlotState } from '../PlotState.svelte.js'
4
+ import { GeomState } from './lib/GeomState.svelte.js'
5
+ import { buildRadarMarks, resolveRadarRadius } from './lib/marks/radar.js'
6
+ import { buildRadarLayout } from '../lib/brewing/polar.js'
7
+ import { buildSelectDetail } from '../lib/select.js'
8
+
9
+ type Row = Record<string, unknown>
10
+
11
+ type AxisSpec = {
12
+ key: string
13
+ label?: string
14
+ unit?: string
15
+ domain?: [number, number]
16
+ ticks?: number
17
+ tickLabels?: string[]
18
+ format?: (value: number) => unknown
19
+ weight?: number
20
+ }
21
+
22
+ // The shape `buildRadarMarks` documents in its `@returns`. Other geoms leave `geom.marks`
23
+ // untyped because they only ever spread it in a template; this one also reads it in script
24
+ // (see `strokeBySeries`), which needs a real element type.
25
+ type RadarMark = {
26
+ d: string
27
+ fill: string
28
+ stroke: string
29
+ alpha: number
30
+ key: string
31
+ patternId: string | null
32
+ }
33
+
34
+ type Options = {
35
+ grid?: boolean
36
+ rings?: number
37
+ sharedDomain?: boolean
38
+ radiusScale?: 'linear' | 'sqrt' | 'auto'
39
+ /** Alias for the `axes` prop, for callers that can only reach `options` — notably
40
+ * Plot's spec-driven path, whose passthrough is a fixed prop list. */
41
+ axes?: (string | AxisSpec)[]
42
+ }
43
+
44
+ type Props = {
45
+ axes?: (string | AxisSpec)[]
46
+ axis?: string
47
+ value?: string
48
+ series?: string
49
+ /** Generic-pipeline aliases for `axis`/`value`/`series`. Radar's own names say what
50
+ * the channels mean here (an axis is not an x position), but Plot's spec-driven
51
+ * path passes only the generic set, so these let a spec drive radar too. The
52
+ * specific names win when both are given. */
53
+ x?: string
54
+ y?: string
55
+ color?: string
56
+ pattern?: string
57
+ /** Fixed area opacity 0–1; defaults to the per-geom preset (radar = 0.25). */
58
+ alpha?: number
59
+ stat?: string
60
+ options?: Options
61
+ onselect?: (data: Row) => void
62
+ }
63
+
64
+ let {
65
+ axes: axesProp,
66
+ axis: axisProp,
67
+ value: valueProp,
68
+ series: seriesProp,
69
+ x = undefined,
70
+ y = undefined,
71
+ color = undefined,
72
+ pattern,
73
+ alpha,
74
+ stat = 'identity',
75
+ options = {},
76
+ onselect = undefined
77
+ }: Props = $props()
78
+
79
+ // Radar's own channel names take precedence; the generic ones are the fallback so a
80
+ // spec-driven <Plot> (which passes only x/y/color) can drive this geom too.
81
+ const axis = $derived(axisProp ?? x)
82
+ const value = $derived(valueProp ?? y)
83
+ const series = $derived(seriesProp ?? color)
84
+ const axes = $derived(axesProp ?? options.axes)
85
+
86
+ const LABEL_GAP = 12
87
+ const ZERO_MARKER_HALF_SPAN_DEG = 6
88
+ const VERTEX_RADIUS = 3
89
+
90
+ const plotState = getContext<PlotState>('plot-state')
91
+
92
+ const geom = new GeomState(plotState, () => ({
93
+ type: 'radar',
94
+ channels: { x: axis, y: value, color: series, pattern },
95
+ stat,
96
+ options: {
97
+ axes,
98
+ sharedDomain: options.sharedDomain,
99
+ rings: options.rings,
100
+ radiusScale: options.radiusScale
101
+ },
102
+ alpha,
103
+ build: buildRadarMarks
104
+ }))
105
+ onMount(geom.register)
106
+ onDestroy(geom.destroy)
107
+ $effect(geom.sync)
108
+
109
+ const marks: RadarMark[] = $derived(geom.marks)
110
+ const w = $derived(plotState.innerWidth)
111
+ const h = $derived(plotState.innerHeight)
112
+
113
+ // One shared definition of the radius AND of which form we're drawing — the adapter
114
+ // builds its polygons from the very same call, so the grid can no longer disagree
115
+ // with them. `micro` is derived from the space actually available rather than from
116
+ // "am I inside a Spark": that keeps the geom free of any container-specific branch
117
+ // (the spec's requirement) and correctly also covers a Plot too small for chrome.
118
+ //
119
+ // Deliberately NOT `plotState.interactive`, which the plan proposed: that is
120
+ // `Boolean(onselect) || selectable`, so an ordinary static <Plot> reads false too and
121
+ // would silently lose its rings, spokes and axis labels.
122
+ const geometry = $derived(resolveRadarRadius(w, h))
123
+ const R = $derived(geometry.R)
124
+ const micro = $derived(geometry.micro)
125
+
126
+ // Radar takes its own channel props — like `GeomState.marks`, this never falls back to the
127
+ // container's `x`/`y`. Without both `axis` and `value` there is nothing coherent to lay a
128
+ // polar grid out against, so no layout is computed at all (mirrors `buildRadarMarks`'s own
129
+ // `!channels?.x || !channels?.y` guard for the polygons).
130
+ const layout = $derived(
131
+ axis !== undefined && value !== undefined
132
+ ? buildRadarLayout(
133
+ geom.data,
134
+ { x: axis, y: value, color: series },
135
+ {
136
+ axes,
137
+ sharedDomain: options.sharedDomain,
138
+ rings: options.rings,
139
+ radiusScale: options.radiusScale,
140
+ R
141
+ }
142
+ )
143
+ : null
144
+ )
145
+
146
+ function toXY(angleDeg: number, radius: number) {
147
+ const rad = (angleDeg * Math.PI) / 180
148
+ return { x: radius * Math.cos(rad), y: radius * Math.sin(rad) }
149
+ }
150
+
151
+ // cos(angle) is within float error of 0 only at true vertical (±90°, top/bottom); every
152
+ // other axis angle sits comfortably to one side, so a tight epsilon can't misclassify a
153
+ // real left/right axis as 'middle'.
154
+ function anchorFor(angleDeg: number): 'start' | 'middle' | 'end' {
155
+ const cosA = Math.cos((angleDeg * Math.PI) / 180)
156
+ if (Math.abs(cosA) < 1e-6) return 'middle'
157
+ return cosA > 0 ? 'start' : 'end'
158
+ }
159
+
160
+ // `buildRadarMarks` keys every mark `${seriesKey}::fill` / `${seriesKey}::stroke` so paint
161
+ // order (all fills, then all strokes) is inspectable from the key alone; this recovers the
162
+ // series value from that key for the `data-plot-series` hook.
163
+ function seriesOf(key: string) {
164
+ return key.slice(0, key.lastIndexOf('::'))
165
+ }
166
+
167
+ // Series stroke colour, recovered from the adapter's stroke marks (the ones with no fill)
168
+ // rather than re-resolving the palette here. A vertex dot has to match its own polygon's
169
+ // outline, and the only way to guarantee that is to read the same value the outline used.
170
+ const strokeBySeries = $derived(
171
+ new Map(
172
+ marks.filter((mark) => mark.fill === 'none').map((mark) => [seriesOf(mark.key), mark.stroke])
173
+ )
174
+ )
175
+
176
+ // A vertex is reachable when either the geom was handed its own callback or the plot is
177
+ // running interactively. Deliberately NOT the `keyboardNav` action: that walks a 1-D
178
+ // category list in DOM order, and radar's vertices are a 2-D series × axis grid, so
179
+ // left/right traversal has no meaning here. Tab reach plus Enter/Space, like Point/Arc.
180
+ const reachable = $derived(Boolean(onselect) || plotState.interactive)
181
+
182
+ function selectVertex(row: Row, seriesKey: unknown, event: MouseEvent | KeyboardEvent) {
183
+ onselect?.(row)
184
+ if (plotState.interactive)
185
+ plotState.handleSelect(
186
+ buildSelectDetail({
187
+ datum: row,
188
+ // `row` is one of the container's original row objects — averaging happens
189
+ // inside polar.js precisely so this lookup resolves instead of returning -1.
190
+ index: plotState.data.indexOf(row),
191
+ channels: { x: axis, y: value },
192
+ geom: 'radar',
193
+ series: seriesKey,
194
+ event
195
+ })
196
+ )
197
+ }
198
+
199
+ // A short dashed arc at the radius where value 0 sits on this one spoke — see
200
+ // `zeroRingFor`'s doc comment for why this can't just be a shared ring.
201
+ function zeroMarkerPath(angleDeg: number, radius: number) {
202
+ const p1 = toXY(angleDeg - ZERO_MARKER_HALF_SPAN_DEG, radius)
203
+ const p2 = toXY(angleDeg + ZERO_MARKER_HALF_SPAN_DEG, radius)
204
+ return `M${p1.x},${p1.y} A${radius},${radius} 0 0 1 ${p2.x},${p2.y}`
205
+ }
206
+ </script>
207
+
208
+ <g data-plot-geom="radar" transform="translate({w / 2}, {h / 2})">
209
+ {#if layout && options.grid && !micro}
210
+ {#each layout.rings as ring, i (i)}
211
+ <circle data-plot-element="radar-grid-ring" cx="0" cy="0" r={ring.radius} />
212
+ {/each}
213
+ {#each layout.axes as ax, i (ax.key)}
214
+ {@const { x: sx, y: sy } = toXY(layout.angles[i], layout.radius)}
215
+ <line
216
+ data-plot-element="radar-grid-spoke"
217
+ data-plot-axis={ax.key}
218
+ x1="0"
219
+ y1="0"
220
+ x2={sx}
221
+ y2={sy}
222
+ />
223
+ {/each}
224
+ {#each layout.zeroRings as zr, i (i)}
225
+ {#if zr !== null}
226
+ <path data-plot-element="radar-zero-ring" d={zeroMarkerPath(layout.angles[i], zr)} />
227
+ {/if}
228
+ {/each}
229
+ {#if layout.axes.length > 0}
230
+ {#each layout.rings as ring, ringIndex (ringIndex)}
231
+ {@const { x: lx, y: ly } = toXY(layout.angles[0], ring.radius)}
232
+ <text class="radar-ring-label" x={lx} y={ly} dy="-2">{ring.labels[0]}</text>
233
+ {/each}
234
+ {/if}
235
+ {/if}
236
+
237
+ {#each marks as mark (mark.key)}
238
+ {#if mark.fill !== 'none'}
239
+ <path
240
+ d={mark.d}
241
+ fill={mark.fill}
242
+ fill-opacity={mark.alpha}
243
+ stroke="none"
244
+ data-plot-element="radar-area"
245
+ data-plot-series={seriesOf(mark.key)}
246
+ />
247
+ {#if mark.patternId}
248
+ <path
249
+ d={mark.d}
250
+ fill="url(#{mark.patternId})"
251
+ stroke="none"
252
+ pointer-events="none"
253
+ data-plot-element="radar-area"
254
+ data-plot-series={seriesOf(mark.key)}
255
+ />
256
+ {/if}
257
+ {:else}
258
+ <path
259
+ d={mark.d}
260
+ fill="none"
261
+ stroke={mark.stroke}
262
+ stroke-width="2"
263
+ data-plot-element="radar-outline"
264
+ data-plot-series={seriesOf(mark.key)}
265
+ />
266
+ {/if}
267
+ {/each}
268
+
269
+ {#if layout && !micro}
270
+ {#each [...layout.series] as [seriesKey, seriesVertices] (seriesKey)}
271
+ {#each seriesVertices as vertex, i (i)}
272
+ <!-- A null vertex is a genuine gap in the data — no reading for this (series, axis)
273
+ cell — so it renders as nothing at all. Plotting it at the centre would invent
274
+ a zero the source never contained. -->
275
+ {#if vertex}
276
+ {@const { x: vx, y: vy } = toXY(vertex.angle, vertex.radius)}
277
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
278
+ <circle
279
+ data-plot-element="radar-vertex"
280
+ data-plot-series={String(seriesKey)}
281
+ data-plot-axis={vertex.axisKey}
282
+ cx={vx}
283
+ cy={vy}
284
+ r={VERTEX_RADIUS}
285
+ fill={strokeBySeries.get(String(seriesKey))}
286
+ role={reachable ? 'button' : 'graphics-symbol'}
287
+ tabindex={reachable ? 0 : undefined}
288
+ style:cursor={reachable ? 'pointer' : undefined}
289
+ aria-label="{layout.axes[i].displayLabel}, {vertex.value}"
290
+ onmouseenter={() => plotState.setHovered(vertex.row)}
291
+ onmouseleave={() => plotState.clearHovered()}
292
+ onclick={reachable ? (e) => selectVertex(vertex.row, seriesKey, e) : undefined}
293
+ onkeydown={reachable
294
+ ? (e) =>
295
+ (e.key === 'Enter' || e.key === ' ') && selectVertex(vertex.row, seriesKey, e)
296
+ : undefined}
297
+ />
298
+ {/if}
299
+ {/each}
300
+ {/each}
301
+ {/if}
302
+
303
+ {#if layout && !micro}
304
+ {#each layout.axes as ax, i (ax.key)}
305
+ {@const { x: lx, y: ly } = toXY(layout.angles[i], layout.radius + LABEL_GAP)}
306
+ <text
307
+ data-plot-element="radar-axis-label"
308
+ data-plot-axis={ax.key}
309
+ x={lx}
310
+ y={ly}
311
+ text-anchor={anchorFor(layout.angles[i])}
312
+ dominant-baseline="central">{ax.displayLabel}</text
313
+ >
314
+ {/each}
315
+ {/if}
316
+ </g>
317
+
318
+ <style>
319
+ [data-plot-element='radar-grid-ring'] {
320
+ fill: none;
321
+ stroke: var(--chart-grid-color, currentColor);
322
+ stroke-width: var(--chart-grid-width, 1);
323
+ stroke-dasharray: var(--chart-grid-dash, 2 4);
324
+ opacity: var(--chart-grid-opacity, 0.15);
325
+ pointer-events: none;
326
+ }
327
+ [data-plot-element='radar-grid-spoke'] {
328
+ stroke: var(--chart-grid-color, currentColor);
329
+ stroke-width: var(--chart-grid-width, 1);
330
+ opacity: var(--chart-grid-opacity, 0.15);
331
+ pointer-events: none;
332
+ }
333
+ [data-plot-element='radar-zero-ring'] {
334
+ fill: none;
335
+ stroke: var(--chart-grid-color, currentColor);
336
+ stroke-width: var(--chart-grid-width, 1);
337
+ stroke-dasharray: 3 3;
338
+ opacity: 0.5;
339
+ pointer-events: none;
340
+ }
341
+ [data-plot-element='radar-axis-label'] {
342
+ font-size: 11px;
343
+ fill: currentColor;
344
+ pointer-events: none;
345
+ }
346
+ .radar-ring-label {
347
+ font-size: 9px;
348
+ fill: currentColor;
349
+ opacity: 0.6;
350
+ pointer-events: none;
351
+ }
352
+ </style>
@@ -0,0 +1,42 @@
1
+ type Row = Record<string, unknown>;
2
+ type AxisSpec = {
3
+ key: string;
4
+ label?: string;
5
+ unit?: string;
6
+ domain?: [number, number];
7
+ ticks?: number;
8
+ tickLabels?: string[];
9
+ format?: (value: number) => unknown;
10
+ weight?: number;
11
+ };
12
+ type Options = {
13
+ grid?: boolean;
14
+ rings?: number;
15
+ sharedDomain?: boolean;
16
+ radiusScale?: 'linear' | 'sqrt' | 'auto';
17
+ /** Alias for the `axes` prop, for callers that can only reach `options` — notably
18
+ * Plot's spec-driven path, whose passthrough is a fixed prop list. */
19
+ axes?: (string | AxisSpec)[];
20
+ };
21
+ type Props = {
22
+ axes?: (string | AxisSpec)[];
23
+ axis?: string;
24
+ value?: string;
25
+ series?: string;
26
+ /** Generic-pipeline aliases for `axis`/`value`/`series`. Radar's own names say what
27
+ * the channels mean here (an axis is not an x position), but Plot's spec-driven
28
+ * path passes only the generic set, so these let a spec drive radar too. The
29
+ * specific names win when both are given. */
30
+ x?: string;
31
+ y?: string;
32
+ color?: string;
33
+ pattern?: string;
34
+ /** Fixed area opacity 0–1; defaults to the per-geom preset (radar = 0.25). */
35
+ alpha?: number;
36
+ stat?: string;
37
+ options?: Options;
38
+ onselect?: (data: Row) => void;
39
+ };
40
+ declare const Radar: import("svelte").Component<Props, {}, "">;
41
+ type Radar = ReturnType<typeof Radar>;
42
+ export default Radar;
@@ -20,30 +20,53 @@
20
20
  return t === 'mean' ? 'avg' : t
21
21
  }
22
22
 
23
+ /** Everything a path builder needs, resolved once per derivation. */
24
+ type Ctx = {
25
+ rows: Record<string, unknown>[]
26
+ // Scales come from PlotState (untyped JS), so they stay loose here.
27
+ xs: (v: unknown) => number
28
+ ys: (v: number) => number
29
+ x: string
30
+ y: string
31
+ innerWidth: number
32
+ }
33
+
34
+ const isDrawable = (s: PlotState | undefined) =>
35
+ Boolean(s?.xScale && s?.yScale && s?.data?.length)
36
+
37
+ /** Null until the plot has data, both scales, both channels and at least one method. */
38
+ const ctx = $derived.by((): Ctx | null => {
39
+ if (!isDrawable(state) || !x || !y || !methods.length) return null
40
+ return { rows: state.data, xs: state.xScale, ys: state.yScale, x, y, innerWidth: state.innerWidth }
41
+ })
42
+
43
+ /** A flat rule spanning the plot — what every `constant` trend (avg, median, min…) draws. */
44
+ const constantPath = (c: Ctx, value: number) => {
45
+ const yy = c.ys(value)
46
+ return `M0,${yy} L${c.innerWidth},${yy}`
47
+ }
48
+
49
+ /** A polyline through the trend's per-row values, dropping points that don't place. */
50
+ function seriesPath(c: Ctx, values: number[]): string | null {
51
+ const pts = values
52
+ .map((v, i) => ({ vx: scalePos(c.xs, c.rows[i][c.x]), vy: c.ys(v) }))
53
+ .filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
54
+ return d3line<{ vx: number; vy: number }>()
55
+ .x((p) => p.vx)
56
+ .y((p) => p.vy)(pts)
57
+ }
58
+
59
+ /** One method → one path, or null when the method doesn't resolve for this data. */
60
+ function trendPath(c: Ctx, m: Method, i: number) {
61
+ const res = computeTrend(c.rows, { x: c.x, y: c.y }, m)
62
+ if (!res) return null
63
+ const d = res.kind === 'constant' ? constantPath(c, res.value) : seriesPath(c, res.values)
64
+ return d ? { d, type: typeOf(m), i } : null
65
+ }
66
+
23
67
  const paths = $derived.by(() => {
24
- const rows = state?.data ?? []
25
- const xs = state?.xScale
26
- const ys = state?.yScale
27
- if (!rows.length || !xs || !ys || !x || !y || !methods.length) return []
28
- const out: { d: string; type: string; i: number }[] = []
29
- methods.forEach((m, idx) => {
30
- const res = computeTrend(rows, { x, y }, m)
31
- if (!res) return
32
- if (res.kind === 'constant') {
33
- const yy = ys(res.value)
34
- out.push({ d: `M0,${yy} L${state.innerWidth},${yy}`, type: typeOf(m), i: idx })
35
- } else {
36
- const pts = res.values
37
- .map((v, i) => ({ vx: scalePos(xs, rows[i][x]), vy: ys(v) }))
38
- .filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
39
- const gen = d3line<{ vx: number; vy: number }>()
40
- .x((p) => p.vx)
41
- .y((p) => p.vy)
42
- const d = gen(pts)
43
- if (d) out.push({ d, type: typeOf(m), i: idx })
44
- }
45
- })
46
- return out
68
+ if (!ctx) return []
69
+ return methods.map((m, i) => trendPath(ctx, m, i)).filter((p) => p !== null)
47
70
  })
48
71
  </script>
49
72
 
@@ -34,3 +34,17 @@ export function resolveFillStroke(row: Record<string, unknown>, channels: {
34
34
  export function resolveAlpha(alpha: number | undefined, type: string, preset?: {
35
35
  opacity?: Record<string, number>;
36
36
  }): number;
37
+ /**
38
+ * Resolve a mark's text label from the geom's `label` prop.
39
+ *
40
+ * - falsy → no label
41
+ * - `true` → the row's value in `defaultField` (usually the geom's y channel)
42
+ * - function → its return value, stringified
43
+ * - string → the row's value in that field
44
+ *
45
+ * @param {boolean|string|((row: Record<string, unknown>) => unknown)|undefined} label
46
+ * @param {Record<string, unknown>} row
47
+ * @param {string} [defaultField] - field used when `label` is `true`
48
+ * @returns {string|null}
49
+ */
50
+ export function resolveLabel(label: boolean | string | ((row: Record<string, unknown>) => unknown) | undefined, row: Record<string, unknown>, defaultField?: string): string | null;
@@ -43,3 +43,24 @@ export function resolveAlpha(alpha, type, preset) {
43
43
  if (typeof alpha === 'number') return alpha
44
44
  return preset?.opacity?.[type] ?? 1
45
45
  }
46
+
47
+ /**
48
+ * Resolve a mark's text label from the geom's `label` prop.
49
+ *
50
+ * - falsy → no label
51
+ * - `true` → the row's value in `defaultField` (usually the geom's y channel)
52
+ * - function → its return value, stringified
53
+ * - string → the row's value in that field
54
+ *
55
+ * @param {boolean|string|((row: Record<string, unknown>) => unknown)|undefined} label
56
+ * @param {Record<string, unknown>} row
57
+ * @param {string} [defaultField] - field used when `label` is `true`
58
+ * @returns {string|null}
59
+ */
60
+ export function resolveLabel(label, row, defaultField) {
61
+ if (!label) return null
62
+ if (label === true) return String((defaultField ? row[defaultField] : undefined) ?? '')
63
+ if (typeof label === 'function') return String(label(row) ?? '')
64
+ if (typeof label === 'string') return String(row[label] ?? '')
65
+ return null
66
+ }
@@ -9,7 +9,12 @@
9
9
  * @param {'linear'|'smooth'|'step'} [curve]
10
10
  * @param {Map<unknown, string>} [patterns]
11
11
  * @param {Function} [place] - orientation mapper (x, y) => {x, y}
12
- * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
12
+ * @param {number} [baselineValue] - data-space value to anchor/split the fill at. When set,
13
+ * each segment is emitted twice (sign: 'above' | 'below'), both anchored at this value's
14
+ * pixel position instead of the chart bottom, so themes can colour the two independently.
15
+ * Fed straight into `yScale()`, so a value outside the scale's domain extrapolates (e.g.
16
+ * 100 against a domain of [-5, 5] gives pixel -475) rather than clamping to the chart edge.
17
+ * @returns {{ d: string, fill: string, stroke: string, key: unknown, groupKey: unknown, patternId: string|null, sign?: 'above'|'below' }[]}
13
18
  */
14
19
  export function buildAreas(data: Object[], channels: {
15
20
  x: string;
@@ -19,12 +24,14 @@ export function buildAreas(data: Object[], channels: {
19
24
  }, xScale: Function, yScale: Function, colors: Map<unknown, {
20
25
  fill: string;
21
26
  stroke: string;
22
- }>, curve?: "linear" | "smooth" | "step", patterns?: Map<unknown, string>, place?: Function): {
27
+ }>, curve?: "linear" | "smooth" | "step", patterns?: Map<unknown, string>, place?: Function, baselineValue?: number): {
23
28
  d: string;
24
29
  fill: string;
25
30
  stroke: string;
26
31
  key: unknown;
32
+ groupKey: unknown;
27
33
  patternId: string | null;
34
+ sign?: "above" | "below";
28
35
  }[];
29
36
  /**
30
37
  * Builds stacked area paths using d3 stack layout.
@@ -13,23 +13,41 @@ import { literalColor, markEntry } from '../../lib/brewing/colors.js'
13
13
  * @param {'linear'|'smooth'|'step'} [curve]
14
14
  * @param {Map<unknown, string>} [patterns]
15
15
  * @param {Function} [place] - orientation mapper (x, y) => {x, y}
16
- * @returns {{ d: string, fill: string, stroke: string, key: unknown, patternId: string|null }[]}
16
+ * @param {number} [baselineValue] - data-space value to anchor/split the fill at. When set,
17
+ * each segment is emitted twice (sign: 'above' | 'below'), both anchored at this value's
18
+ * pixel position instead of the chart bottom, so themes can colour the two independently.
19
+ * Fed straight into `yScale()`, so a value outside the scale's domain extrapolates (e.g.
20
+ * 100 against a domain of [-5, 5] gives pixel -475) rather than clamping to the chart edge.
21
+ * @returns {{ d: string, fill: string, stroke: string, key: unknown, groupKey: unknown, patternId: string|null, sign?: 'above'|'below' }[]}
17
22
  */
18
- export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns, place = (x, y) => ({ x, y })) {
23
+ export function buildAreas(data, channels, xScale, yScale, colors, curve, patterns, place = (x, y) => ({ x, y }), baselineValue = undefined) {
19
24
  const { x: xf, y: yf, pattern: pf } = channels
20
25
  // A literal color (var()/oklch()/hex/currentColor) isn't a data field — it's the exact
21
26
  // fill the caller wants for THIS area. Don't group on it, and don't fall back to the
22
27
  // shared color scale (one value across all geoms); use it directly.
23
28
  const lit = literalColor(channels.color)
24
29
  const cf = lit ? undefined : channels.color
25
- const baseline = yScale.range()[0] // bottom of the chart (y pixel max)
30
+ // Number.isFinite (not a loose !=null check) also rejects NaN/Infinity — a caller-computed
31
+ // stat (e.g. a mean over an empty group) can easily produce NaN, which would otherwise sail
32
+ // through and emit literal "NaN" path commands.
33
+ const hasBaseline = Number.isFinite(baselineValue)
34
+ const baseline = hasBaseline ? yScale(baselineValue) : yScale.range()[0] // bottom of the chart (y pixel max) unless an explicit baseline value is given
26
35
 
27
36
  const xPos = (d) =>
28
37
  typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
29
38
 
30
39
  // Each area edge (baseline + top) is placed so the area transposes under orientation.
31
- // `v` carries the raw y-value so the generator can break the area at a gap (null).
32
- const toEdge = (d) => ({ base: place(xPos(d), baseline), top: place(xPos(d), yScale(d[yf])), v: d[yf] })
40
+ // `sign` clamps the top edge to one side of the baseline — 'above' never dips below it,
41
+ // 'below' never rises above it — the same technique proven in Sparkline (#148). This MUST
42
+ // happen here, in value space, before `place()` runs: `place` is how horizontal/flipped
43
+ // orientation is implemented (it can swap x/y), so clamping the post-place coordinate would
44
+ // clamp whatever axis ends up there — including the category axis on a flipped chart. `v`
45
+ // carries the raw y-value so the generator can break the area at a gap (null).
46
+ const toEdge = (d, sign) => {
47
+ const rawTop = yScale(d[yf])
48
+ const clampedTop = sign === 'above' ? Math.min(rawTop, baseline) : sign === 'below' ? Math.max(rawTop, baseline) : rawTop
49
+ return { base: place(xPos(d), baseline), top: place(xPos(d), clampedTop), v: d[yf] }
50
+ }
33
51
  const makeGen = () => {
34
52
  const gen = area()
35
53
  // A null y-value is a GAP (a missing period): break the area there instead of
@@ -43,6 +61,29 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
43
61
  else if (curve === 'step') gen.curve(curveStep)
44
62
  return gen
45
63
  }
64
+ // One unsigned segment normally; with a baseline, an above and a below copy so themes can
65
+ // colour positive vs negative fill independently. Mirrors the approach proven in Sparkline
66
+ // (#148). Also owns the "no baseline" `d` computation so both call sites just hand over rows.
67
+ const splitBySign = (segBase, rows, grouped) => {
68
+ if (!hasBaseline) return [{ ...segBase, d: makeGen()(rows.map((d) => toEdge(d))) }]
69
+ return ['above', 'below'].map((sign) => ({
70
+ ...segBase,
71
+ d: makeGen()(rows.map((d) => toEdge(d, sign))),
72
+ sign,
73
+ // Only the genuinely ungrouped case (no color/fill channel at all) may use a bare
74
+ // sign — there's exactly one segment set in that case, so 'above'/'below' are
75
+ // already unique. A grouped segment must ALWAYS get a discriminated key, even when
76
+ // its group's value happens to be null/undefined: that's a real, distinct group,
77
+ // not "no grouping" — collapsing the two onto the same bare key is exactly the bug
78
+ // this branches around. `typeof` alone already keeps null (typeof 'object') and
79
+ // undefined (typeof 'undefined') apart, no extra special-casing needed for them.
80
+ // This assumes group values are primitives (string/number/boolean/null/undefined),
81
+ // true for every color/fill channel today; two distinct objects (or two Dates with
82
+ // equal timestamps) stringifying to the same "type:value" could still collide —
83
+ // an accepted, documented bound, not an oversight.
84
+ key: grouped ? `${typeof segBase.key}:${segBase.key}::${sign}` : sign
85
+ }))
86
+ }
46
87
 
47
88
  // For band (categorical) x scales, sort by domain index to preserve intended ordering.
48
89
  // For continuous scales, sort numerically so the path draws left-to-right.
@@ -62,7 +103,11 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
62
103
  patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
63
104
  ? toPatternId(String(patternKey))
64
105
  : null
65
- return [{ d: makeGen()(sortByX(data).map(toEdge)), fill: entry.fill, stroke: 'none', key: null, patternId }]
106
+ // `groupKey` is the untouched, unsigned group discriminator (here always null): callers
107
+ // that need to look a segment back up in a colors/patterns Map (keyed by the raw group
108
+ // value, not by our render key) should use this instead of `key`, since `key` gets a
109
+ // sign suffix once a baseline is in effect.
110
+ return splitBySign({ fill: entry.fill, stroke: 'none', key: null, groupKey: null, patternId }, sortByX(data), false)
66
111
  }
67
112
 
68
113
  // Group by color field
@@ -75,7 +120,7 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
75
120
  // For different-field patterns, assign positionally so each area gets a distinct pattern
76
121
  const orderedPatternKeys = pf && pf !== cf ? [...(patterns?.keys() ?? [])] : null
77
122
 
78
- return [...groups.entries()].map(([key, rows], i) => {
123
+ return [...groups.entries()].flatMap(([key, rows], i) => {
79
124
  const entry = colors?.get(key) ?? { fill: '#888', stroke: '#888' }
80
125
  // Same field or no pf: look up by colorKey. Different field: assign positionally.
81
126
  const patternKey = !pf
@@ -87,7 +132,7 @@ export function buildAreas(data, channels, xScale, yScale, colors, curve, patter
87
132
  patternKey !== null && patternKey !== undefined && patterns?.has(patternKey)
88
133
  ? toPatternId(String(patternKey))
89
134
  : null
90
- return { d: makeGen()(sortByX(rows).map(toEdge)), fill: entry.fill, stroke: 'none', key, patternId }
135
+ return splitBySign({ fill: entry.fill, stroke: 'none', key, groupKey: key, patternId }, sortByX(rows), true)
91
136
  })
92
137
  }
93
138
 
@@ -4,6 +4,9 @@
4
4
  * are set — so an old `color`-only area stays border-less (backward compatible); the
5
5
  * border uses the interior group's darker stroke shade. Per-field border coloring
6
6
  * (fill ≠ color, two legends) is the deferred dual-scale follow-up.
7
+ * `options.baseline`, when set, is passed straight to `buildAreas` (identity-position charts
8
+ * only — stacked/100% areas ignore it) and splits each segment into signed above/below copies
9
+ * (#148).
7
10
  * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
8
11
  */
9
12
  export function buildAreaMarks({ data, plot, channels, options, alpha, type }: {