@rokkit/chart 1.3.15 → 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.
- package/dist/Plot.svelte +25 -9
- package/dist/PlotState.svelte.d.ts +1 -0
- package/dist/PlotState.svelte.js +8 -1
- package/dist/Spark.svelte +165 -0
- package/dist/Spark.svelte.d.ts +31 -0
- package/dist/SparkState.svelte.d.ts +138 -0
- package/dist/SparkState.svelte.js +331 -0
- package/dist/Sparkline.svelte +59 -232
- package/dist/Sparkline.svelte.d.ts +2 -0
- package/dist/charts/RadarChart.svelte +106 -0
- package/dist/charts/RadarChart.svelte.d.ts +43 -0
- package/dist/geoms/Area.svelte +17 -2
- package/dist/geoms/Area.svelte.d.ts +2 -0
- package/dist/geoms/Bar.svelte +2 -9
- package/dist/geoms/Highlight.svelte +2 -7
- package/dist/geoms/Line.svelte +2 -8
- package/dist/geoms/Point.svelte +2 -8
- package/dist/geoms/Radar.svelte +352 -0
- package/dist/geoms/Radar.svelte.d.ts +42 -0
- package/dist/geoms/lib/aesthetics.d.ts +14 -0
- package/dist/geoms/lib/aesthetics.js +21 -0
- package/dist/geoms/lib/areas.d.ts +9 -2
- package/dist/geoms/lib/areas.js +53 -8
- package/dist/geoms/lib/marks/area.d.ts +3 -0
- package/dist/geoms/lib/marks/area.js +11 -2
- package/dist/geoms/lib/marks/radar.d.ts +68 -0
- package/dist/geoms/lib/marks/radar.js +153 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -1
- package/dist/lib/brewing/marks/boxes.js +19 -30
- package/dist/lib/brewing/polar.d.ts +433 -0
- package/dist/lib/brewing/polar.js +584 -0
- package/dist/lib/plot/scales.js +8 -5
- package/dist/lib/preset.d.ts +1 -0
- package/dist/lib/preset.js +2 -1
- package/dist/lib/select.d.ts +3 -3
- package/dist/lib/select.js +1 -1
- package/package.json +1 -1
- package/dist/examples/BarChartExample.svelte +0 -78
- package/dist/examples/BarChartExample.svelte.d.ts +0 -18
- package/dist/lib/brewing/types.d.ts +0 -162
- package/dist/lib/brewing/types.js +0 -73
- package/dist/lib/xscale.d.ts +0 -11
- 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
|
+
}
|
package/dist/lib/plot/scales.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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(
|
package/dist/lib/preset.d.ts
CHANGED
package/dist/lib/preset.js
CHANGED
|
@@ -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',
|
package/dist/lib/select.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/lib/select.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
*/
|