@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.
Files changed (62) hide show
  1. package/README.md +13 -0
  2. package/dist/Plot.svelte +38 -10
  3. package/dist/Plot.svelte.d.ts +7 -2
  4. package/dist/PlotState.svelte.d.ts +115 -71
  5. package/dist/PlotState.svelte.js +174 -547
  6. package/dist/SparkState.svelte.d.ts +3 -1
  7. package/dist/SparkState.svelte.js +11 -41
  8. package/dist/geoms/Contour.svelte +88 -0
  9. package/dist/geoms/Contour.svelte.d.ts +19 -0
  10. package/dist/geoms/Hull.svelte +89 -0
  11. package/dist/geoms/Hull.svelte.d.ts +17 -0
  12. package/dist/geoms/Region.svelte +107 -0
  13. package/dist/geoms/Region.svelte.d.ts +27 -0
  14. package/dist/geoms/Rule.svelte +88 -26
  15. package/dist/geoms/Rule.svelte.d.ts +8 -0
  16. package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
  17. package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
  18. package/dist/geoms/lib/bars.js +13 -20
  19. package/dist/geoms/lib/grouping.d.ts +35 -0
  20. package/dist/geoms/lib/grouping.js +59 -0
  21. package/dist/geoms/lib/marks/contour.d.ts +26 -0
  22. package/dist/geoms/lib/marks/contour.js +67 -0
  23. package/dist/geoms/lib/marks/hull.d.ts +26 -0
  24. package/dist/geoms/lib/marks/hull.js +36 -0
  25. package/dist/geoms/lib/marks/waterfall.js +15 -20
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.js +9 -0
  28. package/dist/lib/abline.d.ts +41 -0
  29. package/dist/lib/abline.js +51 -0
  30. package/dist/lib/hull.d.ts +18 -0
  31. package/dist/lib/hull.js +64 -0
  32. package/dist/lib/plot/domains.d.ts +61 -0
  33. package/dist/lib/plot/domains.js +130 -0
  34. package/dist/lib/plot/running.d.ts +23 -0
  35. package/dist/lib/plot/running.js +32 -0
  36. package/dist/lib/plot/stacking.d.ts +32 -0
  37. package/dist/lib/plot/stacking.js +34 -0
  38. package/dist/lib/plot/types.d.ts +8 -1
  39. package/dist/lib/plot/types.js +5 -1
  40. package/dist/lib/preset.d.ts +2 -0
  41. package/dist/lib/preset.js +76 -41
  42. package/dist/lib/region.d.ts +63 -0
  43. package/dist/lib/region.js +127 -0
  44. package/dist/state/AestheticState.svelte.d.ts +43 -0
  45. package/dist/state/AestheticState.svelte.js +84 -0
  46. package/dist/state/AxisState.svelte.d.ts +26 -0
  47. package/dist/state/AxisState.svelte.js +56 -0
  48. package/dist/state/ChannelState.svelte.d.ts +34 -0
  49. package/dist/state/ChannelState.svelte.js +67 -0
  50. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  51. package/dist/state/GeomRegistry.svelte.js +86 -0
  52. package/dist/state/InteractionState.svelte.d.ts +30 -0
  53. package/dist/state/InteractionState.svelte.js +69 -0
  54. package/dist/state/OrientationState.svelte.d.ts +34 -0
  55. package/dist/state/OrientationState.svelte.js +64 -0
  56. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  57. package/dist/state/PlotConfig.svelte.js +195 -0
  58. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  59. package/dist/state/PlotFrame.svelte.js +18 -0
  60. package/dist/state/ScaleState.svelte.d.ts +36 -0
  61. package/dist/state/ScaleState.svelte.js +121 -0
  62. 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
+ }