@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,130 @@
1
+ /**
2
+ * Axis domains that a geom's own geometry decides, rather than the raw extent of its rows.
3
+ *
4
+ * A box spans its whiskers and outliers, not its rows' y; a stacked bar reaches its column
5
+ * TOTAL; a waterfall bar sits on a running total. Pure functions over the rows a geom draws,
6
+ * so they are testable without Svelte, and `resolveValueDomain` asks them in a fixed order —
7
+ * a new geom with a geometry-driven domain adds a row to that table instead of editing the
8
+ * scale code.
9
+ */
10
+ import { stackFieldOf } from './stacking.js'
11
+ import { runningSpans } from './running.js'
12
+
13
+ const valid = (v) => v !== null && v !== undefined && !isNaN(v)
14
+
15
+ /**
16
+ * Band categories ordered by their summed value — "sort bars by size". Null when not sorting.
17
+ *
18
+ * @param {Record<string, unknown>[][]} datasets - every geom's rows
19
+ * @param {string | undefined} xf
20
+ * @param {string | undefined} yf
21
+ * @param {'asc' | 'desc' | undefined} sort
22
+ */
23
+ export function sortedBandDomain(datasets, xf, yf, sort) {
24
+ if (!sort || !xf || !yf) return null
25
+ // A Map, not an object: keys are raw values (numbers, Dates) an object would stringify.
26
+ const totals = new Map()
27
+ for (const rows of datasets) {
28
+ for (const d of rows) totals.set(d[xf], (totals.get(d[xf]) ?? 0) + (Number(d[yf]) || 0))
29
+ }
30
+ const dir = sort === 'asc' ? 1 : -1
31
+ return [...totals.keys()].sort((a, b) => dir * (totals.get(a) - totals.get(b)))
32
+ }
33
+
34
+ /**
35
+ * A box/violin spans its whiskers (`iqr_min` / `iqr_max`) and every outlier.
36
+ *
37
+ * @param {Record<string, any>[]} rows - box-stat rows
38
+ */
39
+ export function boxDomain(rows) {
40
+ const outliers = rows.flatMap((d) => d.outliers ?? []).filter(valid)
41
+ const mins = [...rows.map((d) => d.iqr_min).filter(valid), ...outliers]
42
+ const maxs = [...rows.map((d) => d.iqr_max).filter(valid), ...outliers]
43
+ return mins.length > 0 && maxs.length > 0 ? [Math.min(...mins), Math.max(...maxs)] : null
44
+ }
45
+
46
+ /**
47
+ * A stacked bar reaches its column total; a 100%-filled one reaches 1.
48
+ *
49
+ * Summed per (x, stack key), keeping the LAST value per key — the builder's own lookup does the
50
+ * same, so rows sharing a key under an identity stat do not double-count. The stack key is the
51
+ * builder's (`stackFieldOf`); with none, the builder draws plain bars, so there is no total to
52
+ * size to and the normal extent applies (null).
53
+ *
54
+ * @param {Record<string, unknown>[]} rows
55
+ * @param {{ x?: string, y?: string, group?: string, fill?: string, color?: string, pattern?: string }} channels
56
+ * @param {'stack' | 'fill' | undefined} position
57
+ */
58
+ export function stackDomain(rows, channels, position) {
59
+ if (position === 'fill') return [0, 1]
60
+ const stackField = stackFieldOf(channels)
61
+ if (!channels.x || rows.length === 0 || !stackField) return null
62
+ const columns = new Map()
63
+ for (const d of rows) {
64
+ const column = columns.get(d[channels.x]) ?? new Map()
65
+ column.set(String(d[stackField]), Number(d[channels.y]) || 0)
66
+ columns.set(d[channels.x], column)
67
+ }
68
+ const totals = [...columns.values()].map((c) => [...c.values()].reduce((s, v) => s + v, 0))
69
+ return [0, Math.max(0, ...totals)]
70
+ }
71
+
72
+ /**
73
+ * A waterfall spans its running total: each step from where the last ended, a total row from 0.
74
+ *
75
+ * @param {Record<string, unknown>[]} rows
76
+ * @param {string} field
77
+ * @param {string} [totalField] - rows flagged here are totals, drawn from zero
78
+ */
79
+ export function waterfallDomain(rows, field, totalField) {
80
+ if (rows.length === 0) return null
81
+ const spans = runningSpans(rows, field, totalField)
82
+ return [Math.min(0, ...spans.map((s) => s.lo)), Math.max(0, ...spans.map((s) => s.hi))]
83
+ }
84
+
85
+ /** A geom's channels without the keys it left undefined (to inherit). */
86
+ const definedOf = (channels = {}) =>
87
+ Object.fromEntries(Object.entries(channels).filter(([, v]) => v !== undefined))
88
+
89
+ const isStacked = (g) =>
90
+ g.options?.stack || g.options?.position === 'stack' || g.options?.position === 'fill'
91
+
92
+ /**
93
+ * Geom types whose geometry decides the VALUE axis, asked in order; the first to return a
94
+ * domain wins. Each resolver reads the first mounted geom it matches.
95
+ */
96
+ const VALUE_DOMAINS = [
97
+ { matches: (g) => g.type === 'box' || g.type === 'violin', domain: (rows) => boxDomain(rows) },
98
+ {
99
+ matches: isStacked,
100
+ // The geom's OWN channels win — `group` exists only there — over the effective ones.
101
+ domain: (rows, geom, channels, field) =>
102
+ stackDomain(
103
+ rows,
104
+ { ...channels, ...definedOf(geom.channels), y: field },
105
+ geom.options?.position === 'fill' ? 'fill' : 'stack'
106
+ )
107
+ },
108
+ {
109
+ matches: (g) => g.type === 'waterfall',
110
+ domain: (rows, geom, _channels, field) => waterfallDomain(rows, field, geom.options?.totalField)
111
+ }
112
+ ]
113
+
114
+ /**
115
+ * The value-axis domain the mounted geoms' geometry demands, or null for the plain extent.
116
+ *
117
+ * @param {Array<{ id: string, type: string, options?: Record<string, any> }>} geoms
118
+ * @param {(id: string) => Record<string, unknown>[]} rowsOf - a geom's rows (post-stat)
119
+ * @param {{ x?: string, fill?: string, color?: string, pattern?: string }} channels - effective
120
+ * @param {string} field - the value channel
121
+ */
122
+ export function resolveValueDomain(geoms, rowsOf, channels, field) {
123
+ for (const resolver of VALUE_DOMAINS) {
124
+ const geom = geoms.find(resolver.matches)
125
+ if (!geom) continue
126
+ const domain = resolver.domain(rowsOf(geom.id), geom, channels, field)
127
+ if (domain) return domain
128
+ }
129
+ return null
130
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A waterfall's geometry: where each bar starts and ends on the value axis.
3
+ *
4
+ * One function for the builder that draws the bars and the domain that sizes the axis to them —
5
+ * two copies disagreed: the builder drew a negative total as a 1px sliver at zero, and let a
6
+ * missing value turn every later bar into NaN, while the axis was sized as if neither happened.
7
+ *
8
+ * A step runs from the running total before it to the running total after; a total row (flagged
9
+ * by `totalField`) runs from 0 to the running total, in whichever direction that is. A missing
10
+ * or non-numeric value is a step of 0.
11
+ *
12
+ * @param {Record<string, unknown>[]} rows
13
+ * @param {string} field
14
+ * @param {string} [totalField]
15
+ * @returns {Array<{ lo: number, hi: number, total: number, isTotal: boolean, delta: number }>}
16
+ */
17
+ export function runningSpans(rows: Record<string, unknown>[], field: string, totalField?: string): Array<{
18
+ lo: number;
19
+ hi: number;
20
+ total: number;
21
+ isTotal: boolean;
22
+ delta: number;
23
+ }>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A waterfall's geometry: where each bar starts and ends on the value axis.
3
+ *
4
+ * One function for the builder that draws the bars and the domain that sizes the axis to them —
5
+ * two copies disagreed: the builder drew a negative total as a 1px sliver at zero, and let a
6
+ * missing value turn every later bar into NaN, while the axis was sized as if neither happened.
7
+ *
8
+ * A step runs from the running total before it to the running total after; a total row (flagged
9
+ * by `totalField`) runs from 0 to the running total, in whichever direction that is. A missing
10
+ * or non-numeric value is a step of 0.
11
+ *
12
+ * @param {Record<string, unknown>[]} rows
13
+ * @param {string} field
14
+ * @param {string} [totalField]
15
+ * @returns {Array<{ lo: number, hi: number, total: number, isTotal: boolean, delta: number }>}
16
+ */
17
+ export function runningSpans(rows, field, totalField) {
18
+ let running = 0
19
+ return rows.map((d) => {
20
+ const isTotal = Boolean(totalField && d[totalField])
21
+ const delta = isTotal ? 0 : Number(d[field]) || 0
22
+ const start = isTotal ? 0 : running
23
+ running += delta
24
+ return {
25
+ lo: Math.min(start, running),
26
+ hi: Math.max(start, running),
27
+ total: running,
28
+ isTotal,
29
+ delta
30
+ }
31
+ })
32
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The distinct non-x fields among [group, color, pattern] — the fields that put more than one
3
+ * bar in a single x band (dodge) or more than one segment in a column (stack). `group`, the
4
+ * explicit grouping channel, comes first. Literal colours are paint, not fields.
5
+ *
6
+ * @param {{ x?: string, group?: string, color?: string, pattern?: string }} channels
7
+ * @returns {string[]}
8
+ */
9
+ export function subBandFields(channels: {
10
+ x?: string;
11
+ group?: string;
12
+ color?: string;
13
+ pattern?: string;
14
+ }): string[];
15
+ /**
16
+ * The field a stacked bar stacks by, from a bar's own channels — or null when there is none,
17
+ * in which case the builder draws plain bars. One rule for the builder that draws the stack
18
+ * and the domain that sizes the axis to it: two copies disagreed, and the axis ended below
19
+ * the tallest bar.
20
+ *
21
+ * `group` defaults to the interior (`fill ?? color`), exactly as the bar marks resolve it.
22
+ *
23
+ * @param {{ x?: string, group?: string, fill?: string, color?: string, pattern?: string }} channels
24
+ * @returns {string | null}
25
+ */
26
+ export function stackFieldOf({ x, group, fill, color, pattern }: {
27
+ x?: string;
28
+ group?: string;
29
+ fill?: string;
30
+ color?: string;
31
+ pattern?: string;
32
+ }): string | null;
@@ -0,0 +1,34 @@
1
+ import { isLiteralColor } from '../brewing/colors.js'
2
+
3
+ /**
4
+ * The distinct non-x fields among [group, color, pattern] — the fields that put more than one
5
+ * bar in a single x band (dodge) or more than one segment in a column (stack). `group`, the
6
+ * explicit grouping channel, comes first. Literal colours are paint, not fields.
7
+ *
8
+ * @param {{ x?: string, group?: string, color?: string, pattern?: string }} channels
9
+ * @returns {string[]}
10
+ */
11
+ export function subBandFields(channels) {
12
+ const { x: xf, group: gf, color: cf, pattern: pf } = channels
13
+ const out = []
14
+ for (const f of [gf, cf, pf]) {
15
+ if (f && f !== xf && !out.includes(f) && !isLiteralColor(f)) out.push(f)
16
+ }
17
+ return out
18
+ }
19
+
20
+ /**
21
+ * The field a stacked bar stacks by, from a bar's own channels — or null when there is none,
22
+ * in which case the builder draws plain bars. One rule for the builder that draws the stack
23
+ * and the domain that sizes the axis to it: two copies disagreed, and the axis ended below
24
+ * the tallest bar.
25
+ *
26
+ * `group` defaults to the interior (`fill ?? color`), exactly as the bar marks resolve it.
27
+ *
28
+ * @param {{ x?: string, group?: string, fill?: string, color?: string, pattern?: string }} channels
29
+ * @returns {string | null}
30
+ */
31
+ export function stackFieldOf({ x, group, fill, color, pattern }) {
32
+ const interior = fill ?? color
33
+ return subBandFields({ x, group: group ?? interior, color: interior, pattern })[0] ?? null
34
+ }
@@ -1,6 +1,6 @@
1
1
  export type GeomSpec = {
2
2
  /**
3
- * - Geom type: 'bar'|'line'|'area'|'point'|'box'|'violin'|'arc' or custom
3
+ * - Geom type: 'bar'|'line'|'area'|'point'|'box'|'violin'|'arc'|'hull'|'contour'|'rule'|'region' or custom
4
4
  */
5
5
  type: string;
6
6
  x?: string | undefined;
@@ -16,6 +16,13 @@ export type GeomSpec = {
16
16
  stat?: string | undefined;
17
17
  label?: string | boolean | undefined;
18
18
  options?: Record<string, unknown> | undefined;
19
+ /**
20
+ * - Geom-specific props spread onto the component
21
+ * (`Region`'s `points`/`label`, `Rule`'s `slope`, `Hull`'s `padding`). For the annotation
22
+ * geoms (`rule`, `region`) these are the ONLY props: they read no data, and a Region's `x`/`y`
23
+ * are ranges, so inheriting the spec's field names would be wrong.
24
+ */
25
+ props?: Record<string, unknown> | undefined;
19
26
  };
20
27
  export type PlotSpec = {
21
28
  data?: Record<string, unknown>[] | undefined;
@@ -3,7 +3,7 @@
3
3
 
4
4
  /**
5
5
  * @typedef {Object} GeomSpec
6
- * @property {string} type - Geom type: 'bar'|'line'|'area'|'point'|'box'|'violin'|'arc' or custom
6
+ * @property {string} type - Geom type: 'bar'|'line'|'area'|'point'|'box'|'violin'|'arc'|'hull'|'contour'|'rule'|'region' or custom
7
7
  * @property {string} [x]
8
8
  * @property {string} [y]
9
9
  * @property {string} [color]
@@ -14,6 +14,10 @@
14
14
  * @property {string} [stat] - Built-in or helpers.stats key
15
15
  * @property {boolean | string} [label]
16
16
  * @property {Record<string, unknown>} [options]
17
+ * @property {Record<string, unknown>} [props] - Geom-specific props spread onto the component
18
+ * (`Region`'s `points`/`label`, `Rule`'s `slope`, `Hull`'s `padding`). For the annotation
19
+ * geoms (`rule`, `region`) these are the ONLY props: they read no data, and a Region's `x`/`y`
20
+ * are ranges, so inheriting the spec's field names would be wrong.
17
21
  */
18
22
 
19
23
  /**
@@ -34,6 +34,8 @@ export namespace defaultPreset {
34
34
  let ribbon: number;
35
35
  let waterfall: number;
36
36
  let rule: number;
37
+ let hull: number;
38
+ let contour: number;
37
39
  let radar: number;
38
40
  }
39
41
  let patterns: string[];
@@ -1,36 +1,71 @@
1
1
  // packages/chart/src/lib/preset.js
2
2
 
3
3
  export const defaultPreset = {
4
- colors: ['blue', 'emerald', 'rose', 'amber', 'violet', 'sky', 'pink', 'teal',
5
- 'orange', 'indigo', 'lime', 'cyan', 'gold', 'lavender'],
6
- shades: {
7
- light: { fill: '300', stroke: '700' },
8
- dark: { fill: '500', stroke: '200' }
9
- },
10
- // Per-geom default mark opacity. `alpha` (a fixed 0–1 prop on each geom / Plot.Root)
11
- // overrides these; solid marks default to 1 so `alpha` is the single opacity knob.
12
- opacity: {
13
- area: 0.6,
14
- bar: 1,
15
- line: 1,
16
- point: 0.8,
17
- arc: 1,
18
- box: 0.5,
19
- violin: 0.5,
20
- jitter: 0.8,
21
- heatmap: 1,
22
- candlestick: 1,
23
- hexbin: 1,
24
- ribbon: 0.5,
25
- waterfall: 1,
26
- rule: 1,
27
- radar: 0.25
28
- },
29
- patterns: ['diagonal', 'dots', 'triangles', 'hatch', 'lattice', 'swell',
30
- 'checkerboard', 'waves', 'petals', 'brick', 'diamonds', 'tile',
31
- 'scales', 'circles', 'pip', 'rings', 'chevrons', 'shards',
32
- 'wedge', 'argyle', 'shell'],
33
- symbols: ['circle', 'square', 'triangle', 'diamond', 'cross', 'star']
4
+ colors: [
5
+ 'blue',
6
+ 'emerald',
7
+ 'rose',
8
+ 'amber',
9
+ 'violet',
10
+ 'sky',
11
+ 'pink',
12
+ 'teal',
13
+ 'orange',
14
+ 'indigo',
15
+ 'lime',
16
+ 'cyan',
17
+ 'gold',
18
+ 'lavender'
19
+ ],
20
+ shades: {
21
+ light: { fill: '300', stroke: '700' },
22
+ dark: { fill: '500', stroke: '200' }
23
+ },
24
+ // Per-geom default mark opacity. `alpha` (a fixed 0–1 prop on each geom / Plot.Root)
25
+ // overrides these; solid marks default to 1 so `alpha` is the single opacity knob.
26
+ opacity: {
27
+ area: 0.6,
28
+ bar: 1,
29
+ line: 1,
30
+ point: 0.8,
31
+ arc: 1,
32
+ box: 0.5,
33
+ violin: 0.5,
34
+ jitter: 0.8,
35
+ heatmap: 1,
36
+ candlestick: 1,
37
+ hexbin: 1,
38
+ ribbon: 0.5,
39
+ waterfall: 1,
40
+ rule: 1,
41
+ hull: 0.15,
42
+ contour: 0.5,
43
+ radar: 0.25
44
+ },
45
+ patterns: [
46
+ 'diagonal',
47
+ 'dots',
48
+ 'triangles',
49
+ 'hatch',
50
+ 'lattice',
51
+ 'swell',
52
+ 'checkerboard',
53
+ 'waves',
54
+ 'petals',
55
+ 'brick',
56
+ 'diamonds',
57
+ 'tile',
58
+ 'scales',
59
+ 'circles',
60
+ 'pip',
61
+ 'rings',
62
+ 'chevrons',
63
+ 'shards',
64
+ 'wedge',
65
+ 'argyle',
66
+ 'shell'
67
+ ],
68
+ symbols: ['circle', 'square', 'triangle', 'diamond', 'cross', 'star']
34
69
  }
35
70
 
36
71
  /**
@@ -40,15 +75,15 @@ export const defaultPreset = {
40
75
  * @returns {typeof defaultPreset}
41
76
  */
42
77
  export function createChartPreset(overrides = {}) {
43
- return {
44
- ...defaultPreset,
45
- ...overrides,
46
- shades: overrides.shades
47
- ? {
48
- light: { ...defaultPreset.shades.light, ...overrides.shades.light },
49
- dark: { ...defaultPreset.shades.dark, ...overrides.shades.dark }
50
- }
51
- : defaultPreset.shades,
52
- opacity: { ...defaultPreset.opacity, ...overrides.opacity }
53
- }
78
+ return {
79
+ ...defaultPreset,
80
+ ...overrides,
81
+ shades: overrides.shades
82
+ ? {
83
+ light: { ...defaultPreset.shades.light, ...overrides.shades.light },
84
+ dark: { ...defaultPreset.shades.dark, ...overrides.shades.dark }
85
+ }
86
+ : defaultPreset.shades,
87
+ opacity: { ...defaultPreset.opacity, ...overrides.opacity }
88
+ }
54
89
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Screen span of a [lo, hi] range on one axis. A band category contributes its whole band,
3
+ * so `x={['a', 'b']}` shades both columns edge to edge. Omitted → the full range; a `null`
4
+ * end runs to that edge of the axis, so `[p75, null]` is "the top quartile and beyond".
5
+ *
6
+ * @param {AnyScale} scale
7
+ * @param {unknown[] | undefined} range
8
+ * @returns {[number, number]}
9
+ */
10
+ export function spanOf(scale: AnyScale, range: unknown[] | undefined): [number, number];
11
+ /**
12
+ * Position of a single data value — band categories resolve to the band centre, which is
13
+ * where a point in that category is drawn.
14
+ *
15
+ * @param {AnyScale} scale
16
+ * @param {unknown} v
17
+ */
18
+ export function positionOf(scale: AnyScale, v: unknown): number;
19
+ /**
20
+ * The region's vertices in (u, v) plot coordinates — before orientation `place`.
21
+ *
22
+ * `points` (a polygon, three or more vertices) wins over `x`/`y` ranges. Fewer than three
23
+ * points is not an area, so it resolves to nothing rather than a degenerate sliver.
24
+ *
25
+ * @param {{ x?: unknown[], y?: unknown[], points?: unknown[][] }} shape
26
+ * @param {AnyScale} xScale
27
+ * @param {AnyScale} yScale
28
+ * @returns {Array<[number, number]>}
29
+ */
30
+ export function regionVertices(shape: {
31
+ x?: unknown[];
32
+ y?: unknown[];
33
+ points?: unknown[][];
34
+ }, xScale: AnyScale, yScale: AnyScale): Array<[number, number]>;
35
+ /**
36
+ * Area centroid of a simple polygon — where its label goes. Falls back to the vertex mean for
37
+ * a zero-area (collinear) polygon, which has no area centroid.
38
+ *
39
+ * @param {Array<{ x: number, y: number }>} pts
40
+ * @returns {{ x: number, y: number }}
41
+ */
42
+ export function centroid(pts: Array<{
43
+ x: number;
44
+ y: number;
45
+ }>): {
46
+ x: number;
47
+ y: number;
48
+ };
49
+ /**
50
+ * SVG path data for a closed polygon.
51
+ *
52
+ * @param {Array<{ x: number, y: number }>} pts
53
+ */
54
+ export function polygonPath(pts: Array<{
55
+ x: number;
56
+ y: number;
57
+ }>): string;
58
+ export type AnyScale = {
59
+ (v: unknown): number | undefined;
60
+ bandwidth?: () => number;
61
+ range: () => number[];
62
+ domain: () => unknown[];
63
+ };
@@ -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
+ }
@@ -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';