@rokkit/chart 1.7.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/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/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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the reader is doing to the chart: the hovered row, the selected rows, the zoom.
|
|
5
|
+
*
|
|
6
|
+
* The selection is a set of ROW objects, compared by identity — they must be the rows the
|
|
7
|
+
* geoms were handed (`PlotState.data`), which is why the plot keeps one proxy for its data.
|
|
8
|
+
*/
|
|
9
|
+
export class InteractionState {
|
|
10
|
+
#config
|
|
11
|
+
#hovered = $state(null)
|
|
12
|
+
#selected = $state(new SvelteSet())
|
|
13
|
+
#zoom = $state(null)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
17
|
+
* @param {unknown[]} [selected] - the initial selection; afterwards the caller drives it
|
|
18
|
+
* through `setSelected` (PlotSurface syncs its bindable prop that way)
|
|
19
|
+
*/
|
|
20
|
+
constructor(config, selected) {
|
|
21
|
+
this.#config = config
|
|
22
|
+
if (selected) this.#selected = new SvelteSet(selected)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get hovered() {
|
|
26
|
+
return this.#hovered
|
|
27
|
+
}
|
|
28
|
+
setHovered(row) {
|
|
29
|
+
this.#hovered = row
|
|
30
|
+
}
|
|
31
|
+
clearHovered() {
|
|
32
|
+
this.#hovered = null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Marks are clickable: the caller listens, or selection is on. */
|
|
36
|
+
get interactive() {
|
|
37
|
+
return Boolean(this.#config.onselect) || this.#config.selectable
|
|
38
|
+
}
|
|
39
|
+
get selectedRows() {
|
|
40
|
+
return [...this.#selected]
|
|
41
|
+
}
|
|
42
|
+
isSelected(row) {
|
|
43
|
+
return this.#selected.has(row)
|
|
44
|
+
}
|
|
45
|
+
setSelected(rows) {
|
|
46
|
+
this.#selected = new SvelteSet(rows ?? [])
|
|
47
|
+
}
|
|
48
|
+
clearSelected() {
|
|
49
|
+
this.#selected = new SvelteSet()
|
|
50
|
+
}
|
|
51
|
+
/** An activation: always reported; toggles the row in the selection when selectable. */
|
|
52
|
+
handleSelect(detail) {
|
|
53
|
+
this.#config.onselect?.(detail)
|
|
54
|
+
if (!this.#config.selectable || detail?.datum === undefined) return
|
|
55
|
+
if (this.#selected.has(detail.datum)) this.#selected.delete(detail.datum)
|
|
56
|
+
else this.#selected.add(detail.datum)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The d3-zoom transform, or null at rest. Scales rescale through it. */
|
|
60
|
+
get zoom() {
|
|
61
|
+
return this.#zoom
|
|
62
|
+
}
|
|
63
|
+
applyZoom(transform) {
|
|
64
|
+
this.#zoom = transform
|
|
65
|
+
}
|
|
66
|
+
resetZoom() {
|
|
67
|
+
this.#zoom = null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Geoms that band the x channel even when it is numeric (a year, a week, a race rank). */
|
|
2
|
+
export const CATEGORICAL_X: readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* Which way the chart reads, and how abstract (x-channel, y-channel) positions map to screen.
|
|
5
|
+
*
|
|
6
|
+
* Only a category-on-x chart flips: horizontal stands the band axis up the screen and lays the
|
|
7
|
+
* value axis along it, with the x/y channels unchanged. A plain scatter has no band, so
|
|
8
|
+
* `orientation` is a no-op for it.
|
|
9
|
+
*/
|
|
10
|
+
export class OrientationState {
|
|
11
|
+
/**
|
|
12
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
13
|
+
* @param {import('./ChannelState.svelte.js').ChannelState} channels
|
|
14
|
+
* @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
|
|
15
|
+
*/
|
|
16
|
+
constructor(config: import("./PlotConfig.svelte.js").PlotConfig, channels: import("./ChannelState.svelte.js").ChannelState, geoms: import("./GeomRegistry.svelte.js").GeomRegistry);
|
|
17
|
+
/** A mounted geom bands the x channel. */
|
|
18
|
+
hasBandGeom: boolean;
|
|
19
|
+
/** The caller's override, else inferred: 'vertical' | 'horizontal' | 'none'. */
|
|
20
|
+
orientation: string;
|
|
21
|
+
/** The x channel is the categorical (band) axis. */
|
|
22
|
+
bandIsX: boolean;
|
|
23
|
+
/** Rendered horizontally: the band axis stands up the screen. */
|
|
24
|
+
flipped: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Abstract (x-channel, y-channel) scale outputs to screen coordinates. Geoms compute
|
|
27
|
+
* `u = xScale(d[x])`, `v = yScale(d[y])`, then place them.
|
|
28
|
+
*/
|
|
29
|
+
place(u: any, v: any): {
|
|
30
|
+
x: any;
|
|
31
|
+
y: any;
|
|
32
|
+
};
|
|
33
|
+
#private;
|
|
34
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { inferFieldType, inferOrientation } from '../lib/plot/scales.js'
|
|
2
|
+
|
|
3
|
+
/** Geoms that band the x channel even when it is numeric (a year, a week, a race rank). */
|
|
4
|
+
export const CATEGORICAL_X = Object.freeze(['bar', 'box', 'violin', 'jitter'])
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which way the chart reads, and how abstract (x-channel, y-channel) positions map to screen.
|
|
8
|
+
*
|
|
9
|
+
* Only a category-on-x chart flips: horizontal stands the band axis up the screen and lays the
|
|
10
|
+
* value axis along it, with the x/y channels unchanged. A plain scatter has no band, so
|
|
11
|
+
* `orientation` is a no-op for it.
|
|
12
|
+
*/
|
|
13
|
+
export class OrientationState {
|
|
14
|
+
#config
|
|
15
|
+
#channels
|
|
16
|
+
#geoms
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('./PlotConfig.svelte.js').PlotConfig} config
|
|
20
|
+
* @param {import('./ChannelState.svelte.js').ChannelState} channels
|
|
21
|
+
* @param {import('./GeomRegistry.svelte.js').GeomRegistry} geoms
|
|
22
|
+
*/
|
|
23
|
+
constructor(config, channels, geoms) {
|
|
24
|
+
this.#config = config
|
|
25
|
+
this.#channels = channels
|
|
26
|
+
this.#geoms = geoms
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A mounted geom bands the x channel. */
|
|
30
|
+
hasBandGeom = $derived(this.#geoms.list.some((g) => CATEGORICAL_X.includes(g.type)))
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Field types of the effective x and y, with x forced to `band` when a banding geom sits on
|
|
34
|
+
* a continuous x/y pair. Null until both channels exist. Computed once for orientation and
|
|
35
|
+
* `bandIsX` both — they used to infer the same types twice.
|
|
36
|
+
*/
|
|
37
|
+
#types = $derived.by(() => {
|
|
38
|
+
const { x, y } = this.#channels.effective
|
|
39
|
+
if (!x || !y) return null
|
|
40
|
+
const rawX = inferFieldType(this.#config.data, x)
|
|
41
|
+
const yType = inferFieldType(this.#config.data, y)
|
|
42
|
+
const xType =
|
|
43
|
+
this.hasBandGeom && rawX === 'continuous' && yType === 'continuous' ? 'band' : rawX
|
|
44
|
+
return { xType, yType }
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** The caller's override, else inferred: 'vertical' | 'horizontal' | 'none'. */
|
|
48
|
+
orientation = $derived(
|
|
49
|
+
this.#config.orientation ??
|
|
50
|
+
(this.#types ? inferOrientation(this.#types.xType, this.#types.yType) : 'none')
|
|
51
|
+
)
|
|
52
|
+
/** The x channel is the categorical (band) axis. */
|
|
53
|
+
bandIsX = $derived(this.#types?.xType === 'band')
|
|
54
|
+
/** Rendered horizontally: the band axis stands up the screen. */
|
|
55
|
+
flipped = $derived(this.orientation === 'horizontal' && this.bandIsX)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Abstract (x-channel, y-channel) scale outputs to screen coordinates. Geoms compute
|
|
59
|
+
* `u = xScale(d[x])`, `v = yScale(d[y])`, then place them.
|
|
60
|
+
*/
|
|
61
|
+
place(u, v) {
|
|
62
|
+
return this.flipped ? { x: v, y: u } : { x: u, y: v }
|
|
63
|
+
}
|
|
64
|
+
}
|