@rokkit/chart 1.4.0 → 1.4.2

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.
@@ -94,15 +94,13 @@ export class PlotState {
94
94
  addField(g.channels?.color)
95
95
  addField(g.channels?.fill)
96
96
  }
97
- const seen = new Set()
97
+ // `includes` dedup rather than a Set, matching `addField` above. These are
98
+ // category values feeding one palette/legend — a domain of dozens, not
99
+ // rows — so the linear scan costs nothing and the function stays free of
100
+ // a collection the reactivity linter has to be told to ignore.
98
101
  const values = []
99
102
  for (const f of fields) {
100
- for (const v of distinct(this.#data, f)) {
101
- if (!seen.has(v)) {
102
- seen.add(v)
103
- values.push(v)
104
- }
105
- }
103
+ for (const v of distinct(this.#data, f)) if (!values.includes(v)) values.push(v)
106
104
  }
107
105
  return values
108
106
  }
@@ -126,6 +124,12 @@ export class PlotState {
126
124
  const xf = this.#effectiveChannels.x
127
125
  const yf = this.#effectiveChannels.y
128
126
  if (!xf || !yf) return null
127
+ // Keyed accumulator, function-local and discarded before returning — a
128
+ // SvelteMap would register a reactive signal per category on every
129
+ // derivation for a collection nothing outside this call can observe.
130
+ // Not a plain object either: keys are raw data values (numbers, Dates),
131
+ // which object keys would coerce to strings.
132
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
129
133
  const totals = new Map()
130
134
  for (const ds of datasets) {
131
135
  for (const d of ds) {
@@ -383,7 +387,12 @@ export class PlotState {
383
387
  const crossVal = this.axisOrigin[1]
384
388
  if (crossVal !== undefined) return this.yScale(crossVal)
385
389
  const domain = this.yScale.domain?.()
390
+ /* v8 ignore start -- unreachable: yScale is either null (caught by the guard
391
+ above) or a d3 scale from buildUnifiedYScale, whose every return path is a
392
+ scaleLinear or scaleBand — both always expose domain() returning an array.
393
+ `ignore next` does not fire on a single-line `if (...) return`. */
386
394
  if (!domain) return this.#innerHeight
395
+ /* v8 ignore stop */
387
396
  // Auto quadrant: place x-axis at y=0 when domain spans zero (no offset)
388
397
  if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return this.yScale(0)
389
398
  // Q1-only: axis at bottom edge, optionally with offset
@@ -84,13 +84,11 @@
84
84
  ? []
85
85
  : [highlight]
86
86
  )
87
- const highlightIndices = $derived.by(() => {
88
- const seen = new Set<number>()
89
- for (const sel of highlightSelectors) {
90
- for (const i of resolveHighlight(rows, sel, { y: 'y' })) seen.add(i)
91
- }
92
- return seen
93
- })
87
+ // `resolveHighlight` returns number[], so the union is a flatMap and the Set is
88
+ // built from that iterable — no incremental .add() to make reactive.
89
+ const highlightIndices = $derived(
90
+ new Set<number>(highlightSelectors.flatMap((sel) => resolveHighlight(rows, sel, { y: 'y' })))
91
+ )
94
92
  const highlightPredicate = $derived(
95
93
  highlightIndices.size > 0
96
94
  ? (_row: Record<string, unknown>, i: number) => highlightIndices.has(i)
@@ -66,14 +66,14 @@
66
66
  function selectPoint(d: Row, event: MouseEvent | KeyboardEvent) {
67
67
  if (plotState.interactive)
68
68
  plotState.handleSelect(
69
- buildSelectDetail(
70
- d,
71
- plotState.data.indexOf(d),
72
- { x, y },
73
- 'area',
74
- seriesField ? d[seriesField] : undefined,
69
+ buildSelectDetail({
70
+ datum: d,
71
+ index: plotState.data.indexOf(d),
72
+ channels: { x, y },
73
+ geom: 'area',
74
+ series: seriesField ? d[seriesField] : undefined,
75
75
  event
76
- )
76
+ })
77
77
  )
78
78
  }
79
79
  </script>
@@ -119,14 +119,14 @@
119
119
  function selectBar(bar: { data: Row }, event: MouseEvent | KeyboardEvent) {
120
120
  if (plotState.interactive)
121
121
  plotState.handleSelect(
122
- buildSelectDetail(
123
- bar.data,
124
- plotState.data.indexOf(bar.data),
125
- { x, y },
126
- 'bar',
127
- seriesField ? bar.data[seriesField] : undefined,
122
+ buildSelectDetail({
123
+ datum: bar.data,
124
+ index: plotState.data.indexOf(bar.data),
125
+ channels: { x, y },
126
+ geom: 'bar',
127
+ series: seriesField ? bar.data[seriesField] : undefined,
128
128
  event
129
- )
129
+ })
130
130
  )
131
131
  }
132
132
 
@@ -16,43 +16,69 @@
16
16
 
17
17
  const state = getContext<PlotState>('plot-state')
18
18
 
19
- const marks = $derived.by(() => {
20
- const rows = state?.data ?? []
21
- const xs = state?.xScale
22
- const ys = state?.yScale
23
- if (!xs || !ys || !x || !y) return []
24
- const selectedRows = state?.selectedRows ?? []
25
- const selectedSet = new Set(selectedRows)
26
- const out = []
27
- const seen = new Set()
28
- if (highlight !== null && highlight !== undefined && rows.length) {
29
- for (const i of resolveHighlight(rows, highlight, { y })) {
30
- const row = rows[i]
31
- out.push({
32
- key: `h${i}`,
33
- cx: scalePos(xs, row[x]),
34
- cy: ys(Number(row[y])),
35
- row,
36
- // A statically-highlighted point that is also selected renders as
37
- // selected — selection is the stronger, interactive state.
38
- selected: selectedSet.has(row)
39
- })
40
- seen.add(row)
41
- }
19
+ type Mark = { key: string; cx: number; cy: number; row: Row; selected: boolean }
20
+ /** Everything both mark builders need, resolved once per derivation. */
21
+ type Ctx = {
22
+ rows: Row[]
23
+ selectedRows: Row[]
24
+ // Scales come from PlotState (untyped JS), so they stay loose here.
25
+ xs: (v: unknown) => number
26
+ ys: (v: number) => number
27
+ x: string
28
+ y: string
29
+ }
30
+
31
+ const hasScales = (s: PlotState | undefined) => Boolean(s?.xScale && s?.yScale)
32
+
33
+ /** Null until the plot has both scales and both channels — nothing can be placed before then. */
34
+ const ctx = $derived.by((): Ctx | null => {
35
+ if (!hasScales(state) || !x || !y) return null
36
+ return {
37
+ rows: state.data ?? [],
38
+ selectedRows: state.selectedRows ?? [],
39
+ xs: state.xScale,
40
+ ys: state.yScale,
41
+ x,
42
+ y
42
43
  }
43
- selectedRows.forEach((row, j) => {
44
- if (seen.has(row)) return
45
- out.push({
46
- key: `s${j}`,
47
- cx: scalePos(xs, row[x]),
48
- cy: ys(Number(row[y])),
49
- row,
50
- selected: true
51
- })
52
- })
53
- return out.filter((m) => Number.isFinite(m.cx) && Number.isFinite(m.cy))
54
44
  })
55
45
 
46
+ const place = (c: Ctx, row: Row) => ({
47
+ cx: scalePos(c.xs, row[c.x]),
48
+ cy: c.ys(Number(row[c.y]))
49
+ })
50
+
51
+ /** Marks for the declarative `highlight` selector. */
52
+ function highlightMarks(c: Ctx, selector: Props['highlight']): Mark[] {
53
+ if (selector === null || selector === undefined) return []
54
+ const selected = new Set(c.selectedRows)
55
+ return resolveHighlight(c.rows, selector, { y: c.y }).map((i) => ({
56
+ key: `h${i}`,
57
+ ...place(c, c.rows[i]),
58
+ row: c.rows[i],
59
+ // A statically-highlighted point that is also selected renders as
60
+ // selected — selection is the stronger, interactive state.
61
+ selected: selected.has(c.rows[i])
62
+ }))
63
+ }
64
+
65
+ /** Marks for interactive selection, minus any row a highlight mark already covers. */
66
+ function selectionMarks(c: Ctx, covered: Set<Row>): Mark[] {
67
+ return c.selectedRows
68
+ .map((row, j) => ({ row, j }))
69
+ .filter(({ row }) => !covered.has(row))
70
+ .map(({ row, j }) => ({ key: `s${j}`, ...place(c, row), row, selected: true }))
71
+ }
72
+
73
+ const isPlaced = (m: Mark) => Number.isFinite(m.cx) && Number.isFinite(m.cy)
74
+
75
+ const marks = $derived.by(() => {
76
+ if (!ctx) return []
77
+ const highlighted = highlightMarks(ctx, highlight)
78
+ // Derived from the marks just built, so "already emitted" has one source of truth.
79
+ const selected = selectionMarks(ctx, new Set(highlighted.map((m) => m.row)))
80
+ return [...highlighted, ...selected].filter(isPlaced)
81
+ })
56
82
  </script>
57
83
 
58
84
  {#if marks.length}
@@ -75,7 +75,14 @@
75
75
  onselect?.(pt.data)
76
76
  if (plotState.interactive)
77
77
  plotState.handleSelect(
78
- buildSelectDetail(pt.data, plotState.data.indexOf(pt.data), { x, y }, 'line', seg?.key, event)
78
+ buildSelectDetail({
79
+ datum: pt.data,
80
+ index: plotState.data.indexOf(pt.data),
81
+ channels: { x, y },
82
+ geom: 'line',
83
+ series: seg?.key,
84
+ event
85
+ })
79
86
  )
80
87
  }
81
88
  </script>
@@ -74,14 +74,14 @@
74
74
  onselect?.(pt.data)
75
75
  if (plotState.interactive)
76
76
  plotState.handleSelect(
77
- buildSelectDetail(
78
- pt.data,
79
- plotState.data.indexOf(pt.data),
80
- { x, y },
81
- 'point',
82
- seriesField ? pt.data[seriesField] : undefined,
77
+ buildSelectDetail({
78
+ datum: pt.data,
79
+ index: plotState.data.indexOf(pt.data),
80
+ channels: { x, y },
81
+ geom: 'point',
82
+ series: seriesField ? pt.data[seriesField] : undefined,
83
83
  event
84
- )
84
+ })
85
85
  )
86
86
  }
87
87
  </script>
@@ -183,16 +183,16 @@
183
183
  onselect?.(row)
184
184
  if (plotState.interactive)
185
185
  plotState.handleSelect(
186
- buildSelectDetail(
187
- row,
186
+ buildSelectDetail({
187
+ datum: row,
188
188
  // `row` is one of the container's original row objects — averaging happens
189
189
  // inside polar.js precisely so this lookup resolves instead of returning -1.
190
- plotState.data.indexOf(row),
191
- { x: axis, y: value },
192
- 'radar',
193
- seriesKey,
190
+ index: plotState.data.indexOf(row),
191
+ channels: { x: axis, y: value },
192
+ geom: 'radar',
193
+ series: seriesKey,
194
194
  event
195
- )
195
+ })
196
196
  )
197
197
  }
198
198
 
@@ -20,30 +20,53 @@
20
20
  return t === 'mean' ? 'avg' : t
21
21
  }
22
22
 
23
+ /** Everything a path builder needs, resolved once per derivation. */
24
+ type Ctx = {
25
+ rows: Record<string, unknown>[]
26
+ // Scales come from PlotState (untyped JS), so they stay loose here.
27
+ xs: (v: unknown) => number
28
+ ys: (v: number) => number
29
+ x: string
30
+ y: string
31
+ innerWidth: number
32
+ }
33
+
34
+ const isDrawable = (s: PlotState | undefined) =>
35
+ Boolean(s?.xScale && s?.yScale && s?.data?.length)
36
+
37
+ /** Null until the plot has data, both scales, both channels and at least one method. */
38
+ const ctx = $derived.by((): Ctx | null => {
39
+ if (!isDrawable(state) || !x || !y || !methods.length) return null
40
+ return { rows: state.data, xs: state.xScale, ys: state.yScale, x, y, innerWidth: state.innerWidth }
41
+ })
42
+
43
+ /** A flat rule spanning the plot — what every `constant` trend (avg, median, min…) draws. */
44
+ const constantPath = (c: Ctx, value: number) => {
45
+ const yy = c.ys(value)
46
+ return `M0,${yy} L${c.innerWidth},${yy}`
47
+ }
48
+
49
+ /** A polyline through the trend's per-row values, dropping points that don't place. */
50
+ function seriesPath(c: Ctx, values: number[]): string | null {
51
+ const pts = values
52
+ .map((v, i) => ({ vx: scalePos(c.xs, c.rows[i][c.x]), vy: c.ys(v) }))
53
+ .filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
54
+ return d3line<{ vx: number; vy: number }>()
55
+ .x((p) => p.vx)
56
+ .y((p) => p.vy)(pts)
57
+ }
58
+
59
+ /** One method → one path, or null when the method doesn't resolve for this data. */
60
+ function trendPath(c: Ctx, m: Method, i: number) {
61
+ const res = computeTrend(c.rows, { x: c.x, y: c.y }, m)
62
+ if (!res) return null
63
+ const d = res.kind === 'constant' ? constantPath(c, res.value) : seriesPath(c, res.values)
64
+ return d ? { d, type: typeOf(m), i } : null
65
+ }
66
+
23
67
  const paths = $derived.by(() => {
24
- const rows = state?.data ?? []
25
- const xs = state?.xScale
26
- const ys = state?.yScale
27
- if (!rows.length || !xs || !ys || !x || !y || !methods.length) return []
28
- const out: { d: string; type: string; i: number }[] = []
29
- methods.forEach((m, idx) => {
30
- const res = computeTrend(rows, { x, y }, m)
31
- if (!res) return
32
- if (res.kind === 'constant') {
33
- const yy = ys(res.value)
34
- out.push({ d: `M0,${yy} L${state.innerWidth},${yy}`, type: typeOf(m), i: idx })
35
- } else {
36
- const pts = res.values
37
- .map((v, i) => ({ vx: scalePos(xs, rows[i][x]), vy: ys(v) }))
38
- .filter((p) => Number.isFinite(p.vx) && Number.isFinite(p.vy))
39
- const gen = d3line<{ vx: number; vy: number }>()
40
- .x((p) => p.vx)
41
- .y((p) => p.vy)
42
- const d = gen(pts)
43
- if (d) out.push({ d, type: typeOf(m), i: idx })
44
- }
45
- })
46
- return out
68
+ if (!ctx) return []
69
+ return methods.map((m, i) => trendPath(ctx, m, i)).filter((p) => p !== null)
47
70
  })
48
71
  </script>
49
72
 
@@ -190,23 +190,34 @@ export function resolveRadiusTransform(weights: number[], requested?: "linear" |
190
190
  * polygon" principle `resolveAxes` already applies to axis membership. The geom is expected to
191
191
  * break the polygon's outline at a `null` vertex instead of drawing a line through it.
192
192
  *
193
+ * The geometry arguments travel as one bag rather than six positionals, matching `ringsFor`'s
194
+ * `(axes, { R, rings, domains, transform })` shape. At the call site that is the difference
195
+ * between `verticesFor(data, axes, angles, domains, 100, 'linear', channels)` — where the reader
196
+ * has to count commas to learn what `100` and `'linear'` mean — and naming each one.
197
+ *
193
198
  * @param {Object[]} data
194
199
  * @param {ResolvedAxis[]} axes - from `resolveAxes`; vertex order follows this array
195
- * @param {number[]} angles - from `anglesFor(axes.map(a => a.weight))`, aligned with `axes`
196
- * @param {[number, number][]} domains - from `domainsFor`, aligned with `axes`
197
- * @param {number} R - outer radius in pixels, forwarded to `radiusFor`
198
- * @param {'linear'|'sqrt'} transform - forwarded to `radiusFor`
199
200
  * @param {{ x: string, y: string, color?: string }} channels - `x` names the axis field, `y` the
200
201
  * value field, `color` the series field — mirroring `fill ?? color`'s role as the series
201
202
  * channel in every other geom. Omitting it entirely means one implicit series.
203
+ * @param {Object} geometry
204
+ * @param {number[]} geometry.angles - from `anglesFor(axes.map(a => a.weight))`, aligned with `axes`
205
+ * @param {[number, number][]} geometry.domains - from `domainsFor`, aligned with `axes`
206
+ * @param {number} geometry.R - outer radius in pixels, forwarded to `radiusFor`
207
+ * @param {'linear'|'sqrt'} geometry.transform - forwarded to `radiusFor`
202
208
  * @returns {Map<unknown, (Vertex|null)[]>} one entry per distinct series value (or the single key
203
209
  * `undefined` when `channels.color` is omitted), each holding one vertex — or `null` for a
204
210
  * gap — per axis, in `axes` order
205
211
  */
206
- export function verticesFor(data: Object[], axes: ResolvedAxis[], angles: number[], domains: [number, number][], R: number, transform: "linear" | "sqrt", channels: {
212
+ export function verticesFor(data: Object[], axes: ResolvedAxis[], channels: {
207
213
  x: string;
208
214
  y: string;
209
215
  color?: string;
216
+ }, { angles, domains, R, transform }: {
217
+ angles: number[];
218
+ domains: [number, number][];
219
+ R: number;
220
+ transform: "linear" | "sqrt";
210
221
  }): Map<unknown, (Vertex | null)[]>;
211
222
  /**
212
223
  * Concentric grid rings for a radar chart — one shared set of circles, evenly spaced in
@@ -79,38 +79,50 @@ function normaliseAxis(entry) {
79
79
  */
80
80
  export function resolveAxes(axes, data, axisField) {
81
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)
82
+ warnInferredAxisOrder()
83
+ return axisKeysInData(data, axisField).map(normaliseAxis)
94
84
  }
95
85
 
96
86
  const resolved = axes.map(normaliseAxis)
97
-
98
87
  const declaredKeys = new Set(resolved.map((a) => a.key))
99
- const dataKeys = new Set()
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()
100
102
  for (const row of data) {
101
103
  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
- )
104
+ if (key !== undefined) seen.add(key)
111
105
  }
106
+ return [...seen]
107
+ }
112
108
 
113
- return resolved
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
+ )
114
126
  }
115
127
 
116
128
  /**
@@ -180,27 +192,42 @@ export function anglesFor(weights) {
180
192
  * @returns {[number, number][]} one domain per axis, positionally aligned with `axes`
181
193
  */
182
194
  export function domainsFor(axes, data, channels, opts = {}) {
183
- const { x: axisField, y: valueField } = channels
184
- const sharedDomain = opts.sharedDomain ?? false
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
+ }
185
199
 
186
- const valuesByKey = new Map(axes.map((axis) => [axis.key, []]))
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, []]))
187
210
  for (const row of data) {
188
- const values = valuesByKey.get(row?.[axisField])
211
+ const values = byKey.get(row?.[axisField])
189
212
  if (!values) continue
190
213
  const value = row?.[valueField]
191
214
  if (Number.isFinite(value)) values.push(value)
192
215
  }
216
+ return byKey
217
+ }
193
218
 
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)))
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]
204
231
  }
