@rokkit/chart 1.6.0 → 1.8.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/README.md +13 -0
- package/dist/Plot.svelte +38 -10
- package/dist/Plot.svelte.d.ts +7 -2
- package/dist/PlotState.svelte.d.ts +115 -71
- package/dist/PlotState.svelte.js +174 -547
- package/dist/SparkState.svelte.d.ts +3 -1
- package/dist/SparkState.svelte.js +11 -41
- package/dist/geoms/Contour.svelte +88 -0
- package/dist/geoms/Contour.svelte.d.ts +19 -0
- package/dist/geoms/Hull.svelte +89 -0
- package/dist/geoms/Hull.svelte.d.ts +17 -0
- package/dist/geoms/Region.svelte +107 -0
- package/dist/geoms/Region.svelte.d.ts +27 -0
- package/dist/geoms/Rule.svelte +88 -26
- package/dist/geoms/Rule.svelte.d.ts +8 -0
- package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
- package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
- package/dist/geoms/lib/bars.js +13 -20
- package/dist/geoms/lib/grouping.d.ts +35 -0
- package/dist/geoms/lib/grouping.js +59 -0
- package/dist/geoms/lib/marks/contour.d.ts +26 -0
- package/dist/geoms/lib/marks/contour.js +67 -0
- package/dist/geoms/lib/marks/hull.d.ts +26 -0
- package/dist/geoms/lib/marks/hull.js +36 -0
- package/dist/geoms/lib/marks/waterfall.js +15 -20
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lib/abline.d.ts +41 -0
- package/dist/lib/abline.js +51 -0
- package/dist/lib/brewing/colors.js +1 -1
- package/dist/lib/hull.d.ts +18 -0
- package/dist/lib/hull.js +64 -0
- package/dist/lib/plot/domains.d.ts +61 -0
- package/dist/lib/plot/domains.js +130 -0
- package/dist/lib/plot/preset.d.ts +1 -1
- package/dist/lib/plot/preset.js +1 -1
- package/dist/lib/plot/running.d.ts +23 -0
- package/dist/lib/plot/running.js +32 -0
- package/dist/lib/plot/stacking.d.ts +32 -0
- package/dist/lib/plot/stacking.js +34 -0
- package/dist/lib/plot/types.d.ts +8 -1
- package/dist/lib/plot/types.js +5 -1
- package/dist/lib/preset.d.ts +2 -0
- package/dist/lib/preset.js +76 -41
- package/dist/lib/region.d.ts +63 -0
- package/dist/lib/region.js +127 -0
- package/dist/lib/swatch.d.ts +1 -1
- package/dist/lib/swatch.js +1 -1
- package/dist/state/AestheticState.svelte.d.ts +43 -0
- package/dist/state/AestheticState.svelte.js +84 -0
- package/dist/state/AxisState.svelte.d.ts +26 -0
- package/dist/state/AxisState.svelte.js +56 -0
- package/dist/state/ChannelState.svelte.d.ts +34 -0
- package/dist/state/ChannelState.svelte.js +67 -0
- package/dist/state/GeomRegistry.svelte.d.ts +44 -0
- package/dist/state/GeomRegistry.svelte.js +86 -0
- package/dist/state/InteractionState.svelte.d.ts +30 -0
- package/dist/state/InteractionState.svelte.js +69 -0
- package/dist/state/OrientationState.svelte.d.ts +34 -0
- package/dist/state/OrientationState.svelte.js +64 -0
- package/dist/state/PlotConfig.svelte.d.ts +179 -0
- package/dist/state/PlotConfig.svelte.js +195 -0
- package/dist/state/PlotFrame.svelte.d.ts +17 -0
- package/dist/state/PlotFrame.svelte.js +18 -0
- package/dist/state/ScaleState.svelte.d.ts +36 -0
- package/dist/state/ScaleState.svelte.js +121 -0
- package/package.json +5 -3
- package/dist/lib/palette.json +0 -277
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Region geometry — data-coordinate shapes resolved to plot (u, v) coordinates.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the component so the maths is testable without a DOM, and so every geom that
|
|
5
|
+
* needs "a shape in data space" (Region today, anything shaded tomorrow) resolves it the same
|
|
6
|
+
* way: continuous values map straight through the scale, band categories cover the WHOLE
|
|
7
|
+
* band, and an omitted range spans the axis.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @typedef {{ (v: unknown): number | undefined, bandwidth?: () => number, range: () => number[], domain: () => unknown[] }} AnyScale */
|
|
11
|
+
|
|
12
|
+
/** @param {AnyScale} scale */
|
|
13
|
+
const isBand = (scale) => typeof scale.bandwidth === 'function'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The data value an open (null/undefined) range end stands for: the low or high end of the
|
|
17
|
+
* axis's domain — the first or last category on a band axis.
|
|
18
|
+
*
|
|
19
|
+
* @param {AnyScale} scale
|
|
20
|
+
* @param {unknown} v
|
|
21
|
+
* @param {'lo' | 'hi'} side
|
|
22
|
+
*/
|
|
23
|
+
function endValue(scale, v, side) {
|
|
24
|
+
if (v !== null && v !== undefined) return v
|
|
25
|
+
const domain = scale.domain()
|
|
26
|
+
if (isBand(scale)) return side === 'lo' ? domain[0] : domain[domain.length - 1]
|
|
27
|
+
const nums = domain.map(Number)
|
|
28
|
+
return side === 'lo' ? Math.min(...nums) : Math.max(...nums)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Screen span of a [lo, hi] range on one axis. A band category contributes its whole band,
|
|
33
|
+
* so `x={['a', 'b']}` shades both columns edge to edge. Omitted → the full range; a `null`
|
|
34
|
+
* end runs to that edge of the axis, so `[p75, null]` is "the top quartile and beyond".
|
|
35
|
+
*
|
|
36
|
+
* @param {AnyScale} scale
|
|
37
|
+
* @param {unknown[] | undefined} range
|
|
38
|
+
* @returns {[number, number]}
|
|
39
|
+
*/
|
|
40
|
+
export function spanOf(scale, range) {
|
|
41
|
+
if (!range || range.length < 2) {
|
|
42
|
+
const r = scale.range()
|
|
43
|
+
return [Math.min(...r), Math.max(...r)]
|
|
44
|
+
}
|
|
45
|
+
const band = isBand(scale) ? /** @type {() => number} */ (scale.bandwidth)() : 0
|
|
46
|
+
const ends = [endValue(scale, range[0], 'lo'), endValue(scale, range[1], 'hi')].map((v) =>
|
|
47
|
+
Number(scale(v))
|
|
48
|
+
)
|
|
49
|
+
return [Math.min(...ends), Math.max(...ends) + band]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Position of a single data value — band categories resolve to the band centre, which is
|
|
54
|
+
* where a point in that category is drawn.
|
|
55
|
+
*
|
|
56
|
+
* @param {AnyScale} scale
|
|
57
|
+
* @param {unknown} v
|
|
58
|
+
*/
|
|
59
|
+
export function positionOf(scale, v) {
|
|
60
|
+
const base = Number(scale(v))
|
|
61
|
+
return isBand(scale) ? base + /** @type {() => number} */ (scale.bandwidth)() / 2 : base
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The region's vertices in (u, v) plot coordinates — before orientation `place`.
|
|
66
|
+
*
|
|
67
|
+
* `points` (a polygon, three or more vertices) wins over `x`/`y` ranges. Fewer than three
|
|
68
|
+
* points is not an area, so it resolves to nothing rather than a degenerate sliver.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ x?: unknown[], y?: unknown[], points?: unknown[][] }} shape
|
|
71
|
+
* @param {AnyScale} xScale
|
|
72
|
+
* @param {AnyScale} yScale
|
|
73
|
+
* @returns {Array<[number, number]>}
|
|
74
|
+
*/
|
|
75
|
+
export function regionVertices(shape, xScale, yScale) {
|
|
76
|
+
if (shape.points) {
|
|
77
|
+
if (shape.points.length < 3) return []
|
|
78
|
+
return shape.points.map(([x, y]) => [positionOf(xScale, x), positionOf(yScale, y)])
|
|
79
|
+
}
|
|
80
|
+
if (!shape.x && !shape.y) return []
|
|
81
|
+
const [x0, x1] = spanOf(xScale, shape.x)
|
|
82
|
+
const [y0, y1] = spanOf(yScale, shape.y)
|
|
83
|
+
// Wound so the first vertex is the (lo-x, lo-y) corner in DATA terms: on a vertical chart
|
|
84
|
+
// the y range is inverted, so y1 (the larger pixel) is the lower value.
|
|
85
|
+
return [
|
|
86
|
+
[x0, y1],
|
|
87
|
+
[x1, y1],
|
|
88
|
+
[x1, y0],
|
|
89
|
+
[x0, y0]
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Area centroid of a simple polygon — where its label goes. Falls back to the vertex mean for
|
|
95
|
+
* a zero-area (collinear) polygon, which has no area centroid.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array<{ x: number, y: number }>} pts
|
|
98
|
+
* @returns {{ x: number, y: number }}
|
|
99
|
+
*/
|
|
100
|
+
export function centroid(pts) {
|
|
101
|
+
let a = 0
|
|
102
|
+
let cx = 0
|
|
103
|
+
let cy = 0
|
|
104
|
+
for (let i = 0; i < pts.length; i++) {
|
|
105
|
+
const p = pts[i]
|
|
106
|
+
const q = pts[(i + 1) % pts.length]
|
|
107
|
+
const cross = p.x * q.y - q.x * p.y
|
|
108
|
+
a += cross
|
|
109
|
+
cx += (p.x + q.x) * cross
|
|
110
|
+
cy += (p.y + q.y) * cross
|
|
111
|
+
}
|
|
112
|
+
if (Math.abs(a) < 1e-9) {
|
|
113
|
+
const n = pts.length || 1
|
|
114
|
+
return { x: pts.reduce((s, p) => s + p.x, 0) / n, y: pts.reduce((s, p) => s + p.y, 0) / n }
|
|
115
|
+
}
|
|
116
|
+
return { x: cx / (3 * a), y: cy / (3 * a) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* SVG path data for a closed polygon.
|
|
121
|
+
*
|
|
122
|
+
* @param {Array<{ x: number, y: number }>} pts
|
|
123
|
+
*/
|
|
124
|
+
export function polygonPath(pts) {
|
|
125
|
+
const fmt = (/** @type {number} */ n) => Math.round(n * 1000) / 1000
|
|
126
|
+
return `M${pts.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(' L')} Z`
|
|
127
|
+
}
|
package/dist/lib/swatch.d.ts
CHANGED
|
@@ -7,6 +7,6 @@ export namespace swatch {
|
|
|
7
7
|
export { PATTERN_ORDER as pattern };
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
-
import palette from '
|
|
10
|
+
import { categoricalPalette as palette } from '@rokkit/core';
|
|
11
11
|
import { shapes } from '../symbols';
|
|
12
12
|
import { PATTERN_ORDER } from './brewing/patterns.js';
|
package/dist/lib/swatch.js
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each category looks like: its palette entry, pattern and symbol — and, for a numeric
|
|
3
|
+
* colour field, the continuous (sequential or diverging) colour scale.
|
|
4
|
+
*/
|
|
5
|
+
export class AestheticState {
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
8
|
+
* @param {import('./ChannelState.svelte.js').ChannelState} channels
|
|
9
|
+
*/
|
|
10
|
+
constructor(config: import("./PlotConfig.svelte.js").PlotConfig, channels: import("./ChannelState.svelte.js").ChannelState);
|
|
11
|
+
/** 'categorical' | 'sequential' | 'diverging' — inferred from the colour field and the spec. */
|
|
12
|
+
colorScaleType: any;
|
|
13
|
+
/** The sequential or diverging colour scale, or null for a categorical colour field. */
|
|
14
|
+
continuousColorScale: {
|
|
15
|
+
scale: (v: number) => string;
|
|
16
|
+
domain: [number, number];
|
|
17
|
+
type: "sequential";
|
|
18
|
+
} | {
|
|
19
|
+
scale: (v: number) => string;
|
|
20
|
+
domain: [number, number, number];
|
|
21
|
+
type: "diverging";
|
|
22
|
+
} | null;
|
|
23
|
+
/**
|
|
24
|
+
* Map<category, { fill, stroke }> — one shared palette across the colour and fill channels.
|
|
25
|
+
*
|
|
26
|
+
* A literal CSS colour is a singleton keyed by null, which every mark falls back to. A
|
|
27
|
+
* `colorDomain` (FacetPlot passes one so panels agree) wins over the local categories. With
|
|
28
|
+
* no colour field at all, a single series takes the first palette entry rather than grey.
|
|
29
|
+
*/
|
|
30
|
+
colors: Map<unknown, {
|
|
31
|
+
fill: string;
|
|
32
|
+
stroke: string;
|
|
33
|
+
}> | Map<null, {
|
|
34
|
+
fill: any;
|
|
35
|
+
stroke: any;
|
|
36
|
+
}>;
|
|
37
|
+
/** Map<category, pattern name> — only for a categorical pattern field. */
|
|
38
|
+
patterns: Map<unknown, string> | SvelteMap<any, any>;
|
|
39
|
+
/** Map<category, symbol shape>. */
|
|
40
|
+
symbols: Map<unknown, string> | SvelteMap<any, any>;
|
|
41
|
+
#private;
|
|
42
|
+
}
|
|
43
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { SvelteMap } from 'svelte/reactivity'
|
|
2
|
+
import { inferFieldType, inferColorScaleType } from '../lib/plot/scales.js'
|
|
3
|
+
import {
|
|
4
|
+
distinct,
|
|
5
|
+
assignColors,
|
|
6
|
+
isLiteralColor,
|
|
7
|
+
buildSequentialScale,
|
|
8
|
+
buildDivergingScale
|
|
9
|
+
} from '../lib/brewing/colors.js'
|
|
10
|
+
import { assignPatterns } from '../lib/brewing/patterns.js'
|
|
11
|
+
import { assignSymbols } from '../lib/brewing/marks/points.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What each category looks like: its palette entry, pattern and symbol — and, for a numeric
|
|
15
|
+
* colour field, the continuous (sequential or diverging) colour scale.
|
|
16
|
+
*/
|
|
17
|
+
export class AestheticState {
|
|
18
|
+
#config
|
|
19
|
+
#channels
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
23
|
+
* @param {import('./ChannelState.svelte.js').ChannelState} channels
|
|
24
|
+
*/
|
|
25
|
+
constructor(config, channels) {
|
|
26
|
+
this.#config = config
|
|
27
|
+
this.#channels = channels
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 'categorical' | 'sequential' | 'diverging' — inferred from the colour field and the spec. */
|
|
31
|
+
colorScaleType = $derived.by(() => {
|
|
32
|
+
const field = this.#channels.effective.color
|
|
33
|
+
if (!field) return 'categorical'
|
|
34
|
+
return inferColorScaleType(this.#config.data, field, {
|
|
35
|
+
colorScale: this.#config.colorScale,
|
|
36
|
+
colorMidpoint: this.#config.colorMidpoint
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** The sequential or diverging colour scale, or null for a categorical colour field. */
|
|
41
|
+
continuousColorScale = $derived.by(() => {
|
|
42
|
+
const field = this.#channels.effective.color
|
|
43
|
+
if (!field || this.colorScaleType === 'categorical') return null
|
|
44
|
+
const opts = {
|
|
45
|
+
colorScheme: this.#config.colorScheme,
|
|
46
|
+
colorDomain: this.#config.colorDomain,
|
|
47
|
+
colorMidpoint: this.#config.colorMidpoint
|
|
48
|
+
}
|
|
49
|
+
const build = this.colorScaleType === 'diverging' ? buildDivergingScale : buildSequentialScale
|
|
50
|
+
return build(this.#config.data, field, opts)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map<category, { fill, stroke }> — one shared palette across the colour and fill channels.
|
|
55
|
+
*
|
|
56
|
+
* A literal CSS colour is a singleton keyed by null, which every mark falls back to. A
|
|
57
|
+
* `colorDomain` (FacetPlot passes one so panels agree) wins over the local categories. With
|
|
58
|
+
* no colour field at all, a single series takes the first palette entry rather than grey.
|
|
59
|
+
*/
|
|
60
|
+
colors = $derived.by(() => {
|
|
61
|
+
const field = this.#channels.effective.color
|
|
62
|
+
if (isLiteralColor(field)) {
|
|
63
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- a derived value, rebuilt whole; nothing mutates it
|
|
64
|
+
return new Map([[null, { fill: field, stroke: field }]])
|
|
65
|
+
}
|
|
66
|
+
const values = this.#config.colorDomain ?? this.#channels.colorValues
|
|
67
|
+
const keys = values.length === 0 && this.#config.data.length > 0 ? [null] : values
|
|
68
|
+
return assignColors(keys, this.#config.mode, this.#config.chartPreset)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** Map<category, pattern name> — only for a categorical pattern field. */
|
|
72
|
+
patterns = $derived.by(() => {
|
|
73
|
+
const pf = this.#channels.effective.pattern
|
|
74
|
+
if (!pf || inferFieldType(this.#config.data, pf) === 'continuous') return new SvelteMap()
|
|
75
|
+
return assignPatterns(distinct(this.#config.data, pf))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/** Map<category, symbol shape>. */
|
|
79
|
+
symbols = $derived.by(() => {
|
|
80
|
+
const sf = this.#channels.effective.symbol
|
|
81
|
+
if (!sf) return new SvelteMap()
|
|
82
|
+
return assignSymbols(distinct(this.#config.data, sf), this.#config.chartPreset)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the axes cross, in screen coordinates.
|
|
3
|
+
*
|
|
4
|
+
* At an explicit `axisOrigin` (a BCG or risk matrix); else at zero when the domain spans it
|
|
5
|
+
* (two- and four-quadrant charts fall out of where zero sits); else pinned to the bottom / left
|
|
6
|
+
* edge, pushed outward by `axisOffset`. A band x axis always pins left.
|
|
7
|
+
*/
|
|
8
|
+
export class AxisState {
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
11
|
+
* @param {{ x: any, y: any }} scales
|
|
12
|
+
* @param {{ innerWidth: number, innerHeight: number }} frame
|
|
13
|
+
*/
|
|
14
|
+
constructor(config: import("./PlotConfig.svelte.js").PlotConfig, scales: {
|
|
15
|
+
x: any;
|
|
16
|
+
y: any;
|
|
17
|
+
}, frame: {
|
|
18
|
+
innerWidth: number;
|
|
19
|
+
innerHeight: number;
|
|
20
|
+
});
|
|
21
|
+
/** The x axis's screen y. */
|
|
22
|
+
xAxisY: any;
|
|
23
|
+
/** The y axis's screen x. */
|
|
24
|
+
yAxisX: any;
|
|
25
|
+
#private;
|
|
26
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the axes cross, in screen coordinates.
|
|
3
|
+
*
|
|
4
|
+
* At an explicit `axisOrigin` (a BCG or risk matrix); else at zero when the domain spans it
|
|
5
|
+
* (two- and four-quadrant charts fall out of where zero sits); else pinned to the bottom / left
|
|
6
|
+
* edge, pushed outward by `axisOffset`. A band x axis always pins left.
|
|
7
|
+
*/
|
|
8
|
+
export class AxisState {
|
|
9
|
+
#config
|
|
10
|
+
#scales
|
|
11
|
+
#frame
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
15
|
+
* @param {{ x: any, y: any }} scales
|
|
16
|
+
* @param {{ innerWidth: number, innerHeight: number }} frame
|
|
17
|
+
*/
|
|
18
|
+
constructor(config, scales, frame) {
|
|
19
|
+
this.#config = config
|
|
20
|
+
this.#scales = scales
|
|
21
|
+
this.#frame = frame
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The x axis's screen y. */
|
|
25
|
+
xAxisY = $derived.by(() => {
|
|
26
|
+
const y = this.#scales.y
|
|
27
|
+
if (typeof y !== 'function') return this.#frame.innerHeight
|
|
28
|
+
const cross = this.#config.axisOrigin[1]
|
|
29
|
+
if (cross !== undefined) return y(cross)
|
|
30
|
+
const domain = y.domain()
|
|
31
|
+
if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return y(0)
|
|
32
|
+
return this.#offset(y(domain[0]), 1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** The y axis's screen x. */
|
|
36
|
+
yAxisX = $derived.by(() => {
|
|
37
|
+
const x = this.#scales.x
|
|
38
|
+
if (typeof x !== 'function') return 0
|
|
39
|
+
const cross = this.#config.axisOrigin[0]
|
|
40
|
+
if (cross !== undefined) return x(cross)
|
|
41
|
+
if (typeof x.bandwidth === 'function') return 0
|
|
42
|
+
const domain = x.domain()
|
|
43
|
+
if (domain[0] <= 0 && domain[domain.length - 1] >= 0) return x(0)
|
|
44
|
+
return this.#offset(x(domain[0]), -1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Push an edge-pinned axis outward by `axisOffset`. Only when there IS an offset: a scale with
|
|
49
|
+
* an empty domain (a race's first frame, before any rows) returns undefined, and
|
|
50
|
+
* `undefined + 0` is NaN — which lands in the axis transform as `translate(NaN, 0)`.
|
|
51
|
+
*/
|
|
52
|
+
#offset(base, direction) {
|
|
53
|
+
const offset = this.#config.axisOffset
|
|
54
|
+
return offset ? base + direction * offset : base
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which data field feeds each aesthetic, once the container and its geoms are merged.
|
|
3
|
+
*/
|
|
4
|
+
export class ChannelState {
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
7
|
+
* @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
|
|
8
|
+
*/
|
|
9
|
+
constructor(config: import("./PlotConfig.svelte.js").PlotConfig, geoms: import("./GeomRegistry.svelte.js").GeomRegistry);
|
|
10
|
+
/**
|
|
11
|
+
* The container's channels, with each field it omits taken from the FIRST geom — per field,
|
|
12
|
+
* not all-or-nothing, so a `<Plot.Root x y>` that omits `color` still picks up
|
|
13
|
+
* `<Plot.Box color=x>`'s, and the palette gets per-category entries instead of grey.
|
|
14
|
+
*/
|
|
15
|
+
effective: import("./PlotConfig.svelte.js").Channels | {
|
|
16
|
+
[k: string]: any;
|
|
17
|
+
};
|
|
18
|
+
/** The colour field, or null when `color` is a literal CSS colour rather than a field. */
|
|
19
|
+
colorField: any;
|
|
20
|
+
/** The fill field, or null for a literal colour. */
|
|
21
|
+
fillField: any;
|
|
22
|
+
patternField: any;
|
|
23
|
+
symbolField: any;
|
|
24
|
+
/**
|
|
25
|
+
* The category values feeding ONE shared palette and legend: every `color` and `fill` field
|
|
26
|
+
* of the container and of every geom (literal colours excluded), their values unioned in
|
|
27
|
+
* order. See docs/backlog/2026-08-17-chart-aesthetics-unification.md §2.
|
|
28
|
+
*
|
|
29
|
+
* `includes` rather than a Set: a domain of dozens of categories, not rows, so the scan
|
|
30
|
+
* costs nothing and there is no collection for the reactivity linter to flag.
|
|
31
|
+
*/
|
|
32
|
+
colorValues: unknown[];
|
|
33
|
+
#private;
|
|
34
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { distinct, isLiteralColor } from '../lib/brewing/colors.js'
|
|
2
|
+
|
|
3
|
+
/** The positional and aesthetic channels a container can inherit from its first geom. */
|
|
4
|
+
const INHERITED = ['x', 'y', 'color', 'fill', 'pattern', 'symbol']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which data field feeds each aesthetic, once the container and its geoms are merged.
|
|
8
|
+
*/
|
|
9
|
+
export class ChannelState {
|
|
10
|
+
#config
|
|
11
|
+
#geoms
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
15
|
+
* @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
|
|
16
|
+
*/
|
|
17
|
+
constructor(config, geoms) {
|
|
18
|
+
this.#config = config
|
|
19
|
+
this.#geoms = geoms
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The container's channels, with each field it omits taken from the FIRST geom — per field,
|
|
24
|
+
* not all-or-nothing, so a `<Plot.Root x y>` that omits `color` still picks up
|
|
25
|
+
* `<Plot.Box color=x>`'s, and the palette gets per-category entries instead of grey.
|
|
26
|
+
*/
|
|
27
|
+
effective = $derived.by(() => {
|
|
28
|
+
const tc = this.#config.channels
|
|
29
|
+
const geom = this.#geoms.first
|
|
30
|
+
if (!geom) return tc
|
|
31
|
+
const own = geom.channels ?? {}
|
|
32
|
+
return Object.fromEntries(INHERITED.map((key) => [key, tc[key] ?? own[key]]))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** The colour field, or null when `color` is a literal CSS colour rather than a field. */
|
|
36
|
+
colorField = $derived(isLiteralColor(this.effective.color) ? null : this.effective.color)
|
|
37
|
+
/** The fill field, or null for a literal colour. */
|
|
38
|
+
fillField = $derived(isLiteralColor(this.effective.fill) ? null : this.effective.fill)
|
|
39
|
+
patternField = $derived(this.effective.pattern)
|
|
40
|
+
symbolField = $derived(this.effective.symbol)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The category values feeding ONE shared palette and legend: every `color` and `fill` field
|
|
44
|
+
* of the container and of every geom (literal colours excluded), their values unioned in
|
|
45
|
+
* order. See docs/backlog/2026-08-17-chart-aesthetics-unification.md §2.
|
|
46
|
+
*
|
|
47
|
+
* `includes` rather than a Set: a domain of dozens of categories, not rows, so the scan
|
|
48
|
+
* costs nothing and there is no collection for the reactivity linter to flag.
|
|
49
|
+
*/
|
|
50
|
+
colorValues = $derived.by(() => {
|
|
51
|
+
const fields = []
|
|
52
|
+
const add = (f) => {
|
|
53
|
+
if (f && !isLiteralColor(f) && !fields.includes(f)) fields.push(f)
|
|
54
|
+
}
|
|
55
|
+
add(this.#config.channels.color)
|
|
56
|
+
add(this.#config.channels.fill)
|
|
57
|
+
for (const g of this.#geoms.list) {
|
|
58
|
+
add(g.channels?.color)
|
|
59
|
+
add(g.channels?.fill)
|
|
60
|
+
}
|
|
61
|
+
const values = []
|
|
62
|
+
for (const f of fields) {
|
|
63
|
+
for (const v of distinct(this.#config.data, f)) if (!values.includes(v)) values.push(v)
|
|
64
|
+
}
|
|
65
|
+
return values
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The geoms mounted in one plot, and the rows each one draws.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `PlotState` and `SparkState`, which used to carry a copy each — and so needed the
|
|
5
|
+
* undefined-channel fix below made twice. `source` is whatever owns the rows and the container
|
|
6
|
+
* channels: `{ data, channels, helpers? }`, read live through its getters.
|
|
7
|
+
*/
|
|
8
|
+
export class GeomRegistry {
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ data: Record<string, unknown>[], channels: Record<string, string | undefined>, helpers?: Record<string, unknown> }} source
|
|
11
|
+
* @param {string} [prefix] - id prefix, so a plot's geom ids and a spark's never collide
|
|
12
|
+
*/
|
|
13
|
+
constructor(source: {
|
|
14
|
+
data: Record<string, unknown>[];
|
|
15
|
+
channels: Record<string, string | undefined>;
|
|
16
|
+
helpers?: Record<string, unknown>;
|
|
17
|
+
}, prefix?: string);
|
|
18
|
+
/** Every mounted geom's config, in mount order. */
|
|
19
|
+
get list(): any[];
|
|
20
|
+
/** The first mounted geom — the one a container without channels borrows them from. */
|
|
21
|
+
get first(): any;
|
|
22
|
+
/** Distinct geom types mounted (the legend picks a swatch style from these). */
|
|
23
|
+
types: SvelteSet<any>;
|
|
24
|
+
/** @param {(g: any) => boolean} predicate */
|
|
25
|
+
find(predicate: (g: any) => boolean): any;
|
|
26
|
+
/** Called once per geom from its onMount; the returned id is its handle. */
|
|
27
|
+
register(config: any): string;
|
|
28
|
+
/**
|
|
29
|
+
* Called from a geom's `$effect`. The read of the list is untracked: tracked, the effect
|
|
30
|
+
* would re-run on its own write (effect_update_depth_exceeded).
|
|
31
|
+
*/
|
|
32
|
+
update(id: any, config: any): void;
|
|
33
|
+
/** Called from a geom's onDestroy, which Svelte runs outside any tracking scope. */
|
|
34
|
+
unregister(id: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* The rows one geom draws.
|
|
37
|
+
*
|
|
38
|
+
* Identity stat returns the source rows THEMSELVES, not a copy: geoms look a row up with
|
|
39
|
+
* `plotState.data.indexOf(row)`, which only matches the same object.
|
|
40
|
+
*/
|
|
41
|
+
data(id: any): Object[];
|
|
42
|
+
#private;
|
|
43
|
+
}
|
|
44
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { untrack } from 'svelte'
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
3
|
+
import { applyGeomStat } from '../lib/plot/stat.js'
|
|
4
|
+
|
|
5
|
+
/** Module-wide so ids stay unique across every plot and spark on the page. */
|
|
6
|
+
let nextId = 0
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The geoms mounted in one plot, and the rows each one draws.
|
|
10
|
+
*
|
|
11
|
+
* Shared by `PlotState` and `SparkState`, which used to carry a copy each — and so needed the
|
|
12
|
+
* undefined-channel fix below made twice. `source` is whatever owns the rows and the container
|
|
13
|
+
* channels: `{ data, channels, helpers? }`, read live through its getters.
|
|
14
|
+
*/
|
|
15
|
+
export class GeomRegistry {
|
|
16
|
+
#source
|
|
17
|
+
#prefix
|
|
18
|
+
#geoms = $state([])
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ data: Record<string, unknown>[], channels: Record<string, string | undefined>, helpers?: Record<string, unknown> }} source
|
|
22
|
+
* @param {string} [prefix] - id prefix, so a plot's geom ids and a spark's never collide
|
|
23
|
+
*/
|
|
24
|
+
constructor(source, prefix = 'geom') {
|
|
25
|
+
this.#source = source
|
|
26
|
+
this.#prefix = prefix
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every mounted geom's config, in mount order. */
|
|
30
|
+
get list() {
|
|
31
|
+
return this.#geoms
|
|
32
|
+
}
|
|
33
|
+
/** The first mounted geom — the one a container without channels borrows them from. */
|
|
34
|
+
get first() {
|
|
35
|
+
return this.#geoms[0]
|
|
36
|
+
}
|
|
37
|
+
/** Distinct geom types mounted (the legend picks a swatch style from these). */
|
|
38
|
+
types = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
|
|
39
|
+
|
|
40
|
+
/** @param {(g: any) => boolean} predicate */
|
|
41
|
+
find(predicate) {
|
|
42
|
+
return this.#geoms.find(predicate)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Called once per geom from its onMount; the returned id is its handle. */
|
|
46
|
+
register(config) {
|
|
47
|
+
const id = `${this.#prefix}-${nextId++}`
|
|
48
|
+
this.#geoms = [...this.#geoms, { id, ...config }]
|
|
49
|
+
return id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called from a geom's `$effect`. The read of the list is untracked: tracked, the effect
|
|
54
|
+
* would re-run on its own write (effect_update_depth_exceeded).
|
|
55
|
+
*/
|
|
56
|
+
update(id, config) {
|
|
57
|
+
this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Called from a geom's onDestroy, which Svelte runs outside any tracking scope. */
|
|
61
|
+
unregister(id) {
|
|
62
|
+
this.#geoms = this.#geoms.filter((g) => g.id !== id)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rows one geom draws.
|
|
67
|
+
*
|
|
68
|
+
* Identity stat returns the source rows THEMSELVES, not a copy: geoms look a row up with
|
|
69
|
+
* `plotState.data.indexOf(row)`, which only matches the same object.
|
|
70
|
+
*/
|
|
71
|
+
data(id) {
|
|
72
|
+
const geom = this.#geoms.find((g) => g.id === id)
|
|
73
|
+
if (!geom) return []
|
|
74
|
+
const stat = geom.stat ?? 'identity'
|
|
75
|
+
const rows = this.#source.data
|
|
76
|
+
if (stat === 'identity') return rows
|
|
77
|
+
// A geom that omits a channel to inherit it from the container sends
|
|
78
|
+
// `{ x: undefined }`, not `{}`. Spread as-is, that own `undefined` would clobber the
|
|
79
|
+
// container's value and the stat would silently fall back to identity.
|
|
80
|
+
const geomChannels = Object.fromEntries(
|
|
81
|
+
Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
|
|
82
|
+
)
|
|
83
|
+
const channels = { ...this.#source.channels, ...geomChannels }
|
|
84
|
+
return applyGeomStat(rows, { stat, channels }, this.#source.helpers)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reader is doing to the chart: the hovered row, the selected rows, the zoom.
|
|
3
|
+
*
|
|
4
|
+
* The selection is a set of ROW objects, compared by identity — they must be the rows the
|
|
5
|
+
* geoms were handed (`PlotState.data`), which is why the plot keeps one proxy for its data.
|
|
6
|
+
*/
|
|
7
|
+
export class InteractionState {
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
10
|
+
* @param {unknown[]} [selected] - the initial selection; afterwards the caller drives it
|
|
11
|
+
* through `setSelected` (PlotSurface syncs its bindable prop that way)
|
|
12
|
+
*/
|
|
13
|
+
constructor(config: import("./PlotConfig.svelte.js").PlotConfig, selected?: unknown[]);
|
|
14
|
+
get hovered(): null;
|
|
15
|
+
setHovered(row: any): void;
|
|
16
|
+
clearHovered(): void;
|
|
17
|
+
/** Marks are clickable: the caller listens, or selection is on. */
|
|
18
|
+
get interactive(): boolean;
|
|
19
|
+
get selectedRows(): any[];
|
|
20
|
+
isSelected(row: any): boolean;
|
|
21
|
+
setSelected(rows: any): void;
|
|
22
|
+
clearSelected(): void;
|
|
23
|
+
/** An activation: always reported; toggles the row in the selection when selectable. */
|
|
24
|
+
handleSelect(detail: any): void;
|
|
25
|
+
/** The d3-zoom transform, or null at rest. Scales rescale through it. */
|
|
26
|
+
get zoom(): null;
|
|
27
|
+
applyZoom(transform: any): void;
|
|
28
|
+
resetZoom(): void;
|
|
29
|
+
#private;
|
|
30
|
+
}
|