@rokkit/chart 1.3.14 → 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.
Files changed (48) hide show
  1. package/dist/AnimatedPlot.svelte +4 -1
  2. package/dist/Plot.svelte +36 -9
  3. package/dist/Plot.svelte.d.ts +7 -0
  4. package/dist/PlotState.svelte.d.ts +1 -0
  5. package/dist/PlotState.svelte.js +10 -1
  6. package/dist/Spark.svelte +165 -0
  7. package/dist/Spark.svelte.d.ts +31 -0
  8. package/dist/SparkState.svelte.d.ts +138 -0
  9. package/dist/SparkState.svelte.js +331 -0
  10. package/dist/Sparkline.svelte +59 -179
  11. package/dist/Sparkline.svelte.d.ts +2 -0
  12. package/dist/charts/RadarChart.svelte +106 -0
  13. package/dist/charts/RadarChart.svelte.d.ts +43 -0
  14. package/dist/geoms/Area.svelte +17 -2
  15. package/dist/geoms/Area.svelte.d.ts +2 -0
  16. package/dist/geoms/Bar.svelte +2 -9
  17. package/dist/geoms/Highlight.svelte +2 -7
  18. package/dist/geoms/Line.svelte +2 -8
  19. package/dist/geoms/Point.svelte +2 -8
  20. package/dist/geoms/Radar.svelte +352 -0
  21. package/dist/geoms/Radar.svelte.d.ts +42 -0
  22. package/dist/geoms/lib/aesthetics.d.ts +14 -0
  23. package/dist/geoms/lib/aesthetics.js +21 -0
  24. package/dist/geoms/lib/areas.d.ts +9 -2
  25. package/dist/geoms/lib/areas.js +53 -8
  26. package/dist/geoms/lib/marks/area.d.ts +3 -0
  27. package/dist/geoms/lib/marks/area.js +11 -2
  28. package/dist/geoms/lib/marks/radar.d.ts +68 -0
  29. package/dist/geoms/lib/marks/radar.js +153 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +7 -1
  32. package/dist/lib/brewing/marks/boxes.js +19 -30
  33. package/dist/lib/brewing/polar.d.ts +433 -0
  34. package/dist/lib/brewing/polar.js +584 -0
  35. package/dist/lib/plot/scales.js +8 -5
  36. package/dist/lib/plot/types.d.ts +1 -0
  37. package/dist/lib/plot/types.js +1 -0
  38. package/dist/lib/preset.d.ts +1 -0
  39. package/dist/lib/preset.js +2 -1
  40. package/dist/lib/select.d.ts +3 -3
  41. package/dist/lib/select.js +1 -1
  42. package/package.json +1 -1
  43. package/dist/examples/BarChartExample.svelte +0 -78
  44. package/dist/examples/BarChartExample.svelte.d.ts +0 -18
  45. package/dist/lib/brewing/types.d.ts +0 -162
  46. package/dist/lib/brewing/types.js +0 -73
  47. package/dist/lib/xscale.d.ts +0 -11
  48. package/dist/lib/xscale.js +0 -31
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Pure polar layout for the radar/spider geom.
3
+ *
4
+ * A radar plots one series across N named axes arranged around a circle, so — unlike
5
+ * a Cartesian chart, where swapping row order never changes the plot — axis ORDER is
6
+ * itself the encoding: adjacent axes create visual peaks and valleys that read as
7
+ * relationships between them. Two axes that are uncorrelated in the data can look
8
+ * strongly linked just because a caller happened to declare them next to each other,
9
+ * and the reverse is true too. So axis order has to be an analytical choice made by
10
+ * whoever configures the chart, not an accident of however the upstream data happened
11
+ * to be sorted.
12
+ *
13
+ * This module holds no Svelte state and reaches into no chart context — it is called
14
+ * from both the `Radar` geom (inside `Plot`) and any future sparkline-style radar, so
15
+ * it must stay a plain data-in/data-out transform.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} AxisSpec
20
+ * @property {string} key - field name in the data this axis reads from
21
+ * @property {string} [label] - display label; defaults to `key`
22
+ * @property {string} [unit] - appended to the label in parentheses for `displayLabel`
23
+ * @property {[number, number]} [domain] - explicit value domain; otherwise inferred later
24
+ * @property {number} [ticks] - number of tick rings to draw
25
+ * @property {string[]} [tickLabels] - custom labels for each tick ring
26
+ * @property {Function} [format] - value formatter for ticks/tooltips
27
+ * @property {number} [weight] - relative angular share of the circle; defaults to 1
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} ResolvedAxis
32
+ * @property {string} key
33
+ * @property {string} label
34
+ * @property {string} displayLabel
35
+ * @property {[number, number]} [domain]
36
+ * @property {number} [ticks]
37
+ * @property {string[]} [tickLabels]
38
+ * @property {Function} [format]
39
+ * @property {number} weight
40
+ */
41
+
42
+ /**
43
+ * Fills in the derived fields shared by every axis, regardless of which of the two
44
+ * accepted shorthand forms it started from.
45
+ *
46
+ * @param {string|AxisSpec} entry
47
+ * @returns {ResolvedAxis}
48
+ */
49
+ function normaliseAxis(entry) {
50
+ const spec = typeof entry === 'string' ? { key: entry } : entry
51
+ const label = spec.label ?? spec.key
52
+ return {
53
+ ...spec,
54
+ label,
55
+ displayLabel: spec.unit ? `${label} (${spec.unit})` : label,
56
+ weight: spec.weight ?? 1
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolves the `axes` prop of a Radar chart to a normalised, ordered list.
62
+ *
63
+ * When `axes` is supplied it is the sole source of truth for both membership and
64
+ * order: axes it names but the data lacks are kept anyway (the geom renders an empty
65
+ * spoke for them — better a visible gap than a silently reshaped polygon), and data
66
+ * axes it doesn't name are dropped, with a dev warning, since silently adding an
67
+ * un-requested spoke would change the shape just as surely as silently reordering one.
68
+ *
69
+ * When `axes` is omitted entirely we fall back to first-appearance order in the data.
70
+ * That fallback exists so a bare `<Radar axis="metric" value="score" />` still renders
71
+ * something, but because radar shape is order-sensitive we warn every time it's used —
72
+ * the resulting order depends on incidental upstream row order, not an analytical
73
+ * choice, and should not be relied on for anything the caller shows to someone else.
74
+ *
75
+ * @param {(string|AxisSpec)[]|undefined} axes
76
+ * @param {Object[]} data
77
+ * @param {string} axisField - the data field naming which axis a row belongs to
78
+ * @returns {ResolvedAxis[]}
79
+ */
80
+ export function resolveAxes(axes, data, axisField) {
81
+ if (!axes) {
82
+ const seen = new Set()
83
+ for (const row of data) {
84
+ const key = row?.[axisField]
85
+ if (key !== undefined && !seen.has(key)) seen.add(key)
86
+ }
87
+ // eslint-disable-next-line no-console
88
+ console.warn(
89
+ '[Radar] No `axes` prop given — falling back to first-appearance order in the data. ' +
90
+ 'Radar shape is order-sensitive (adjacent axes read as related), so pass an explicit ' +
91
+ '`axes` array to make the order an analytical choice rather than an accident of row order.'
92
+ )
93
+ return [...seen].map(normaliseAxis)
94
+ }
95
+
96
+ const resolved = axes.map(normaliseAxis)
97
+
98
+ const declaredKeys = new Set(resolved.map((a) => a.key))
99
+ const dataKeys = new Set()
100
+ for (const row of data) {
101
+ const key = row?.[axisField]
102
+ if (key !== undefined) dataKeys.add(key)
103
+ }
104
+ const dropped = [...dataKeys].filter((key) => !declaredKeys.has(key))
105
+ if (dropped.length > 0) {
106
+ // eslint-disable-next-line no-console
107
+ console.warn(
108
+ `[Radar] Data axis(es) not named in \`axes\` were dropped: ${dropped.join(', ')}. ` +
109
+ 'Add them to `axes` if they belong on the chart.'
110
+ )
111
+ }
112
+
113
+ return resolved
114
+ }
115
+
116
+ /**
117
+ * Angle in degrees for each axis — first axis at the top, proceeding clockwise.
118
+ *
119
+ * Each axis owns a wedge proportional to its weight and sits at that wedge's
120
+ * MIDPOINT, so the wedge is symmetric around its spoke. Midpoint placement alone
121
+ * would put axis 0 half a sector past the top, so axis 0's half-wedge is
122
+ * subtracted to rotate the whole diagram back.
123
+ *
124
+ * At equal weights this reduces exactly to -90 + i*360/n. A test asserts that for
125
+ * n=3..6, because an earlier draft of this formula omitted the rotation term and was
126
+ * off by 180/n for every n — nothing sat at the top.
127
+ *
128
+ * @param {number[]} weights - one per axis; non-positive or non-finite treated as 1
129
+ * @returns {number[]} degrees, -90 = top
130
+ */
131
+ export function anglesFor(weights) {
132
+ const w = weights.map((x) => (Number.isFinite(x) && x > 0 ? x : 1))
133
+ const total = w.reduce((a, b) => a + b, 0)
134
+ if (!total) return []
135
+ const half0 = w[0] / 2
136
+ let before = 0
137
+ return w.map((wi) => {
138
+ const angle = -90 + (360 * (before + wi / 2 - half0)) / total
139
+ before += wi
140
+ return angle
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Value domain per axis for a radar/spider chart.
146
+ *
147
+ * A radar's most-cited honesty problem is that per-axis domains are usually INFERRED from
148
+ * whichever rows happen to be present: filter a comparator series out of `data` and every
149
+ * remaining series' radius moves, because the domain that normalises it just shrank — the
150
+ * rendered shape becomes a property of the query, not of the entity being plotted. A
151
+ * DECLARED `AxisSpec.domain` fixes this by being used verbatim regardless of which rows are
152
+ * in `data`, so removing a comparator moves nothing. That is why declaring `domain` is the
153
+ * documented recommendation rather than an advanced option — this module's spec asserts the
154
+ * stability directly: the same series computes identical domains with and without a
155
+ * much-larger comparator series under a declared domain, and DIFFERENT domains under an
156
+ * inferred one, so the distinction is provably real rather than assumed.
157
+ *
158
+ * Inferred domains (no declared `domain`) are `[0, max]` for non-negative data. An axis whose
159
+ * values include a negative infers `[min, max]` instead of clamping the low end to 0 —
160
+ * clamping would silently delete the part of the shape that dips below centre, which is a
161
+ * worse failure than an occasionally-asymmetric domain. Non-finite values (a stray string, a
162
+ * missing field) are filtered out of the min/max computation entirely, so they cannot poison
163
+ * a domain to `NaN`.
164
+ *
165
+ * `opts.sharedDomain` collapses every undeclared axis onto one `[min, max]` computed across
166
+ * ALL axes' values (still applying the negatives-extend rule globally, so one negative
167
+ * anywhere widens every shared-mode axis down), for callers who want one consistent scale
168
+ * across mixed axes instead of a per-axis one. A declared `domain` still wins per-axis even
169
+ * when `sharedDomain` is on — declaring an axis is a stronger, more specific statement than a
170
+ * chart-wide default.
171
+ *
172
+ * An axis with no data at all (named in `axes` but absent from every row) gets the finite,
173
+ * degenerate domain `[0, 0]` rather than `NaN` — enough for a caller to render an empty spoke
174
+ * without the radius math blowing up.
175
+ *
176
+ * @param {ResolvedAxis[]} axes
177
+ * @param {Object[]} data
178
+ * @param {{ x: string, y: string }} channels - `x` names the axis field, `y` the value field
179
+ * @param {{ sharedDomain?: boolean }} [opts]
180
+ * @returns {[number, number][]} one domain per axis, positionally aligned with `axes`
181
+ */
182
+ export function domainsFor(axes, data, channels, opts = {}) {
183
+ const { x: axisField, y: valueField } = channels
184
+ const sharedDomain = opts.sharedDomain ?? false
185
+
186
+ const valuesByKey = new Map(axes.map((axis) => [axis.key, []]))
187
+ for (const row of data) {
188
+ const values = valuesByKey.get(row?.[axisField])
189
+ if (!values) continue
190
+ const value = row?.[valueField]
191
+ if (Number.isFinite(value)) values.push(value)
192
+ }
193
+
194
+ const infer = (values) => {
195
+ if (values.length === 0) return [0, 0]
196
+ const min = Math.min(...values)
197
+ const max = Math.max(...values)
198
+ return min < 0 ? [min, max] : [0, max]
199
+ }
200
+
201
+ const shared = sharedDomain ? infer([...valuesByKey.values()].flat()) : null
202
+
203
+ return axes.map((axis) => axis.domain ?? shared ?? infer(valuesByKey.get(axis.key)))
204
+ }
205
+
206
+ /**
207
+ * Maps a value to a radius, in pixels, for one axis of a radar/spider chart.
208
+ *
209
+ * The centre is `domain.min`, NOT zero: on a domain of `[-5, 10]` with `R = 100`, a value of 0
210
+ * sits at `R * 5/15 ≈ 33.33`, not at the centre. Centring on 0 unconditionally would silently
211
+ * misplace every axis whose domain doesn't start at zero — this follows `domainsFor`'s own
212
+ * inferred-negative-domain rule (`[min, max]`, never clamped to 0): whatever `domainsFor` decided
213
+ * an axis's low end is, THAT is the value drawn at the centre, not literal zero.
214
+ *
215
+ * `transform` is `'linear'` (`R * ratio`) or `'sqrt'` (`R * sqrt(ratio)`) — see
216
+ * `resolveRadiusTransform` for why a caller would ever want the latter. Anything other than the
217
+ * literal string `'sqrt'` is treated as `'linear'`.
218
+ *
219
+ * Two failure modes are handled explicitly rather than left to produce `NaN`:
220
+ *
221
+ * - A zero-width domain (`min === max`, e.g. the `[0, 0]` `domainsFor` returns for an axis
222
+ * absent from every row) would divide by zero. Rather than `NaN`, it resolves to `0` — the
223
+ * centre — continuing the promise `domainsFor`'s own doc comment already makes for that case:
224
+ * "enough for a caller to render an empty spoke without the radius math blowing up."
225
+ * - A non-finite `value` (missing field, a stray string that slipped past upstream filtering)
226
+ * returns `null` rather than being coerced to some position. Plotting it anyway — at the
227
+ * centre, say — would silently reshape the polygon exactly the way `resolveAxes` already
228
+ * refuses to do for axis membership ("better a visible gap than a silently reshaped polygon").
229
+ * The geom is expected to break the line at a `null` vertex rather than draw a false one.
230
+ *
231
+ * A finite value outside `[min, max]` is clamped into `[0, R]` rather than left to overshoot the
232
+ * ring (linear) or hand a negative ratio to `sqrt`, which has no real root and would produce
233
+ * `NaN`. `domainsFor` treats an out-of-domain value as "the caller/renderer problem, not
234
+ * domainsFor's" — `radiusFor` *is* that renderer, so this is where the clamp belongs.
235
+ *
236
+ * @param {number} value
237
+ * @param {[number, number]} domain
238
+ * @param {number} R - outer radius in pixels
239
+ * @param {'linear'|'sqrt'} transform
240
+ * @returns {number|null} radius in pixels, or `null` for a non-finite value
241
+ */
242
+ export function radiusFor(value, domain, R, transform) {
243
+ if (!Number.isFinite(value)) return null
244
+
245
+ const [min, max] = domain
246
+ const span = max - min
247
+ if (span === 0) return 0
248
+
249
+ let ratio = (value - min) / span
250
+ if (ratio < 0) ratio = 0
251
+ else if (ratio > 1) ratio = 1
252
+
253
+ return transform === 'sqrt' ? R * Math.sqrt(ratio) : R * ratio
254
+ }
255
+
256
+ /**
257
+ * Picks the radius transform for a radar chart, given its axis weights.
258
+ *
259
+ * A wedge's area is ~½θr². `anglesFor` already makes θ (a wedge's angular width) proportional to
260
+ * weight for a weighted radar. If radius stayed linear in value, a heavily-weighted axis would get
261
+ * both a wider wedge AND a radius growing at the ordinary linear rate for the same value — the two
262
+ * compound, so swept area grows faster than `weight × value`, stacking on top of radar's already
263
+ * well-known area exaggeration (a 2x difference in a linear radius already reads as ~4x in swept
264
+ * area). Making radius track `√value` cancels exactly one factor of that compounding: with
265
+ * `r² ∝ value`, area becomes `∝ θ * value ∝ weight * value` — the honest claim a weighted radar is
266
+ * making about its data in the first place. (This module's spec asserts that proportionality
267
+ * numerically rather than arguing it, since it is the entire reason this branch exists.)
268
+ *
269
+ * At equal weights every wedge is the same width regardless of transform, so there is nothing to
270
+ * compensate for — `'linear'` is used, matching conventional radar (whose area exaggeration is a
271
+ * documented, accepted limitation, not something this module tries to fix).
272
+ *
273
+ * `requested` lets a caller pin the transform explicitly; an explicit `'linear'` or `'sqrt'`
274
+ * always wins over the weight-derived choice, so a consumer who has deliberately chosen one never
275
+ * has it silently swapped out from under them the next time someone adds an unequal weight to the
276
+ * axes list.
277
+ *
278
+ * Weights are normalised the same way `anglesFor` normalises them (non-positive or non-finite
279
+ * treated as 1) before the equality check, so this function's answer always matches the wedges
280
+ * `anglesFor` actually draws for the same `weights` array.
281
+ *
282
+ * @param {number[]} weights - one per axis, the same array `anglesFor` would receive
283
+ * @param {'linear'|'sqrt'|'auto'} [requested] - defaults to `'auto'`
284
+ * @returns {'linear'|'sqrt'}
285
+ */
286
+ export function resolveRadiusTransform(weights, requested = 'auto') {
287
+ if (requested === 'linear' || requested === 'sqrt') return requested
288
+
289
+ const w = weights.map((x) => (Number.isFinite(x) && x > 0 ? x : 1))
290
+ const allEqual = w.every((x) => x === w[0])
291
+ return allEqual ? 'linear' : 'sqrt'
292
+ }
293
+
294
+ /**
295
+ * @typedef {Object} Vertex
296
+ * @property {string} axisKey - the axis this vertex belongs to
297
+ * @property {number} value - the plotted value; the average when the `(series, axis)` cell had
298
+ * more than one row
299
+ * @property {number} angle - degrees, taken from `angles` at this axis's position
300
+ * @property {number} radius - pixels, from `radiusFor`; never `null` here because `value` is
301
+ * always finite by construction (non-finite readings are filtered out before averaging)
302
+ * @property {Record<string, unknown>} row - one of the original rows for this cell, chosen so `===` identity with
303
+ * the container's `data` array survives averaging
304
+ */
305
+
306
+ /**
307
+ * Turns rows into one polygon's worth of vertices per series, positionally aligned with `axes`.
308
+ *
309
+ * A row belongs to the `(series, axis)` cell identified by `channels.color` (or the single
310
+ * implicit series `undefined`, when no series channel is given at all) crossed with
311
+ * `channels.x`. Two situations a naive per-cell lookup would mishandle are resolved explicitly,
312
+ * for reasons that matter more than they look like they should:
313
+ *
314
+ * DUPLICATE cells — more than one row landing on the same `(series, axis)` pair — are averaged
315
+ * HERE, inside this module, rather than by routing through the generic `stat`/`applyGeomStat`
316
+ * machinery the rest of the package uses for aggregation. `applyGeomStat` calls
317
+ * `groupDataByKeys`, which builds a FRESH row object containing only the group-by keys plus the
318
+ * summarised value — a new allocation that fails `===` identity against anything in the
319
+ * container's `data` array. Every geom in this package computes its `onselect` index as
320
+ * `plotState.data.indexOf(row)`, so an aggregated row would report `index: -1` and silently drop
321
+ * every field that wasn't a channel. That is not a rare edge case for radar: long-format
322
+ * `(series × axis)` data duplicating a cell is the single most common data-shape mistake a radar
323
+ * consumer will make, so aggregation cannot be allowed to degrade interactivity the way a generic
324
+ * stat would. The fix: average the values, but keep a reference to one of the ORIGINAL row
325
+ * objects (the first one encountered) as the vertex's `row`, so identity survives. A repeated
326
+ * `(series, axis)` pair also `console.warn`s in dev, matching this file's existing warn
327
+ * register — axis is a small, fixed enum rather than a repeated-measurement field, so a duplicate
328
+ * is almost always a mistake rather than intentional multiple sampling.
329
+ *
330
+ * MISSING cells — a `(series, axis)` pair with no matching row at all, or one whose only
331
+ * matching row(s) all have a non-finite value — resolve to `null`, never to a value of `0`. Zero
332
+ * is a real, meaningful position on almost every axis (the centre, per `radiusFor`'s own
333
+ * contract), so defaulting a gap to it would silently invent a score and render as a real low
334
+ * value rather than as missing data — the same "better a visible gap than a silently reshaped
335
+ * polygon" principle `resolveAxes` already applies to axis membership. The geom is expected to
336
+ * break the polygon's outline at a `null` vertex instead of drawing a line through it.
337
+ *
338
+ * @param {Object[]} data
339
+ * @param {ResolvedAxis[]} axes - from `resolveAxes`; vertex order follows this array
340
+ * @param {number[]} angles - from `anglesFor(axes.map(a => a.weight))`, aligned with `axes`
341
+ * @param {[number, number][]} domains - from `domainsFor`, aligned with `axes`
342
+ * @param {number} R - outer radius in pixels, forwarded to `radiusFor`
343
+ * @param {'linear'|'sqrt'} transform - forwarded to `radiusFor`
344
+ * @param {{ x: string, y: string, color?: string }} channels - `x` names the axis field, `y` the
345
+ * value field, `color` the series field — mirroring `fill ?? color`'s role as the series
346
+ * channel in every other geom. Omitting it entirely means one implicit series.
347
+ * @returns {Map<unknown, (Vertex|null)[]>} one entry per distinct series value (or the single key
348
+ * `undefined` when `channels.color` is omitted), each holding one vertex — or `null` for a
349
+ * gap — per axis, in `axes` order
350
+ */
351
+ export function verticesFor(data, axes, angles, domains, R, transform, channels) {
352
+ const { x: axisField, y: valueField, color: seriesField } = channels
353
+
354
+ const cells = new Map()
355
+ for (const row of data) {
356
+ const seriesKey = seriesField !== undefined ? row?.[seriesField] : undefined
357
+ const axisKey = row?.[axisField]
358
+ if (!cells.has(seriesKey)) cells.set(seriesKey, new Map())
359
+ const bySeries = cells.get(seriesKey)
360
+ if (!bySeries.has(axisKey)) bySeries.set(axisKey, [])
361
+ bySeries.get(axisKey).push(row)
362
+ }
363
+
364
+ const result = new Map()
365
+ for (const [seriesKey, bySeries] of cells) {
366
+ const vertices = axes.map((axis, i) => {
367
+ const rows = bySeries.get(axis.key)
368
+ if (!rows) return null
369
+
370
+ if (rows.length > 1) {
371
+ // eslint-disable-next-line no-console
372
+ console.warn(
373
+ `[Radar] Duplicate (series, axis) cell — axis "${axis.key}"` +
374
+ `${seriesKey !== undefined ? `, series "${seriesKey}"` : ''} has ${rows.length} rows; ` +
375
+ 'averaging their values. A repeated (series, axis) pair is almost always a data bug, ' +
376
+ 'since axis is a small fixed enum rather than a repeated-measurement field.'
377
+ )
378
+ }
379
+
380
+ const values = rows.map((row) => row[valueField]).filter(Number.isFinite)
381
+ if (values.length === 0) return null
382
+
383
+ const value = values.reduce((a, b) => a + b, 0) / values.length
384
+ return {
385
+ axisKey: axis.key,
386
+ value,
387
+ angle: angles[i],
388
+ radius: radiusFor(value, domains[i], R, transform),
389
+ row: rows[0]
390
+ }
391
+ })
392
+ result.set(seriesKey, vertices)
393
+ }
394
+
395
+ return result
396
+ }
397
+
398
+ /**
399
+ * @typedef {Object} Ring
400
+ * @property {number} radius - pixels from centre
401
+ * @property {(string|number)[]} labels - one label per axis, positionally aligned with `axes`
402
+ */
403
+
404
+ /**
405
+ * How many concentric rings the shared grid draws, and why it can only ever be one number
406
+ * for the whole chart rather than one per axis.
407
+ *
408
+ * @param {ResolvedAxis[]} axes
409
+ * @param {number} fallbackRings
410
+ * @returns {number}
411
+ */
412
+ function resolveRingCount(axes, fallbackRings) {
413
+ const ticks = axes.map((axis) => axis.ticks)
414
+ // Vacuously false when axes is empty, which is what we want: nothing to agree on, so fall
415
+ // through to the fallback rather than reading `ticks[0]` off an empty array.
416
+ const uniform = axes.length > 0 && ticks.every((t) => t !== undefined && t === ticks[0])
417
+ if (uniform) return ticks[0]
418
+
419
+ if (ticks.some((t) => t !== undefined)) {
420
+ // eslint-disable-next-line no-console
421
+ console.warn(
422
+ '[Radar] AxisSpec.ticks differ across axes — falling back to `opts.rings` ' +
423
+ `(${fallbackRings}) for the shared grid. The geom draws ONE grid, common to every ` +
424
+ 'spoke, so per-spoke ring counts cannot be honoured; declare the same `ticks` on ' +
425
+ 'every axis if a ring is meant to read as "k of n" everywhere.'
426
+ )
427
+ }
428
+ return fallbackRings
429
+ }
430
+
431
+ /**
432
+ * Inverts `radiusFor` to answer "what value does ring `ratio` (a fraction of R) sit at, on
433
+ * THIS axis's domain and transform?" — needed because rings are laid out evenly in RADIUS
434
+ * (see `ringsFor`), so the value a given ring represents depends on the axis's domain and,
435
+ * under `sqrt`, on the transform too: `radius = R * sqrt(ratio)` inverts to `valueRatio =
436
+ * ratio^2`, not `ratio` itself.
437
+ *
438
+ * @param {number} ratio - `i / ringCount`, the SAME fraction of R every ring uses
439
+ * @param {[number, number]} domain
440
+ * @param {'linear'|'sqrt'} transform
441
+ * @returns {number}
442
+ */
443
+ function valueAtRing(ratio, domain, transform) {
444
+ const [min, max] = domain
445
+ const valueRatio = transform === 'sqrt' ? ratio ** 2 : ratio
446
+ return min + valueRatio * (max - min)
447
+ }
448
+
449
+ /**
450
+ * Concentric grid rings for a radar chart — one shared set of circles, evenly spaced in
451
+ * RADIUS, that every spoke is drawn against.
452
+ *
453
+ * Ring *i* sits at `(i / ringCount) * R`. This follows directly from `radiusFor`'s own linear
454
+ * mapping between radius and normalised position: since every spoke shares one `R` and the
455
+ * grid is one set of concentric circles (not one grid per axis), the only spacing that keeps
456
+ * "ring 2 is twice as far out as ring 1" true on EVERY spoke simultaneously is spacing by
457
+ * radius. Spacing the rings evenly in VALUE instead would only coincide with this for a
458
+ * domain that starts at 0 (see the spec: a `[20, 120]`-style domain exposes the divergence,
459
+ * because splitting an unanchored domain into equal value steps and converting each through
460
+ * `radiusFor` on ITS OWN axis does not reduce to `(i/ringCount) * R` the way it would for a
461
+ * zero-anchored domain).
462
+ *
463
+ * Ring COUNT has to be one number for the whole grid, because there is only one grid, drawn
464
+ * once. When every `AxisSpec` declares the same `ticks`, that count is used verbatim and ring
465
+ * *k* genuinely means "k of n" on every spoke. When they disagree, honouring any one axis's
466
+ * count would silently mislabel every other axis's rings, so this falls back to `opts.rings`
467
+ * (default 4) and warns — the geom cannot draw a different number of rings per spoke.
468
+ *
469
+ * Ring LABELS are resolved per axis (each ring has one label per axis, since the same radius
470
+ * means a different value on each spoke): a declared `tickLabels` entry wins outright — it is
471
+ * the ordinal vocabulary of a Likert-style axis and has no "value" to compute at all. Absent
472
+ * that, the ring's value on that axis (via `valueAtRing`) is run through the axis's `format`
473
+ * if one is given, else left as the bare number. A DECLARED domain makes this value a stable,
474
+ * meaningful tick; an INFERRED one (no `domain` supplied anywhere for this axis) makes it only
475
+ * an approximation of "if the current rows were the whole story."
476
+ *
477
+ * @param {ResolvedAxis[]} axes
478
+ * @param {{ R: number, rings?: number, domains?: [number, number][], transform?: 'linear'|'sqrt' }} opts
479
+ * @returns {Ring[]}
480
+ */
481
+ export function ringsFor(axes, opts = {}) {
482
+ const { R, rings: fallbackRings = 4, domains, transform = 'linear' } = opts
483
+ const ringCount = resolveRingCount(axes, fallbackRings)
484
+
485
+ return Array.from({ length: ringCount }, (_, idx) => {
486
+ const i = idx + 1
487
+ const radius = (i / ringCount) * R
488
+ const labels = axes.map((axis, axisIndex) => {
489
+ if (Array.isArray(axis.tickLabels) && axis.tickLabels[idx] !== undefined) {
490
+ return axis.tickLabels[idx]
491
+ }
492
+ const domain = domains?.[axisIndex] ?? axis.domain ?? [0, 0]
493
+ const value = valueAtRing(i / ringCount, domain, transform)
494
+ return typeof axis.format === 'function' ? axis.format(value) : value
495
+ })
496
+ return { radius, labels }
497
+ })
498
+ }
499
+
500
+ /**
501
+ * Zero-reference marker, per axis — a dashed ring segment (in the geom, not here) at the
502
+ * radius where the value 0 actually sits on that spoke.
503
+ *
504
+ * `radiusFor` centres each spoke on `domain.min`, not on 0, because `domainsFor` lets a
505
+ * negative value extend a domain rather than clamping it. That is honest about the data but
506
+ * has a real cost: the hub means a DIFFERENT value on every spoke whose domain doesn't start
507
+ * at 0, so a reader has no way to tell where "nothing" is just by looking at the centre. An
508
+ * all-zeros row would then bow OUTWARD to wherever 0 lands on each axis instead of collapsing
509
+ * to a point at the centre — the opposite of how a radar's most basic case is meant to read.
510
+ *
511
+ * So: any axis whose domain CONTAINS zero (`min <= 0 <= max`) but does not START there
512
+ * (`min !== 0`) gets a marker at `radiusFor(0, domain, R, transform)`. Two axes need none:
513
+ *
514
+ * - `min === 0` — the hub already is zero, nothing to mark.
515
+ * - the domain EXCLUDES zero entirely (`min > 0` or `max < 0`) — there is no honest position
516
+ * for "zero" ON this spoke at all. `radiusFor` would clamp value `0` to whichever end of
517
+ * `[0, R]` is nearest, which is exactly the position `domain.min` already occupies — drawing
518
+ * a "this is zero" marker there would misrepresent `domain.min` as zero, which is worse than
519
+ * drawing nothing. This module returns `null` for that case rather than a clamped radius.
520
+ *
521
+ * @param {ResolvedAxis[]} axes
522
+ * @param {[number, number][]} domains - aligned with `axes`, from `domainsFor`
523
+ * @param {number} R
524
+ * @param {'linear'|'sqrt'} transform
525
+ * @returns {(number|null)[]} one entry per axis, positionally aligned with `axes`
526
+ */
527
+ export function zeroRingFor(axes, domains, R, transform) {
528
+ return axes.map((_, i) => {
529
+ const [min, max] = domains[i]
530
+ const containsZero = min <= 0 && max >= 0
531
+ return containsZero && min !== 0 ? radiusFor(0, domains[i], R, transform) : null
532
+ })
533
+ }
534
+
535
+ /**
536
+ * @typedef {Object} RadarLayout
537
+ * @property {ResolvedAxis[]} axes
538
+ * @property {number[]} angles - from `anglesFor`, aligned with `axes`
539
+ * @property {[number, number][]} domains - from `domainsFor`, aligned with `axes`
540
+ * @property {Map<unknown, (import('./polar.js').Vertex|null)[]>} series - from `verticesFor`
541
+ * @property {Ring[]} rings - from `ringsFor`
542
+ * @property {(number|null)[]} zeroRings - from `zeroRingFor`, aligned with `axes`
543
+ * @property {number} radius - the outer radius, `R`, every other measurement is relative to
544
+ * @property {'linear'|'sqrt'} transform - resolved via `resolveRadiusTransform`
545
+ */
546
+
547
+ /**
548
+ * Composes every piece of this module into the one call a `Radar` geom actually needs — the
549
+ * public entry point, and the reason the rest of the module is allowed to stay a set of small,
550
+ * separately-testable functions instead of one large one.
551
+ *
552
+ * This function makes the ordering dependency between those functions explicit, because it is
553
+ * real, not incidental: `axes` must exist before weights can be read off it for `anglesFor`/
554
+ * `resolveRadiusTransform`; domains must exist before `verticesFor` (which places vertices) or
555
+ * `zeroRingFor` (which needs to know each axis's centre) can run; and the resolved `transform`
556
+ * must be shared between `verticesFor` and `ringsFor`/`zeroRingFor` so a ring drawn at "80% of
557
+ * R" and a vertex plotted at "80% of R" agree on what value that actually represents.
558
+ *
559
+ * `channels` follows the same `{ x, y, color? }` convention as `domainsFor`/`verticesFor` — `x`
560
+ * names the axis field, `y` the value field, `color` the series field. `opts.axes` is the
561
+ * `Radar` component's own `axes` prop (bare strings, full `AxisSpec`s, or a mix — see
562
+ * `resolveAxes`); `opts.R` defaults to `100` so this function is independently callable (by
563
+ * tests, tooling, anything that isn't a geom mid-render) without having to invent a pixel
564
+ * radius — a real `Radar.svelte` is expected to pass its actual computed outer radius instead.
565
+ *
566
+ * @param {Object[]} data
567
+ * @param {{ x: string, y: string, color?: string }} channels
568
+ * @param {{ axes?: (string|AxisSpec)[], sharedDomain?: boolean, rings?: number,
569
+ * radiusScale?: 'linear'|'sqrt'|'auto', R?: number }} [opts]
570
+ * @returns {RadarLayout}
571
+ */
572
+ export function buildRadarLayout(data, channels, opts = {}) {
573
+ const R = opts.R ?? 100
574
+ const axes = resolveAxes(opts.axes, data, channels.x)
575
+ const weights = axes.map((axis) => axis.weight)
576
+ const angles = anglesFor(weights)
577
+ const domains = domainsFor(axes, data, channels, { sharedDomain: opts.sharedDomain })
578
+ const transform = resolveRadiusTransform(weights, opts.radiusScale)
579
+ const series = verticesFor(data, axes, angles, domains, R, transform, channels)
580
+ const rings = ringsFor(axes, { R, rings: opts.rings, domains, transform })
581
+ const zeroRings = zeroRingFor(axes, domains, R, transform)
582
+
583
+ return { axes, angles, domains, series, rings, zeroRings, radius: R, transform }
584
+ }
@@ -42,10 +42,10 @@ export function buildUnifiedXScale(datasets, field, width, opts = {}) {
42
42
  const numericValues = allValues.map(Number)
43
43
  const [minVal, maxVal] = extent(numericValues)
44
44
  const domainMin = (opts.includeZero ?? false) ? 0 : (minVal ?? 0)
45
- return scaleLinear()
46
- .domain([domainMin, maxVal ?? 0])
47
- .range(range)
48
- .nice()
45
+ const s = scaleLinear().domain([domainMin, maxVal ?? 0]).range(range)
46
+ // Same opts.nice escape hatch as the opts.domain branch above (e.g. a spark's x-axis,
47
+ // which must span the full width edge-to-edge, not stop short at a rounded tick).
48
+ return opts.nice === false ? s : s.nice()
49
49
  }
50
50
 
51
51
  const domain = [...new Set(allValues)].filter((v) => v !== null && v !== undefined)
@@ -58,7 +58,10 @@ export function buildUnifiedXScale(datasets, field, width, opts = {}) {
58
58
  export function buildUnifiedYScale(datasets, field, height, opts = {}) {
59
59
  const yrange = opts.range ?? [height, 0]
60
60
  if (opts.domain) {
61
- return scaleLinear().domain(opts.domain).range(yrange).nice()
61
+ const s = scaleLinear().domain(opts.domain).range(yrange)
62
+ // nice() pads the domain outward to round numbers — wrong for a caller (e.g. a spark)
63
+ // whose domain is already the exact value it wants rendered edge-to-edge.
64
+ return opts.nice === false ? s : s.nice()
62
65
  }
63
66
  const rawValues = datasets.flatMap((d) => d.map((r) => r[field])).filter((v) => v !== null && v !== undefined)
64
67
  const isNumeric = rawValues.length > 0 && rawValues.every(
@@ -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]
@@ -34,6 +34,7 @@ export namespace defaultPreset {
34
34
  let ribbon: number;
35
35
  let waterfall: number;
36
36
  let rule: number;
37
+ let radar: number;
37
38
  }
38
39
  let patterns: string[];
39
40
  let symbols: string[];
@@ -23,7 +23,8 @@ export const defaultPreset = {
23
23
  hexbin: 1,
24
24
  ribbon: 0.5,
25
25
  waterfall: 1,
26
- rule: 1
26
+ rule: 1,
27
+ radar: 0.25
27
28
  },
28
29
  patterns: ['diagonal', 'dots', 'triangles', 'hatch', 'lattice', 'swell',
29
30
  'checkerboard', 'waves', 'petals', 'brick', 'diamonds', 'tile',
@@ -3,20 +3,20 @@
3
3
  * @param {Record<string, unknown>} datum
4
4
  * @param {number} index index within the geom's rendered data
5
5
  * @param {{ x?: string, y?: string }} channels
6
- * @param {'line'|'point'|'area'|'bar'} geom
6
+ * @param {'line'|'point'|'area'|'bar'|'radar'} geom
7
7
  * @param {unknown} series color/group value, or undefined
8
8
  * @param {MouseEvent|KeyboardEvent} event
9
9
  */
10
10
  export function buildSelectDetail(datum: Record<string, unknown>, index: number, channels: {
11
11
  x?: string;
12
12
  y?: string;
13
- }, geom: "line" | "point" | "area" | "bar", series: unknown, event: MouseEvent | KeyboardEvent): {
13
+ }, geom: "line" | "point" | "area" | "bar" | "radar", series: unknown, event: MouseEvent | KeyboardEvent): {
14
14
  datum: Record<string, unknown>;
15
15
  index: number;
16
16
  series: {} | undefined;
17
17
  value: unknown;
18
18
  x: unknown;
19
19
  y: unknown;
20
- geom: "bar" | "line" | "area" | "point";
20
+ geom: "bar" | "line" | "area" | "point" | "radar";
21
21
  event: MouseEvent | KeyboardEvent;
22
22
  };