205
232
 
206
233
  /**
@@ -335,52 +362,36 @@ export function resolveRadiusTransform(weights, requested = 'auto') {
335
362
  * polygon" principle `resolveAxes` already applies to axis membership. The geom is expected to
336
363
  * break the polygon's outline at a `null` vertex instead of drawing a line through it.
337
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
+ *
338
370
  * @param {Object[]} data
339
371
  * @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
372
  * @param {{ x: string, y: string, color?: string }} channels - `x` names the axis field, `y` the
345
373
  * value field, `color` the series field — mirroring `fill ?? color`'s role as the series
346
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`
347
380
  * @returns {Map<unknown, (Vertex|null)[]>} one entry per distinct series value (or the single key
348
381
  * `undefined` when `channels.color` is omitted), each holding one vertex — or `null` for a
349
382
  * gap — per axis, in `axes` order
350
383
  */
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
- }
384
+ export function verticesFor(data, axes, channels, { angles, domains, R, transform }) {
385
+ const { y: valueField } = channels
386
+ const cells = cellsBySeriesAndAxis(data, channels)
363
387
 
364
388
  const result = new Map()
365
389
  for (const [seriesKey, bySeries] of cells) {
366
390
  const vertices = axes.map((axis, i) => {
367
391
  const rows = bySeries.get(axis.key)
368
392
  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
393
+ const value = cellValue(rows, { axisKey: axis.key, seriesKey, valueField })
394
+ if (value === null) return null
384
395
  return {
385
396
  axisKey: axis.key,
386
397
  value,
@@ -395,6 +406,56 @@ export function verticesFor(data, axes, angles, domains, R, transform, channels)
395
406
  return result
396
407
  }
397
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
+
398
459
  /**
399
460
  * @typedef {Object} Ring
400
461
  * @property {number} radius - pixels from centre
@@ -576,7 +637,7 @@ export function buildRadarLayout(data, channels, opts = {}) {
576
637
  const angles = anglesFor(weights)
577
638
  const domains = domainsFor(axes, data, channels, { sharedDomain: opts.sharedDomain })
578
639
  const transform = resolveRadiusTransform(weights, opts.radiusScale)
579
- const series = verticesFor(data, axes, angles, domains, R, transform, channels)
640
+ const series = verticesFor(data, axes, channels, { angles, domains, R, transform })
580
641
  const rings = ringsFor(axes, { R, rings: opts.rings, domains, transform })
581
642
  const zeroRings = zeroRingFor(axes, domains, R, transform)
582
643
 
@@ -1,43 +1,94 @@
1
1
  /**
2
- * Resolve a highlight selector to zero or more row indices.
2
+ * Highlight-selector resolution.
3
+ *
4
+ * One small resolver per selector kind, plus a dispatcher. The kinds share
5
+ * nothing but their signature, so keeping them separate means each can be read
6
+ * and tested on its own instead of as one branch of a 24-complexity chain.
7
+ */
8
+
9
+ /**
10
+ * Indices where a predicate selector matches.
3
11
  * @param {Array<Record<string, unknown>>} rows
4
- * @param {'first'|'last'|'min'|'max'|number|((row:any,i:number)=>boolean)} selector
5
- * @param {{ y?: string }} [opts]
12
+ * @param {(row: any, i: number) => boolean} predicate
6
13
  * @returns {number[]}
7
14
  */
8
- export function resolveHighlight(rows, selector, opts = {}) {
9
- if (!Array.isArray(rows) || rows.length === 0 || selector === null || selector === undefined)
10
- return []
11
- const { y } = opts
15
+ function byPredicate(rows, predicate) {
16
+ const out = []
17
+ for (let i = 0; i < rows.length; i++) if (predicate(rows[i], i)) out.push(i)
18
+ return out
19
+ }
12
20
 
13
- if (typeof selector === 'function') {
14
- const out = []
15
- for (let i = 0; i < rows.length; i++) if (selector(rows[i], i)) out.push(i)
16
- return out
17
- }
21
+ /**
22
+ * A single index. Negative values count back from the end (`-1` is the last
23
+ * row); anything out of range resolves to nothing.
24
+ * @param {Array<Record<string, unknown>>} rows
25
+ * @param {number} index
26
+ * @returns {number[]}
27
+ */
28
+ function byIndex(rows, index) {
29
+ const i = index < 0 ? rows.length + index : index
30
+ return i >= 0 && i < rows.length ? [i] : []
31
+ }
18
32
 
19
- if (typeof selector === 'number') {
20
- const i = selector < 0 ? rows.length + selector : selector
21
- return i >= 0 && i < rows.length ? [i] : []
22
- }
33
+ /**
34
+ * The index of the row whose numeric `y` is smallest (`min`) or largest (`max`).
35
+ *
36
+ * Non-numeric values are dropped rather than compared — `NaN` loses every
37
+ * comparison, so leaving them in would make the result depend on iteration
38
+ * order. Ties keep the earliest row. Candidates are collected before reducing
39
+ * so an all-`Infinity` column still resolves to its first row, which a
40
+ * strict-improvement scan seeded with `Infinity` would reject.
41
+ *
42
+ * @param {Array<Record<string, unknown>>} rows
43
+ * @param {string | undefined} y
44
+ * @param {'min'|'max'} kind
45
+ * @returns {number[]}
46
+ */
47
+ function byExtreme(rows, y, kind) {
48
+ if (!y) return []
49
+ // Searching for the smallest `sign * value` finds the max when sign is -1,
50
+ // which collapses two mirror-image scans into one.
51
+ const sign = kind === 'min' ? 1 : -1
52
+ const candidates = rows
53
+ .map((row, i) => ({ i, value: sign * Number(row[y]) }))
54
+ .filter(({ value }) => !Number.isNaN(value))
55
+ if (candidates.length === 0) return []
56
+ return [candidates.reduce((best, c) => (c.value < best.value ? c : best)).i]
57
+ }
23
58
 
24
- if (selector === 'first') return [0]
25
- if (selector === 'last') return [rows.length - 1]
59
+ /**
60
+ * Named selectors, all sharing one signature so the dispatcher can call any of
61
+ * them uniformly. `rows` is guaranteed non-empty by the time these run.
62
+ * @type {Record<string, (rows: Array<Record<string, unknown>>, y?: string) => number[]>}
63
+ */
64
+ const NAMED = {
65
+ first: () => [0],
66
+ last: (rows) => [rows.length - 1],
67
+ min: (rows, y) => byExtreme(rows, y, 'min'),
68
+ max: (rows, y) => byExtreme(rows, y, 'max')
69
+ }
26
70
 
27
- if (selector === 'min' || selector === 'max') {
28
- if (!y) return []
29
- let best = -1
30
- let bestVal = null
31
- for (let i = 0; i < rows.length; i++) {
32
- const v = Number(rows[i][y])
33
- if (Number.isNaN(v)) continue
34
- if (bestVal === null || (selector === 'min' ? v < bestVal : v > bestVal)) {
35
- bestVal = v
36
- best = i
37
- }
38
- }
39
- return best === -1 ? [] : [best]
40
- }
71
+ /**
72
+ * True when the inputs cannot resolve to any row, whatever the selector means.
73
+ * @param {unknown} rows
74
+ * @param {unknown} selector
75
+ */
76
+ const isUnresolvable = (rows, selector) =>
77
+ !Array.isArray(rows) || rows.length === 0 || selector === null || selector === undefined
41
78
 
42
- return []
79
+ /**
80
+ * Resolve a highlight selector to zero or more row indices.
81
+ * @param {Array<Record<string, unknown>>} rows
82
+ * @param {'first'|'last'|'min'|'max'|number|((row:any,i:number)=>boolean)} selector
83
+ * @param {{ y?: string }} [opts]
84
+ * @returns {number[]}
85
+ */
86
+ export function resolveHighlight(rows, selector, opts = {}) {
87
+ if (isUnresolvable(rows, selector)) return []
88
+ if (typeof selector === 'function') return byPredicate(rows, selector)
89
+ if (typeof selector === 'number') return byIndex(rows, selector)
90
+ // hasOwn, not a bare lookup: `NAMED['constructor']` would otherwise resolve to
91
+ // Object's constructor and get called. Same guard as PATTERNS in SparkState.
92
+ const named = Object.hasOwn(NAMED, selector) ? NAMED[selector] : null
93
+ return named ? named(rows, opts.y) : []
43
94
  }
@@ -1,16 +1,30 @@
1
1
  /**
2
2
  * Assemble the SelectDetail handed to a chart's onselect callback.
3
- * @param {Record<string, unknown>} datum
4
- * @param {number} index index within the geom's rendered data
5
- * @param {{ x?: string, y?: string }} channels
6
- * @param {'line'|'point'|'area'|'bar'|'radar'} geom
7
- * @param {unknown} series color/group value, or undefined
8
- * @param {MouseEvent|KeyboardEvent} event
3
+ *
4
+ * Takes one named bag rather than six positionals: the tail of the old
5
+ * signature read `…, { x, y }, 'line', seg?.key, event)` at every call site,
6
+ * where nothing but the argument order told you which of the trailing values
7
+ * was the geom and which was the series.
8
+ *
9
+ * @param {Object} args
10
+ * @param {Record<string, unknown>} args.datum
11
+ * @param {number} args.index index within the geom's rendered data
12
+ * @param {{ x?: string, y?: string }} args.channels
13
+ * @param {'line'|'point'|'area'|'bar'|'radar'} args.geom
14
+ * @param {unknown} [args.series] color/group value, or undefined
15
+ * @param {MouseEvent|KeyboardEvent} args.event
9
16
  */
10
- export function buildSelectDetail(datum: Record<string, unknown>, index: number, channels: {
11
- x?: string;
12
- y?: string;
13
- }, geom: "line" | "point" | "area" | "bar" | "radar", series: unknown, event: MouseEvent | KeyboardEvent): {
17
+ export function buildSelectDetail({ datum, index, channels, geom, series, event }: {
18
+ datum: Record<string, unknown>;
19
+ index: number;
20
+ channels: {
21
+ x?: string;
22
+ y?: string;
23
+ };
24
+ geom: "line" | "point" | "area" | "bar" | "radar";
25
+ series?: unknown;
26
+ event: MouseEvent | KeyboardEvent;
27
+ }): {
14
28
  datum: Record<string, unknown>;
15
29
  index: number;
16
30
  series: {} | undefined;
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Assemble the SelectDetail handed to a chart's onselect callback.
3
- * @param {Record<string, unknown>} datum
4
- * @param {number} index index within the geom's rendered data
5
- * @param {{ x?: string, y?: string }} channels
6
- * @param {'line'|'point'|'area'|'bar'|'radar'} geom
7
- * @param {unknown} series color/group value, or undefined
8
- * @param {MouseEvent|KeyboardEvent} event
3
+ *
4
+ * Takes one named bag rather than six positionals: the tail of the old
5
+ * signature read `…, { x, y }, 'line', seg?.key, event)` at every call site,
6
+ * where nothing but the argument order told you which of the trailing values
7
+ * was the geom and which was the series.
8
+ *
9
+ * @param {Object} args
10
+ * @param {Record<string, unknown>} args.datum
11
+ * @param {number} args.index index within the geom's rendered data
12
+ * @param {{ x?: string, y?: string }} args.channels
13
+ * @param {'line'|'point'|'area'|'bar'|'radar'} args.geom
14
+ * @param {unknown} [args.series] color/group value, or undefined
15
+ * @param {MouseEvent|KeyboardEvent} args.event
9
16
  */
10
- export function buildSelectDetail(datum, index, channels, geom, series, event) {
17
+ export function buildSelectDetail({ datum, index, channels, geom, series, event }) {
11
18
  const { x, y } = channels
12
19
  return {
13
20
  datum,
package/dist/lib/trend.js CHANGED
@@ -58,14 +58,63 @@ export function expRegression(xs, ys) {
58
58
  return { a: Math.exp(reg.b), b: reg.m }
59
59
  }
60
60
 
61
+ /** `mean` is an accepted alias for `avg`. */
62
+ const canonicalType = (type) => (type === 'mean' ? 'avg' : type)
63
+
64
+ /** Accept a number, a bare type name, or a full config object; produce a config or null. */
61
65
  function normalize(method) {
62
66
  if (typeof method === 'number') return { type: 'value', value: method }
63
- if (typeof method === 'string') return { type: method === 'mean' ? 'avg' : method }
67
+ if (typeof method === 'string') return { type: canonicalType(method) }
64
68
  if (method && typeof method === 'object' && method.type)
65
- return { ...method, type: method.type === 'mean' ? 'avg' : method.type }
69
+ return { ...method, type: canonicalType(method.type) }
66
70
  return null
67
71
  }
68
72
 
73
+ /**
74
+ * Numeric series for the two channels. A non-finite x falls back to the row
75
+ * index, which is what makes trends work on a categorical/band axis.
76
+ * @param {Array<Record<string, unknown>>} rows
77
+ * @param {{x?: string, y?: string}} channels
78
+ */
79
+ function toSeries(rows, { x, y }) {
80
+ return {
81
+ ys: rows.map((r) => Number(r[y])),
82
+ xs: rows.map((r, i) => {
83
+ const v = Number(r[x])
84
+ return Number.isFinite(v) ? v : i
85
+ })
86
+ }
87
+ }
88
+
89
+ /**
90
+ * One builder per trend type, replacing a nine-case switch. Each takes the
91
+ * prepared series plus the normalized config and returns a trend or null, so a
92
+ * new trend type is a new entry rather than another branch.
93
+ * @type {Record<string, (ctx: {xs: number[], ys: number[], cfg: Record<string, any>}) => object | null>}
94
+ */
95
+ const BUILDERS = {
96
+ avg: ({ ys }) => ({ kind: 'constant', value: mean(ys) }),
97
+ median: ({ ys }) => ({ kind: 'constant', value: median(ys) }),
98
+ min: ({ ys }) => ({ kind: 'constant', value: Math.min(...ys) }),
99
+ max: ({ ys }) => ({ kind: 'constant', value: Math.max(...ys) }),
100
+ value: ({ cfg }) =>
101
+ typeof cfg.value === 'number' ? { kind: 'constant', value: cfg.value } : null,
102
+ linear: ({ xs, ys }) => {
103
+ const reg = linearRegression(xs, ys)
104
+ return reg ? { kind: 'series', values: xs.map((v) => reg.m * v + reg.b) } : null
105
+ },
106
+ ma: ({ ys, cfg }) =>
107
+ cfg.window ? { kind: 'series', values: movingAverage(ys, cfg.window) } : null,
108
+ ema: ({ ys, cfg }) => ({
109
+ kind: 'series',
110
+ values: ema(ys, { span: cfg.span, alpha: cfg.alpha })
111
+ }),
112
+ exp: ({ xs, ys }) => {
113
+ const reg = expRegression(xs, ys)
114
+ return reg ? { kind: 'series', values: xs.map((v) => reg.a * Math.exp(reg.b * v)) } : null
115
+ }
116
+ }
117
+
69
118
  /**
70
119
  * Compute a trend for `rows` on channels {x, y}.
71
120
  * @returns {{kind:'constant', value:number} | {kind:'series', values:number[]} | null}
@@ -73,41 +122,8 @@ function normalize(method) {
73
122
  export function computeTrend(rows, channels, method) {
74
123
  const cfg = normalize(method)
75
124
  if (!cfg || !Array.isArray(rows) || rows.length === 0) return null
76
- const { x, y } = channels
77
- const ys = rows.map((r) => Number(r[y]))
78
- const xs = rows.map((r, i) => {
79
- const v = Number(r[x])
80
- return Number.isFinite(v) ? v : i
81
- })
82
-
83
- switch (cfg.type) {
84
- case 'avg':
85
- return { kind: 'constant', value: mean(ys) }
86
- case 'median':
87
- return { kind: 'constant', value: median(ys) }
88
- case 'min':
89
- return { kind: 'constant', value: Math.min(...ys) }
90
- case 'max':
91
- return { kind: 'constant', value: Math.max(...ys) }
92
- case 'value':
93
- return typeof cfg.value === 'number' ? { kind: 'constant', value: cfg.value } : null
94
- case 'linear': {
95
- const reg = linearRegression(xs, ys)
96
- if (!reg) return null
97
- return { kind: 'series', values: xs.map((v) => reg.m * v + reg.b) }
98
- }
99
- case 'ma': {
100
- if (!cfg.window) return null
101
- return { kind: 'series', values: movingAverage(ys, cfg.window) }
102
- }
103
- case 'ema':
104
- return { kind: 'series', values: ema(ys, { span: cfg.span, alpha: cfg.alpha }) }
105
- case 'exp': {
106
- const reg = expRegression(xs, ys)
107
- if (!reg) return null
108
- return { kind: 'series', values: xs.map((v) => reg.a * Math.exp(reg.b * v)) }
109
- }
110
- default:
111
- return null
112
- }
125
+ // hasOwn, not a bare lookup: an unknown type must fall through to null, and a
126
+ // bare `BUILDERS[cfg.type]` would resolve inherited keys like 'constructor'.
127
+ if (!Object.hasOwn(BUILDERS, cfg.type)) return null
128
+ return BUILDERS[cfg.type]({ ...toSeries(rows, channels), cfg })
113
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "postpublish": "node ../../config/pack-repoint.mjs --restore && rm -f LICENSE",
21
21
  "clean": "rm -rf dist",
22
22
  "build": "svelte-package -i src -o dist",
23
- "check": "svelte-check --tsconfig ./tsconfig.json"
23
+ "check": "svelte-check --tsconfig ./tsconfig.json",
24
+ "check:types": "tsc --noEmit"
24
25
  },
25
26
  "files": [
26
27
  "dist",
@@ -72,6 +73,6 @@
72
73
  "@types/d3-transition": "^3.0.9",
73
74
  "@types/d3-zoom": "^3.0.8",
74
75
  "@types/ramda": "^0.31.1",
75
- "svelte-check": "^4.4.3"
76
+ "svelte-check": "^4.7.6"
76
77
  }
77
78
  }