@rokkit/chart 1.4.0 → 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.
- package/dist/PlotState.svelte.js +11 -7
- package/dist/Sparkline.svelte +5 -7
- package/dist/geoms/Area.svelte +7 -7
- package/dist/geoms/Bar.svelte +7 -7
- package/dist/geoms/Highlight.svelte +60 -34
- package/dist/geoms/Line.svelte +8 -1
- package/dist/geoms/Point.svelte +7 -7
- package/dist/geoms/Radar.svelte +7 -7
- package/dist/geoms/Trend.svelte +46 -23
- package/dist/lib/brewing/polar.d.ts +16 -5
- package/dist/lib/brewing/polar.js +131 -70
- package/dist/lib/highlight.js +84 -33
- package/dist/lib/select.d.ts +24 -10
- package/dist/lib/select.js +14 -7
- package/dist/lib/trend.js +55 -39
- package/package.json +1 -1
package/dist/PlotState.svelte.js
CHANGED
|
@@ -94,15 +94,13 @@ export class PlotState {
|
|
|
94
94
|
addField(g.channels?.color)
|
|
95
95
|
addField(g.channels?.fill)
|
|
96
96
|
}
|
|
97
|
-
|
|
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) {
|
package/dist/Sparkline.svelte
CHANGED
|
@@ -84,13 +84,11 @@
|
|
|
84
84
|
? []
|
|
85
85
|
: [highlight]
|
|
86
86
|
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
package/dist/geoms/Area.svelte
CHANGED
|
@@ -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>
|
package/dist/geoms/Bar.svelte
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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}
|
package/dist/geoms/Line.svelte
CHANGED
|
@@ -75,7 +75,14 @@
|
|
|
75
75
|
onselect?.(pt.data)
|
|
76
76
|
if (plotState.interactive)
|
|
77
77
|
plotState.handleSelect(
|
|
78
|
-
buildSelectDetail(
|
|
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>
|
package/dist/geoms/Point.svelte
CHANGED
|
@@ -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>
|
package/dist/geoms/Radar.svelte
CHANGED
|
@@ -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
|
|
package/dist/geoms/Trend.svelte
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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[],
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
184
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
352
|
-
const {
|
|
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 (
|
|
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
|
|
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
|
|
package/dist/lib/highlight.js
CHANGED
|
@@ -1,43 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 {
|
|
5
|
-
* @param {{ y?: string }} [opts]
|
|
12
|
+
* @param {(row: any, i: number) => boolean} predicate
|
|
6
13
|
* @returns {number[]}
|
|
7
14
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/select.d.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Assemble the SelectDetail handed to a chart's onselect callback.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
package/dist/lib/select.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Assemble the SelectDetail handed to a chart's onselect callback.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
67
|
+
if (typeof method === 'string') return { type: canonicalType(method) }
|
|
64
68
|
if (method && typeof method === 'object' && method.type)
|
|
65
|
-
return { ...method, type: 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|