@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,645 @@
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
+ warnInferredAxisOrder()
83
+ return axisKeysInData(data, axisField).map(normaliseAxis)
84
+ }
85
+
86
+ const resolved = axes.map(normaliseAxis)
87
+ const declaredKeys = new Set(resolved.map((a) => a.key))
88
+ warnDroppedAxes(axisKeysInData(data, axisField).filter((key) => !declaredKeys.has(key)))
89
+ return resolved
90
+ }
91
+
92
+ /**
93
+ * Distinct axis keys present in the data, in first-appearance order. Shared by
94
+ * both branches of `resolveAxes`, which previously walked the rows twice with
95
+ * near-identical loops.
96
+ * @param {Object[]} data
97
+ * @param {string} axisField
98
+ * @returns {unknown[]}
99
+ */
100
+ function axisKeysInData(data, axisField) {
101
+ const seen = new Set()
102
+ for (const row of data) {
103
+ const key = row?.[axisField]
104
+ if (key !== undefined) seen.add(key)
105
+ }
106
+ return [...seen]
107
+ }
108
+
109
+ function warnInferredAxisOrder() {
110
+ // eslint-disable-next-line no-console
111
+ console.warn(
112
+ '[Radar] No `axes` prop given — falling back to first-appearance order in the data. ' +
113
+ 'Radar shape is order-sensitive (adjacent axes read as related), so pass an explicit ' +
114
+ '`axes` array to make the order an analytical choice rather than an accident of row order.'
115
+ )
116
+ }
117
+
118
+ /** @param {unknown[]} dropped */
119
+ function warnDroppedAxes(dropped) {
120
+ if (dropped.length === 0) return
121
+ // eslint-disable-next-line no-console
122
+ console.warn(
123
+ `[Radar] Data axis(es) not named in \`axes\` were dropped: ${dropped.join(', ')}. ` +
124
+ 'Add them to `axes` if they belong on the chart.'
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Angle in degrees for each axis — first axis at the top, proceeding clockwise.
130
+ *
131
+ * Each axis owns a wedge proportional to its weight and sits at that wedge's
132
+ * MIDPOINT, so the wedge is symmetric around its spoke. Midpoint placement alone
133
+ * would put axis 0 half a sector past the top, so axis 0's half-wedge is
134
+ * subtracted to rotate the whole diagram back.
135
+ *
136
+ * At equal weights this reduces exactly to -90 + i*360/n. A test asserts that for
137
+ * n=3..6, because an earlier draft of this formula omitted the rotation term and was
138
+ * off by 180/n for every n — nothing sat at the top.
139
+ *
140
+ * @param {number[]} weights - one per axis; non-positive or non-finite treated as 1
141
+ * @returns {number[]} degrees, -90 = top
142
+ */
143
+ export function anglesFor(weights) {
144
+ const w = weights.map((x) => (Number.isFinite(x) && x > 0 ? x : 1))
145
+ const total = w.reduce((a, b) => a + b, 0)
146
+ if (!total) return []
147
+ const half0 = w[0] / 2
148
+ let before = 0
149
+ return w.map((wi) => {
150
+ const angle = -90 + (360 * (before + wi / 2 - half0)) / total
151
+ before += wi
152
+ return angle
153
+ })
154
+ }
155
+
156
+ /**
157
+ * Value domain per axis for a radar/spider chart.
158
+ *
159
+ * A radar's most-cited honesty problem is that per-axis domains are usually INFERRED from
160
+ * whichever rows happen to be present: filter a comparator series out of `data` and every
161
+ * remaining series' radius moves, because the domain that normalises it just shrank — the
162
+ * rendered shape becomes a property of the query, not of the entity being plotted. A
163
+ * DECLARED `AxisSpec.domain` fixes this by being used verbatim regardless of which rows are
164
+ * in `data`, so removing a comparator moves nothing. That is why declaring `domain` is the
165
+ * documented recommendation rather than an advanced option — this module's spec asserts the
166
+ * stability directly: the same series computes identical domains with and without a
167
+ * much-larger comparator series under a declared domain, and DIFFERENT domains under an
168
+ * inferred one, so the distinction is provably real rather than assumed.
169
+ *
170
+ * Inferred domains (no declared `domain`) are `[0, max]` for non-negative data. An axis whose
171
+ * values include a negative infers `[min, max]` instead of clamping the low end to 0 —
172
+ * clamping would silently delete the part of the shape that dips below centre, which is a
173
+ * worse failure than an occasionally-asymmetric domain. Non-finite values (a stray string, a
174
+ * missing field) are filtered out of the min/max computation entirely, so they cannot poison
175
+ * a domain to `NaN`.
176
+ *
177
+ * `opts.sharedDomain` collapses every undeclared axis onto one `[min, max]` computed across
178
+ * ALL axes' values (still applying the negatives-extend rule globally, so one negative
179
+ * anywhere widens every shared-mode axis down), for callers who want one consistent scale
180
+ * across mixed axes instead of a per-axis one. A declared `domain` still wins per-axis even
181
+ * when `sharedDomain` is on — declaring an axis is a stronger, more specific statement than a
182
+ * chart-wide default.
183
+ *
184
+ * An axis with no data at all (named in `axes` but absent from every row) gets the finite,
185
+ * degenerate domain `[0, 0]` rather than `NaN` — enough for a caller to render an empty spoke
186
+ * without the radius math blowing up.
187
+ *
188
+ * @param {ResolvedAxis[]} axes
189
+ * @param {Object[]} data
190
+ * @param {{ x: string, y: string }} channels - `x` names the axis field, `y` the value field
191
+ * @param {{ sharedDomain?: boolean }} [opts]
192
+ * @returns {[number, number][]} one domain per axis, positionally aligned with `axes`
193
+ */
194
+ export function domainsFor(axes, data, channels, opts = {}) {
195
+ const valuesByKey = finiteValuesByAxis(axes, data, channels)
196
+ const shared = opts.sharedDomain ? inferDomain([...valuesByKey.values()].flat()) : null
197
+ return axes.map((axis) => axis.domain ?? shared ?? inferDomain(valuesByKey.get(axis.key)))
198
+ }
199
+
200
+ /**
201
+ * Finite values bucketed per axis key. Rows naming an axis that isn't declared
202
+ * are skipped — `resolveAxes` has already warned about them.
203
+ * @param {ResolvedAxis[]} axes
204
+ * @param {Object[]} data
205
+ * @param {{ x: string, y: string }} channels
206
+ * @returns {Map<unknown, number[]>}
207
+ */
208
+ function finiteValuesByAxis(axes, data, { x: axisField, y: valueField }) {
209
+ const byKey = new Map(axes.map((axis) => [axis.key, []]))
210
+ for (const row of data) {
211
+ const values = byKey.get(row?.[axisField])
212
+ if (!values) continue
213
+ const value = row?.[valueField]
214
+ if (Number.isFinite(value)) values.push(value)
215
+ }
216
+ return byKey
217
+ }
218
+
219
+ /**
220
+ * Domain covering `values`, anchored at 0 unless the data goes negative. An
221
+ * axis with no values gets `[0, 0]` — enough to render an empty spoke without
222
+ * the radius math blowing up (see `radiusFor`).
223
+ * @param {number[]} values
224
+ * @returns {[number, number]}
225
+ */
226
+ function inferDomain(values) {
227
+ if (values.length === 0) return [0, 0]
228
+ const min = Math.min(...values)
229
+ const max = Math.max(...values)
230
+ return min < 0 ? [min, max] : [0, max]
231
+ }
232
+
233
+ /**
234
+ * Maps a value to a radius, in pixels, for one axis of a radar/spider chart.
235
+ *
236
+ * The centre is `domain.min`, NOT zero: on a domain of `[-5, 10]` with `R = 100`, a value of 0
237
+ * sits at `R * 5/15 ≈ 33.33`, not at the centre. Centring on 0 unconditionally would silently
238
+ * misplace every axis whose domain doesn't start at zero — this follows `domainsFor`'s own
239
+ * inferred-negative-domain rule (`[min, max]`, never clamped to 0): whatever `domainsFor` decided
240
+ * an axis's low end is, THAT is the value drawn at the centre, not literal zero.
241
+ *
242
+ * `transform` is `'linear'` (`R * ratio`) or `'sqrt'` (`R * sqrt(ratio)`) — see
243
+ * `resolveRadiusTransform` for why a caller would ever want the latter. Anything other than the
244
+ * literal string `'sqrt'` is treated as `'linear'`.
245
+ *
246
+ * Two failure modes are handled explicitly rather than left to produce `NaN`:
247
+ *
248
+ * - A zero-width domain (`min === max`, e.g. the `[0, 0]` `domainsFor` returns for an axis
249
+ * absent from every row) would divide by zero. Rather than `NaN`, it resolves to `0` — the
250
+ * centre — continuing the promise `domainsFor`'s own doc comment already makes for that case:
251
+ * "enough for a caller to render an empty spoke without the radius math blowing up."
252
+ * - A non-finite `value` (missing field, a stray string that slipped past upstream filtering)
253
+ * returns `null` rather than being coerced to some position. Plotting it anyway — at the
254
+ * centre, say — would silently reshape the polygon exactly the way `resolveAxes` already
255
+ * refuses to do for axis membership ("better a visible gap than a silently reshaped polygon").
256
+ * The geom is expected to break the line at a `null` vertex rather than draw a false one.
257
+ *
258
+ * A finite value outside `[min, max]` is clamped into `[0, R]` rather than left to overshoot the
259
+ * ring (linear) or hand a negative ratio to `sqrt`, which has no real root and would produce
260
+ * `NaN`. `domainsFor` treats an out-of-domain value as "the caller/renderer problem, not
261
+ * domainsFor's" — `radiusFor` *is* that renderer, so this is where the clamp belongs.
262
+ *
263
+ * @param {number} value
264
+ * @param {[number, number]} domain
265
+ * @param {number} R - outer radius in pixels
266
+ * @param {'linear'|'sqrt'} transform
267
+ * @returns {number|null} radius in pixels, or `null` for a non-finite value
268
+ */
269
+ export function radiusFor(value, domain, R, transform) {
270
+ if (!Number.isFinite(value)) return null
271
+
272
+ const [min, max] = domain
273
+ const span = max - min
274
+ if (span === 0) return 0
275
+
276
+ let ratio = (value - min) / span
277
+ if (ratio < 0) ratio = 0
278
+ else if (ratio > 1) ratio = 1
279
+
280
+ return transform === 'sqrt' ? R * Math.sqrt(ratio) : R * ratio
281
+ }
282
+
283
+ /**
284
+ * Picks the radius transform for a radar chart, given its axis weights.
285
+ *
286
+ * A wedge's area is ~½θr². `anglesFor` already makes θ (a wedge's angular width) proportional to
287
+ * weight for a weighted radar. If radius stayed linear in value, a heavily-weighted axis would get
288
+ * both a wider wedge AND a radius growing at the ordinary linear rate for the same value — the two
289
+ * compound, so swept area grows faster than `weight × value`, stacking on top of radar's already
290
+ * well-known area exaggeration (a 2x difference in a linear radius already reads as ~4x in swept
291
+ * area). Making radius track `√value` cancels exactly one factor of that compounding: with
292
+ * `r² ∝ value`, area becomes `∝ θ * value ∝ weight * value` — the honest claim a weighted radar is
293
+ * making about its data in the first place. (This module's spec asserts that proportionality
294
+ * numerically rather than arguing it, since it is the entire reason this branch exists.)
295
+ *
296
+ * At equal weights every wedge is the same width regardless of transform, so there is nothing to
297
+ * compensate for — `'linear'` is used, matching conventional radar (whose area exaggeration is a
298
+ * documented, accepted limitation, not something this module tries to fix).
299
+ *
300
+ * `requested` lets a caller pin the transform explicitly; an explicit `'linear'` or `'sqrt'`
301
+ * always wins over the weight-derived choice, so a consumer who has deliberately chosen one never
302
+ * has it silently swapped out from under them the next time someone adds an unequal weight to the
303
+ * axes list.
304
+ *
305
+ * Weights are normalised the same way `anglesFor` normalises them (non-positive or non-finite
306
+ * treated as 1) before the equality check, so this function's answer always matches the wedges
307
+ * `anglesFor` actually draws for the same `weights` array.
308
+ *
309
+ * @param {number[]} weights - one per axis, the same array `anglesFor` would receive
310
+ * @param {'linear'|'sqrt'|'auto'} [requested] - defaults to `'auto'`
311
+ * @returns {'linear'|'sqrt'}
312
+ */
313
+ export function resolveRadiusTransform(weights, requested = 'auto') {
314
+ if (requested === 'linear' || requested === 'sqrt') return requested
315
+
316
+ const w = weights.map((x) => (Number.isFinite(x) && x > 0 ? x : 1))
317
+ const allEqual = w.every((x) => x === w[0])
318
+ return allEqual ? 'linear' : 'sqrt'
319
+ }
320
+
321
+ /**
322
+ * @typedef {Object} Vertex
323
+ * @property {string} axisKey - the axis this vertex belongs to
324
+ * @property {number} value - the plotted value; the average when the `(series, axis)` cell had
325
+ * more than one row
326
+ * @property {number} angle - degrees, taken from `angles` at this axis's position
327
+ * @property {number} radius - pixels, from `radiusFor`; never `null` here because `value` is
328
+ * always finite by construction (non-finite readings are filtered out before averaging)
329
+ * @property {Record<string, unknown>} row - one of the original rows for this cell, chosen so `===` identity with
330
+ * the container's `data` array survives averaging
331
+ */
332
+
333
+ /**
334
+ * Turns rows into one polygon's worth of vertices per series, positionally aligned with `axes`.
335
+ *
336
+ * A row belongs to the `(series, axis)` cell identified by `channels.color` (or the single
337
+ * implicit series `undefined`, when no series channel is given at all) crossed with
338
+ * `channels.x`. Two situations a naive per-cell lookup would mishandle are resolved explicitly,
339
+ * for reasons that matter more than they look like they should:
340
+ *
341
+ * DUPLICATE cells — more than one row landing on the same `(series, axis)` pair — are averaged
342
+ * HERE, inside this module, rather than by routing through the generic `stat`/`applyGeomStat`
343
+ * machinery the rest of the package uses for aggregation. `applyGeomStat` calls
344
+ * `groupDataByKeys`, which builds a FRESH row object containing only the group-by keys plus the
345
+ * summarised value — a new allocation that fails `===` identity against anything in the
346
+ * container's `data` array. Every geom in this package computes its `onselect` index as
347
+ * `plotState.data.indexOf(row)`, so an aggregated row would report `index: -1` and silently drop
348
+ * every field that wasn't a channel. That is not a rare edge case for radar: long-format
349
+ * `(series × axis)` data duplicating a cell is the single most common data-shape mistake a radar
350
+ * consumer will make, so aggregation cannot be allowed to degrade interactivity the way a generic
351
+ * stat would. The fix: average the values, but keep a reference to one of the ORIGINAL row
352
+ * objects (the first one encountered) as the vertex's `row`, so identity survives. A repeated
353
+ * `(series, axis)` pair also `console.warn`s in dev, matching this file's existing warn
354
+ * register — axis is a small, fixed enum rather than a repeated-measurement field, so a duplicate
355
+ * is almost always a mistake rather than intentional multiple sampling.
356
+ *
357
+ * MISSING cells — a `(series, axis)` pair with no matching row at all, or one whose only
358
+ * matching row(s) all have a non-finite value — resolve to `null`, never to a value of `0`. Zero
359
+ * is a real, meaningful position on almost every axis (the centre, per `radiusFor`'s own
360
+ * contract), so defaulting a gap to it would silently invent a score and render as a real low
361
+ * value rather than as missing data — the same "better a visible gap than a silently reshaped
362
+ * polygon" principle `resolveAxes` already applies to axis membership. The geom is expected to
363
+ * break the polygon's outline at a `null` vertex instead of drawing a line through it.
364
+ *
365
+ * The geometry arguments travel as one bag rather than six positionals, matching `ringsFor`'s
366
+ * `(axes, { R, rings, domains, transform })` shape. At the call site that is the difference
367
+ * between `verticesFor(data, axes, angles, domains, 100, 'linear', channels)` — where the reader
368
+ * has to count commas to learn what `100` and `'linear'` mean — and naming each one.
369
+ *
370
+ * @param {Object[]} data
371
+ * @param {ResolvedAxis[]} axes - from `resolveAxes`; vertex order follows this array
372
+ * @param {{ x: string, y: string, color?: string }} channels - `x` names the axis field, `y` the
373
+ * value field, `color` the series field — mirroring `fill ?? color`'s role as the series
374
+ * channel in every other geom. Omitting it entirely means one implicit series.
375
+ * @param {Object} geometry
376
+ * @param {number[]} geometry.angles - from `anglesFor(axes.map(a => a.weight))`, aligned with `axes`
377
+ * @param {[number, number][]} geometry.domains - from `domainsFor`, aligned with `axes`
378
+ * @param {number} geometry.R - outer radius in pixels, forwarded to `radiusFor`
379
+ * @param {'linear'|'sqrt'} geometry.transform - forwarded to `radiusFor`
380
+ * @returns {Map<unknown, (Vertex|null)[]>} one entry per distinct series value (or the single key
381
+ * `undefined` when `channels.color` is omitted), each holding one vertex — or `null` for a
382
+ * gap — per axis, in `axes` order
383
+ */
384
+ export function verticesFor(data, axes, channels, { angles, domains, R, transform }) {
385
+ const { y: valueField } = channels
386
+ const cells = cellsBySeriesAndAxis(data, channels)
387
+
388
+ const result = new Map()
389
+ for (const [seriesKey, bySeries] of cells) {
390
+ const vertices = axes.map((axis, i) => {
391
+ const rows = bySeries.get(axis.key)
392
+ if (!rows) return null
393
+ const value = cellValue(rows, { axisKey: axis.key, seriesKey, valueField })
394
+ if (value === null) return null
395
+ return {
396
+ axisKey: axis.key,
397
+ value,
398
+ angle: angles[i],
399
+ radius: radiusFor(value, domains[i], R, transform),
400
+ row: rows[0]
401
+ }
402
+ })
403
+ result.set(seriesKey, vertices)
404
+ }
405
+
406
+ return result
407
+ }
408
+
409
+ /**
410
+ * Group rows two levels deep, series → axis → rows, because a radar cell is
411
+ * identified by the `(series, axis)` pair rather than by either alone.
412
+ * @param {Object[]} data
413
+ * @param {{ x: string, color?: string }} channels
414
+ * @returns {Map<unknown, Map<unknown, Object[]>>}
415
+ */
416
+ function cellsBySeriesAndAxis(data, { x: axisField, color: seriesField }) {
417
+ const cells = new Map()
418
+ for (const row of data) {
419
+ const seriesKey = seriesField !== undefined ? row?.[seriesField] : undefined
420
+ const axisKey = row?.[axisField]
421
+ if (!cells.has(seriesKey)) cells.set(seriesKey, new Map())
422
+ const bySeries = cells.get(seriesKey)
423
+ if (!bySeries.has(axisKey)) bySeries.set(axisKey, [])
424
+ bySeries.get(axisKey).push(row)
425
+ }
426
+ return cells
427
+ }
428
+
429
+ /**
430
+ * The single value a `(series, axis)` cell contributes: the mean of its finite
431
+ * values, or `null` when it has none. See this module's `verticesFor` doc for
432
+ * why a repeated pair is averaged AND warned about rather than silently picked.
433
+ * @param {Object[]} rows
434
+ * @param {{ axisKey: unknown, seriesKey: unknown, valueField: string }} cell
435
+ * @returns {number|null}
436
+ */
437
+ function cellValue(rows, { axisKey, seriesKey, valueField }) {
438
+ if (rows.length > 1) warnDuplicateCell(rows.length, axisKey, seriesKey)
439
+ const values = rows.map((row) => row[valueField]).filter(Number.isFinite)
440
+ if (values.length === 0) return null
441
+ return values.reduce((a, b) => a + b, 0) / values.length
442
+ }
443
+
444
+ /**
445
+ * @param {number} count
446
+ * @param {unknown} axisKey
447
+ * @param {unknown} seriesKey
448
+ */
449
+ function warnDuplicateCell(count, axisKey, seriesKey) {
450
+ // eslint-disable-next-line no-console
451
+ console.warn(
452
+ `[Radar] Duplicate (series, axis) cell — axis "${axisKey}"` +
453
+ `${seriesKey !== undefined ? `, series "${seriesKey}"` : ''} has ${count} rows; ` +
454
+ 'averaging their values. A repeated (series, axis) pair is almost always a data bug, ' +
455
+ 'since axis is a small fixed enum rather than a repeated-measurement field.'
456
+ )
457
+ }
458
+
459
+ /**
460
+ * @typedef {Object} Ring
461
+ * @property {number} radius - pixels from centre
462
+ * @property {(string|number)[]} labels - one label per axis, positionally aligned with `axes`
463
+ */
464
+
465
+ /**
466
+ * How many concentric rings the shared grid draws, and why it can only ever be one number
467
+ * for the whole chart rather than one per axis.
468
+ *
469
+ * @param {ResolvedAxis[]} axes
470
+ * @param {number} fallbackRings
471
+ * @returns {number}
472
+ */
473
+ function resolveRingCount(axes, fallbackRings) {
474
+ const ticks = axes.map((axis) => axis.ticks)
475
+ // Vacuously false when axes is empty, which is what we want: nothing to agree on, so fall
476
+ // through to the fallback rather than reading `ticks[0]` off an empty array.
477
+ const uniform = axes.length > 0 && ticks.every((t) => t !== undefined && t === ticks[0])
478
+ if (uniform) return ticks[0]
479
+
480
+ if (ticks.some((t) => t !== undefined)) {
481
+ // eslint-disable-next-line no-console
482
+ console.warn(
483
+ '[Radar] AxisSpec.ticks differ across axes — falling back to `opts.rings` ' +
484
+ `(${fallbackRings}) for the shared grid. The geom draws ONE grid, common to every ` +
485
+ 'spoke, so per-spoke ring counts cannot be honoured; declare the same `ticks` on ' +
486
+ 'every axis if a ring is meant to read as "k of n" everywhere.'
487
+ )
488
+ }
489
+ return fallbackRings
490
+ }
491
+
492
+ /**
493
+ * Inverts `radiusFor` to answer "what value does ring `ratio` (a fraction of R) sit at, on
494
+ * THIS axis's domain and transform?" — needed because rings are laid out evenly in RADIUS
495
+ * (see `ringsFor`), so the value a given ring represents depends on the axis's domain and,
496
+ * under `sqrt`, on the transform too: `radius = R * sqrt(ratio)` inverts to `valueRatio =
497
+ * ratio^2`, not `ratio` itself.
498
+ *
499
+ * @param {number} ratio - `i / ringCount`, the SAME fraction of R every ring uses
500
+ * @param {[number, number]} domain
501
+ * @param {'linear'|'sqrt'} transform
502
+ * @returns {number}
503
+ */
504
+ function valueAtRing(ratio, domain, transform) {
505
+ const [min, max] = domain
506
+ const valueRatio = transform === 'sqrt' ? ratio ** 2 : ratio
507
+ return min + valueRatio * (max - min)
508
+ }
509
+
510
+ /**
511
+ * Concentric grid rings for a radar chart — one shared set of circles, evenly spaced in
512
+ * RADIUS, that every spoke is drawn against.
513
+ *
514
+ * Ring *i* sits at `(i / ringCount) * R`. This follows directly from `radiusFor`'s own linear
515
+ * mapping between radius and normalised position: since every spoke shares one `R` and the
516
+ * grid is one set of concentric circles (not one grid per axis), the only spacing that keeps
517
+ * "ring 2 is twice as far out as ring 1" true on EVERY spoke simultaneously is spacing by
518
+ * radius. Spacing the rings evenly in VALUE instead would only coincide with this for a
519
+ * domain that starts at 0 (see the spec: a `[20, 120]`-style domain exposes the divergence,
520
+ * because splitting an unanchored domain into equal value steps and converting each through
521
+ * `radiusFor` on ITS OWN axis does not reduce to `(i/ringCount) * R` the way it would for a
522
+ * zero-anchored domain).
523
+ *
524
+ * Ring COUNT has to be one number for the whole grid, because there is only one grid, drawn
525
+ * once. When every `AxisSpec` declares the same `ticks`, that count is used verbatim and ring
526
+ * *k* genuinely means "k of n" on every spoke. When they disagree, honouring any one axis's
527
+ * count would silently mislabel every other axis's rings, so this falls back to `opts.rings`
528
+ * (default 4) and warns — the geom cannot draw a different number of rings per spoke.
529
+ *
530
+ * Ring LABELS are resolved per axis (each ring has one label per axis, since the same radius
531
+ * means a different value on each spoke): a declared `tickLabels` entry wins outright — it is
532
+ * the ordinal vocabulary of a Likert-style axis and has no "value" to compute at all. Absent
533
+ * that, the ring's value on that axis (via `valueAtRing`) is run through the axis's `format`
534
+ * if one is given, else left as the bare number. A DECLARED domain makes this value a stable,
535
+ * meaningful tick; an INFERRED one (no `domain` supplied anywhere for this axis) makes it only
536
+ * an approximation of "if the current rows were the whole story."
537
+ *
538
+ * @param {ResolvedAxis[]} axes
539
+ * @param {{ R: number, rings?: number, domains?: [number, number][], transform?: 'linear'|'sqrt' }} opts
540
+ * @returns {Ring[]}
541
+ */
542
+ export function ringsFor(axes, opts = {}) {
543
+ const { R, rings: fallbackRings = 4, domains, transform = 'linear' } = opts
544
+ const ringCount = resolveRingCount(axes, fallbackRings)
545
+
546
+ return Array.from({ length: ringCount }, (_, idx) => {
547
+ const i = idx + 1
548
+ const radius = (i / ringCount) * R
549
+ const labels = axes.map((axis, axisIndex) => {
550
+ if (Array.isArray(axis.tickLabels) && axis.tickLabels[idx] !== undefined) {
551
+ return axis.tickLabels[idx]
552
+ }
553
+ const domain = domains?.[axisIndex] ?? axis.domain ?? [0, 0]
554
+ const value = valueAtRing(i / ringCount, domain, transform)
555
+ return typeof axis.format === 'function' ? axis.format(value) : value
556
+ })
557
+ return { radius, labels }
558
+ })
559
+ }
560
+
561
+ /**
562
+ * Zero-reference marker, per axis — a dashed ring segment (in the geom, not here) at the
563
+ * radius where the value 0 actually sits on that spoke.
564
+ *
565
+ * `radiusFor` centres each spoke on `domain.min`, not on 0, because `domainsFor` lets a
566
+ * negative value extend a domain rather than clamping it. That is honest about the data but
567
+ * has a real cost: the hub means a DIFFERENT value on every spoke whose domain doesn't start
568
+ * at 0, so a reader has no way to tell where "nothing" is just by looking at the centre. An
569
+ * all-zeros row would then bow OUTWARD to wherever 0 lands on each axis instead of collapsing
570
+ * to a point at the centre — the opposite of how a radar's most basic case is meant to read.
571
+ *
572
+ * So: any axis whose domain CONTAINS zero (`min <= 0 <= max`) but does not START there
573
+ * (`min !== 0`) gets a marker at `radiusFor(0, domain, R, transform)`. Two axes need none:
574
+ *
575
+ * - `min === 0` — the hub already is zero, nothing to mark.
576
+ * - the domain EXCLUDES zero entirely (`min > 0` or `max < 0`) — there is no honest position
577
+ * for "zero" ON this spoke at all. `radiusFor` would clamp value `0` to whichever end of
578
+ * `[0, R]` is nearest, which is exactly the position `domain.min` already occupies — drawing
579
+ * a "this is zero" marker there would misrepresent `domain.min` as zero, which is worse than
580
+ * drawing nothing. This module returns `null` for that case rather than a clamped radius.
581
+ *
582
+ * @param {ResolvedAxis[]} axes
583
+ * @param {[number, number][]} domains - aligned with `axes`, from `domainsFor`
584
+ * @param {number} R
585
+ * @param {'linear'|'sqrt'} transform
586
+ * @returns {(number|null)[]} one entry per axis, positionally aligned with `axes`
587
+ */
588
+ export function zeroRingFor(axes, domains, R, transform) {
589
+ return axes.map((_, i) => {
590
+ const [min, max] = domains[i]
591
+ const containsZero = min <= 0 && max >= 0
592
+ return containsZero && min !== 0 ? radiusFor(0, domains[i], R, transform) : null
593
+ })
594
+ }
595
+
596
+ /**
597
+ * @typedef {Object} RadarLayout
598
+ * @property {ResolvedAxis[]} axes
599
+ * @property {number[]} angles - from `anglesFor`, aligned with `axes`
600
+ * @property {[number, number][]} domains - from `domainsFor`, aligned with `axes`
601
+ * @property {Map<unknown, (import('./polar.js').Vertex|null)[]>} series - from `verticesFor`
602
+ * @property {Ring[]} rings - from `ringsFor`
603
+ * @property {(number|null)[]} zeroRings - from `zeroRingFor`, aligned with `axes`
604
+ * @property {number} radius - the outer radius, `R`, every other measurement is relative to
605
+ * @property {'linear'|'sqrt'} transform - resolved via `resolveRadiusTransform`
606
+ */
607
+
608
+ /**
609
+ * Composes every piece of this module into the one call a `Radar` geom actually needs — the
610
+ * public entry point, and the reason the rest of the module is allowed to stay a set of small,
611
+ * separately-testable functions instead of one large one.
612
+ *
613
+ * This function makes the ordering dependency between those functions explicit, because it is
614
+ * real, not incidental: `axes` must exist before weights can be read off it for `anglesFor`/
615
+ * `resolveRadiusTransform`; domains must exist before `verticesFor` (which places vertices) or
616
+ * `zeroRingFor` (which needs to know each axis's centre) can run; and the resolved `transform`
617
+ * must be shared between `verticesFor` and `ringsFor`/`zeroRingFor` so a ring drawn at "80% of
618
+ * R" and a vertex plotted at "80% of R" agree on what value that actually represents.
619
+ *
620
+ * `channels` follows the same `{ x, y, color? }` convention as `domainsFor`/`verticesFor` — `x`
621
+ * names the axis field, `y` the value field, `color` the series field. `opts.axes` is the
622
+ * `Radar` component's own `axes` prop (bare strings, full `AxisSpec`s, or a mix — see
623
+ * `resolveAxes`); `opts.R` defaults to `100` so this function is independently callable (by
624
+ * tests, tooling, anything that isn't a geom mid-render) without having to invent a pixel
625
+ * radius — a real `Radar.svelte` is expected to pass its actual computed outer radius instead.
626
+ *
627
+ * @param {Object[]} data
628
+ * @param {{ x: string, y: string, color?: string }} channels
629
+ * @param {{ axes?: (string|AxisSpec)[], sharedDomain?: boolean, rings?: number,
630
+ * radiusScale?: 'linear'|'sqrt'|'auto', R?: number }} [opts]
631
+ * @returns {RadarLayout}
632
+ */
633
+ export function buildRadarLayout(data, channels, opts = {}) {
634
+ const R = opts.R ?? 100
635
+ const axes = resolveAxes(opts.axes, data, channels.x)
636
+ const weights = axes.map((axis) => axis.weight)
637
+ const angles = anglesFor(weights)
638
+ const domains = domainsFor(axes, data, channels, { sharedDomain: opts.sharedDomain })
639
+ const transform = resolveRadiusTransform(weights, opts.radiusScale)
640
+ const series = verticesFor(data, axes, channels, { angles, domains, R, transform })
641
+ const rings = ringsFor(axes, { R, rings: opts.rings, domains, transform })
642
+ const zeroRings = zeroRingFor(axes, domains, R, transform)
643
+
644
+ return { axes, angles, domains, series, rings, zeroRings, radius: R, transform }
645
+ }