@rokkit/chart 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +13 -0
  2. package/dist/Plot/DataTable.svelte +54 -0
  3. package/dist/Plot/DataTable.svelte.d.ts +12 -0
  4. package/dist/Plot/SpecGeoms.svelte +78 -0
  5. package/dist/Plot/SpecGeoms.svelte.d.ts +11 -0
  6. package/dist/Plot.svelte +47 -151
  7. package/dist/Plot.svelte.d.ts +5 -0
  8. package/dist/PlotState.svelte.d.ts +115 -71
  9. package/dist/PlotState.svelte.js +174 -547
  10. package/dist/SparkState.svelte.d.ts +3 -1
  11. package/dist/SparkState.svelte.js +11 -41
  12. package/dist/geoms/Arc.svelte +1 -1
  13. package/dist/geoms/Bar.svelte +23 -32
  14. package/dist/geoms/Contour.svelte +88 -0
  15. package/dist/geoms/Contour.svelte.d.ts +19 -0
  16. package/dist/geoms/Hull.svelte +89 -0
  17. package/dist/geoms/Hull.svelte.d.ts +17 -0
  18. package/dist/geoms/Region.svelte +107 -0
  19. package/dist/geoms/Region.svelte.d.ts +27 -0
  20. package/dist/geoms/Rule.svelte +88 -26
  21. package/dist/geoms/Rule.svelte.d.ts +8 -0
  22. package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
  23. package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
  24. package/dist/geoms/lib/bars.js +13 -20
  25. package/dist/geoms/lib/grouping.d.ts +35 -0
  26. package/dist/geoms/lib/grouping.js +59 -0
  27. package/dist/geoms/lib/marks/arc.d.ts +1 -0
  28. package/dist/geoms/lib/marks/contour.d.ts +26 -0
  29. package/dist/geoms/lib/marks/contour.js +67 -0
  30. package/dist/geoms/lib/marks/hull.d.ts +26 -0
  31. package/dist/geoms/lib/marks/hull.js +36 -0
  32. package/dist/geoms/lib/marks/waterfall.js +15 -20
  33. package/dist/index.d.ts +9 -0
  34. package/dist/index.js +9 -0
  35. package/dist/lib/abline.d.ts +41 -0
  36. package/dist/lib/abline.js +51 -0
  37. package/dist/lib/brewing/marks/arcs.d.ts +6 -1
  38. package/dist/lib/brewing/marks/arcs.js +9 -1
  39. package/dist/lib/brewing/polar.js +8 -3
  40. package/dist/lib/hull.d.ts +18 -0
  41. package/dist/lib/hull.js +64 -0
  42. package/dist/lib/plot/domains.d.ts +61 -0
  43. package/dist/lib/plot/domains.js +130 -0
  44. package/dist/lib/plot/running.d.ts +23 -0
  45. package/dist/lib/plot/running.js +32 -0
  46. package/dist/lib/plot/spec.d.ts +59 -0
  47. package/dist/lib/plot/spec.js +131 -0
  48. package/dist/lib/plot/stacking.d.ts +32 -0
  49. package/dist/lib/plot/stacking.js +34 -0
  50. package/dist/lib/plot/types.d.ts +8 -1
  51. package/dist/lib/plot/types.js +5 -1
  52. package/dist/lib/preset.d.ts +2 -0
  53. package/dist/lib/preset.js +76 -41
  54. package/dist/lib/region.d.ts +63 -0
  55. package/dist/lib/region.js +127 -0
  56. package/dist/state/AestheticState.svelte.d.ts +43 -0
  57. package/dist/state/AestheticState.svelte.js +84 -0
  58. package/dist/state/AxisState.svelte.d.ts +26 -0
  59. package/dist/state/AxisState.svelte.js +56 -0
  60. package/dist/state/ChannelState.svelte.d.ts +34 -0
  61. package/dist/state/ChannelState.svelte.js +67 -0
  62. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  63. package/dist/state/GeomRegistry.svelte.js +86 -0
  64. package/dist/state/InteractionState.svelte.d.ts +30 -0
  65. package/dist/state/InteractionState.svelte.js +69 -0
  66. package/dist/state/OrientationState.svelte.d.ts +34 -0
  67. package/dist/state/OrientationState.svelte.js +64 -0
  68. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  69. package/dist/state/PlotConfig.svelte.js +195 -0
  70. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  71. package/dist/state/PlotFrame.svelte.js +18 -0
  72. package/dist/state/ScaleState.svelte.d.ts +36 -0
  73. package/dist/state/ScaleState.svelte.js +121 -0
  74. package/package.json +5 -3
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The segment of `y = slope·x + intercept` that lies inside a rectangular domain, or null
3
+ * when the line misses it.
4
+ *
5
+ * Clipping in DATA space, not pixels, is what keeps a reference line honest under zoom: the
6
+ * scales' domains are already the visible window, so the same call is correct at any zoom.
7
+ * A line that merely touches a corner returns a zero-length segment rather than null — it is
8
+ * inside the domain, just barely.
9
+ *
10
+ * @param {number} slope
11
+ * @param {number} intercept
12
+ * @param {[number, number]} xDomain
13
+ * @param {[number, number]} yDomain
14
+ * @returns {{ x1: number, y1: number, x2: number, y2: number } | null}
15
+ */
16
+ export function clipAbline(slope: number, intercept: number, xDomain: [number, number], yDomain: [number, number]): {
17
+ x1: number;
18
+ y1: number;
19
+ x2: number;
20
+ y2: number;
21
+ } | null;
22
+ /**
23
+ * Where a sloped line's label goes: its midpoint, rotated to run along it.
24
+ *
25
+ * Not the end, where an axis-aligned rule puts its label — a sloped line's ends are corners
26
+ * of the plot, exactly where points and axis ticks crowd. The angle is folded into
27
+ * (-90°, 90°] so the text never reads upside down, whichever way the line was drawn.
28
+ *
29
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} seg screen coordinates
30
+ * @returns {{ x: number, y: number, angle: number }}
31
+ */
32
+ export function slopeLabel({ x1, y1, x2, y2 }: {
33
+ x1: number;
34
+ y1: number;
35
+ x2: number;
36
+ y2: number;
37
+ }): {
38
+ x: number;
39
+ y: number;
40
+ angle: number;
41
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The segment of `y = slope·x + intercept` that lies inside a rectangular domain, or null
3
+ * when the line misses it.
4
+ *
5
+ * Clipping in DATA space, not pixels, is what keeps a reference line honest under zoom: the
6
+ * scales' domains are already the visible window, so the same call is correct at any zoom.
7
+ * A line that merely touches a corner returns a zero-length segment rather than null — it is
8
+ * inside the domain, just barely.
9
+ *
10
+ * @param {number} slope
11
+ * @param {number} intercept
12
+ * @param {[number, number]} xDomain
13
+ * @param {[number, number]} yDomain
14
+ * @returns {{ x1: number, y1: number, x2: number, y2: number } | null}
15
+ */
16
+ export function clipAbline(slope, intercept, xDomain, yDomain) {
17
+ const [x0, x1] = [Math.min(...xDomain), Math.max(...xDomain)]
18
+ const [y0, y1] = [Math.min(...yDomain), Math.max(...yDomain)]
19
+ const at = (x) => slope * x + intercept
20
+
21
+ if (slope === 0) {
22
+ return intercept < y0 || intercept > y1
23
+ ? null
24
+ : { x1: x0, y1: intercept, x2: x1, y2: intercept }
25
+ }
26
+
27
+ // Where the line crosses the lower and upper y bounds, ordered along x.
28
+ const xAtY0 = (y0 - intercept) / slope
29
+ const xAtY1 = (y1 - intercept) / slope
30
+ const lo = Math.max(x0, Math.min(xAtY0, xAtY1))
31
+ const hi = Math.min(x1, Math.max(xAtY0, xAtY1))
32
+ if (lo > hi) return null
33
+ return { x1: lo, y1: at(lo), x2: hi, y2: at(hi) }
34
+ }
35
+
36
+ /**
37
+ * Where a sloped line's label goes: its midpoint, rotated to run along it.
38
+ *
39
+ * Not the end, where an axis-aligned rule puts its label — a sloped line's ends are corners
40
+ * of the plot, exactly where points and axis ticks crowd. The angle is folded into
41
+ * (-90°, 90°] so the text never reads upside down, whichever way the line was drawn.
42
+ *
43
+ * @param {{ x1: number, y1: number, x2: number, y2: number }} seg screen coordinates
44
+ * @returns {{ x: number, y: number, angle: number }}
45
+ */
46
+ export function slopeLabel({ x1, y1, x2, y2 }) {
47
+ let angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
48
+ if (angle > 90) angle -= 180
49
+ if (angle <= -90) angle += 180
50
+ return { x: (x1 + x2) / 2, y: (y1 + y2) / 2, angle }
51
+ }
@@ -7,7 +7,11 @@
7
7
  * @param {number} height
8
8
  * @param {{ innerRadius?: number }} opts
9
9
  * @param {Map<unknown, string>} [patterns]
10
- * @returns {{ d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
10
+ * `key` is the slice's category — what picks its colour and pattern — so it repeats when rows
11
+ * share a category, and is undefined without a colour field. `id` is unique per slice (the key
12
+ * plus its occurrence), for keyed rendering; stable across updates while categories are unique.
13
+ *
14
+ * @returns {{ id: string, d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
11
15
  */
12
16
  export function buildArcs(data: Record<string, unknown>[], channels: {
13
17
  color?: string;
@@ -16,6 +20,7 @@ export function buildArcs(data: Record<string, unknown>[], channels: {
16
20
  }, colors: Map<any, any>, width: number, height: number, opts?: {
17
21
  innerRadius?: number;
18
22
  }, patterns?: Map<unknown, string>): {
23
+ id: string;
19
24
  d: string | null;
20
25
  fill: string;
21
26
  stroke: string;
@@ -11,7 +11,11 @@ import { literalColor, markEntry } from '../colors.js'
11
11
  * @param {number} height
12
12
  * @param {{ innerRadius?: number }} opts
13
13
  * @param {Map<unknown, string>} [patterns]
14
- * @returns {{ d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
14
+ * `key` is the slice's category — what picks its colour and pattern — so it repeats when rows
15
+ * share a category, and is undefined without a colour field. `id` is unique per slice (the key
16
+ * plus its occurrence), for keyed rendering; stable across updates while categories are unique.
17
+ *
18
+ * @returns {{ id: string, d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
15
19
  */
16
20
  export function buildArcs(data, channels, colors, width, height, opts = {}, patterns) {
17
21
  const { color: lf, y: yf } = channels
@@ -32,6 +36,7 @@ export function buildArcs(data, channels, colors, width, height, opts = {}, patt
32
36
  // Label radius: midpoint between inner and outer (or 70% out for solid pie)
33
37
  const labelRadius = innerRadius > 0 ? (innerRadius + radius) / 2 : radius * 0.65
34
38
  const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius)
39
+ const seen = new Map()
35
40
  return slices.map((slice, i) => {
36
41
  // With a literal color there's no category field, so key each slice by index to keep
37
42
  // them distinct (patterns, which are field-keyed, don't apply to a single literal fill).
@@ -41,7 +46,10 @@ export function buildArcs(data, channels, colors, width, height, opts = {}, patt
41
46
  !lit && key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
42
47
  const pct = Math.round(((slice.endAngle - slice.startAngle) / total) * 100)
43
48
  const [cx, cy] = labelArc.centroid(slice)
49
+ const occurrence = seen.get(key) ?? 0
50
+ seen.set(key, occurrence + 1)
44
51
  return {
52
+ id: `${String(key)}#${occurrence}`,
45
53
  d: arcGen(slice),
46
54
  fill: colorEntry.fill,
47
55
  stroke: colorEntry.stroke,
@@ -43,11 +43,15 @@
43
43
  * Fills in the derived fields shared by every axis, regardless of which of the two
44
44
  * accepted shorthand forms it started from.
45
45
  *
46
- * @param {string|AxisSpec} entry
46
+ * Only an object is an AxisSpec; any other value — a string, or a number / boolean category
47
+ * read from the data — is the axis key itself. (Treating every non-string as a spec spread a
48
+ * numeric category into `{}`, keying every numeric axis `undefined`.)
49
+ *
50
+ * @param {string|number|AxisSpec} entry
47
51
  * @returns {ResolvedAxis}
48
52
  */
49
53
  function normaliseAxis(entry) {
50
- const spec = typeof entry === 'string' ? { key: entry } : entry
54
+ const spec = typeof entry === 'object' ? entry : { key: entry }
51
55
  const label = spec.label ?? spec.key
52
56
  return {
53
57
  ...spec,
@@ -101,7 +105,8 @@ function axisKeysInData(data, axisField) {
101
105
  const seen = new Set()
102
106
  for (const row of data) {
103
107
  const key = row?.[axisField]
104
- if (key !== undefined) seen.add(key)
108
+ // A missing category — undefined or null — names no axis.
109
+ if (key !== undefined && key !== null) seen.add(key)
105
110
  }
106
111
  return [...seen]
107
112
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hull vertices, counter-clockwise in a y-up frame, starting from the lowest-x point.
3
+ * Collinear edge points are dropped; duplicates collapse; one or two distinct points come
4
+ * back as-is (a dot, a segment).
5
+ *
6
+ * @param {Pt[]} points
7
+ * @returns {Pt[]}
8
+ */
9
+ export function convexHull(points: Pt[]): Pt[];
10
+ /**
11
+ * Closed SVG path through hull vertices. One vertex is a zero-length subpath and two are a
12
+ * segment — with a round cap and a wide stroke those render as a dot and a capsule, which is
13
+ * how `Hull` pads every group, whatever its size, with one element.
14
+ *
15
+ * @param {Pt[]} hull
16
+ */
17
+ export function hullPath(hull: Pt[]): string;
18
+ export type Pt = [number, number];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Convex hull (Andrew's monotone chain), O(n log n), and its SVG path.
3
+ *
4
+ * Hand-rolled rather than `d3-polygon` because the degenerate cases matter here and d3 returns
5
+ * null for them: a group with one or two members is still a group, and an outline that
6
+ * silently disappears for a small package is the same lie as an edge that disappears for an
7
+ * unresolved call.
8
+ */
9
+
10
+ /** @typedef {[number, number]} Pt */
11
+
12
+ /** z of (b - a) × (c - a): > 0 is a left (counter-clockwise) turn. */
13
+ const cross = (/** @type {Pt} */ a, /** @type {Pt} */ b, /** @type {Pt} */ c) =>
14
+ (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
15
+
16
+ /**
17
+ * Hull vertices, counter-clockwise in a y-up frame, starting from the lowest-x point.
18
+ * Collinear edge points are dropped; duplicates collapse; one or two distinct points come
19
+ * back as-is (a dot, a segment).
20
+ *
21
+ * @param {Pt[]} points
22
+ * @returns {Pt[]}
23
+ */
24
+ export function convexHull(points) {
25
+ const seen = new Set()
26
+ const pts = []
27
+ for (const p of points) {
28
+ const key = `${p[0]},${p[1]}`
29
+ if (!seen.has(key)) {
30
+ seen.add(key)
31
+ pts.push(p)
32
+ }
33
+ }
34
+ pts.sort((a, b) => a[0] - b[0] || a[1] - b[1])
35
+ if (pts.length < 3) return pts
36
+
37
+ /** @param {Pt[]} seq */
38
+ const chain = (seq) => {
39
+ /** @type {Pt[]} */
40
+ const out = []
41
+ for (const p of seq) {
42
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop()
43
+ out.push(p)
44
+ }
45
+ out.pop()
46
+ return out
47
+ }
48
+ const hull = [...chain(pts), ...chain([...pts].reverse())]
49
+ // All points collinear: both chains collapse to the two extremes.
50
+ return hull.length < 2 ? [pts[0], pts[pts.length - 1]] : hull
51
+ }
52
+
53
+ /**
54
+ * Closed SVG path through hull vertices. One vertex is a zero-length subpath and two are a
55
+ * segment — with a round cap and a wide stroke those render as a dot and a capsule, which is
56
+ * how `Hull` pads every group, whatever its size, with one element.
57
+ *
58
+ * @param {Pt[]} hull
59
+ */
60
+ export function hullPath(hull) {
61
+ if (hull.length === 0) return ''
62
+ const fmt = (/** @type {number} */ n) => Math.round(n * 1000) / 1000
63
+ return `M${hull.map(([x, y]) => `${fmt(x)},${fmt(y)}`).join('L')}Z`
64
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Band categories ordered by their summed value — "sort bars by size". Null when not sorting.
3
+ *
4
+ * @param {Record<string, unknown>[][]} datasets - every geom's rows
5
+ * @param {string | undefined} xf
6
+ * @param {string | undefined} yf
7
+ * @param {'asc' | 'desc' | undefined} sort
8
+ */
9
+ export function sortedBandDomain(datasets: Record<string, unknown>[][], xf: string | undefined, yf: string | undefined, sort: "asc" | "desc" | undefined): any[] | null;
10
+ /**
11
+ * A box/violin spans its whiskers (`iqr_min` / `iqr_max`) and every outlier.
12
+ *
13
+ * @param {Record<string, any>[]} rows - box-stat rows
14
+ */
15
+ export function boxDomain(rows: Record<string, any>[]): number[] | null;
16
+ /**
17
+ * A stacked bar reaches its column total; a 100%-filled one reaches 1.
18
+ *
19
+ * Summed per (x, stack key), keeping the LAST value per key — the builder's own lookup does the
20
+ * same, so rows sharing a key under an identity stat do not double-count. The stack key is the
21
+ * builder's (`stackFieldOf`); with none, the builder draws plain bars, so there is no total to
22
+ * size to and the normal extent applies (null).
23
+ *
24
+ * @param {Record<string, unknown>[]} rows
25
+ * @param {{ x?: string, y?: string, group?: string, fill?: string, color?: string, pattern?: string }} channels
26
+ * @param {'stack' | 'fill' | undefined} position
27
+ */
28
+ export function stackDomain(rows: Record<string, unknown>[], channels: {
29
+ x?: string;
30
+ y?: string;
31
+ group?: string;
32
+ fill?: string;
33
+ color?: string;
34
+ pattern?: string;
35
+ }, position: "stack" | "fill" | undefined): number[] | null;
36
+ /**
37
+ * A waterfall spans its running total: each step from where the last ended, a total row from 0.
38
+ *
39
+ * @param {Record<string, unknown>[]} rows
40
+ * @param {string} field
41
+ * @param {string} [totalField] - rows flagged here are totals, drawn from zero
42
+ */
43
+ export function waterfallDomain(rows: Record<string, unknown>[], field: string, totalField?: string): number[] | null;
44
+ /**
45
+ * The value-axis domain the mounted geoms' geometry demands, or null for the plain extent.
46
+ *
47
+ * @param {Array<{ id: string, type: string, options?: Record<string, any> }>} geoms
48
+ * @param {(id: string) => Record<string, unknown>[]} rowsOf - a geom's rows (post-stat)
49
+ * @param {{ x?: string, fill?: string, color?: string, pattern?: string }} channels - effective
50
+ * @param {string} field - the value channel
51
+ */
52
+ export function resolveValueDomain(geoms: Array<{
53
+ id: string;
54
+ type: string;
55
+ options?: Record<string, any>;
56
+ }>, rowsOf: (id: string) => Record<string, unknown>[], channels: {
57
+ x?: string;
58
+ fill?: string;
59
+ color?: string;
60
+ pattern?: string;
61
+ }, field: string): number[] | null;
@@ -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,59 @@
1
+ /**
2
+ * The config `PlotSurface` builds its `PlotState` from.
3
+ *
4
+ * @param {Record<string, any> | undefined} spec
5
+ * @param {Record<string, any>} props - the component's props
6
+ * @param {unknown} chartPreset - the resolved preset (context, else the default)
7
+ */
8
+ export function resolvePlotConfig(spec: Record<string, any> | undefined, props: Record<string, any>, chartPreset: unknown): {
9
+ [k: string]: any;
10
+ };
11
+ /**
12
+ * The chrome around the marks: grid, legend, title, summary, axis labels, and the fields the
13
+ * trend / highlight overlays draw on.
14
+ *
15
+ * `grid`: `false` hides it; `true` is 'auto'; `'x' | 'y' | 'both'` names the lines.
16
+ *
17
+ * @param {Record<string, any> | undefined} spec
18
+ * @param {Record<string, any>} props
19
+ */
20
+ export function resolveChrome(spec: Record<string, any> | undefined, props: Record<string, any>): {
21
+ showGrid: boolean;
22
+ gridLines: any;
23
+ showLegend: any;
24
+ title: any;
25
+ summary: any;
26
+ overlayX: any;
27
+ overlayY: any;
28
+ xLabel: any;
29
+ yLabel: any;
30
+ };
31
+ /**
32
+ * The accessible table's columns: the spec's channels (deduped — a table's keyed each needs
33
+ * unique names, and `x === color` is normal), else the first row's keys.
34
+ *
35
+ * @param {Record<string, any> | undefined} spec
36
+ * @param {Record<string, unknown>[]} rows
37
+ * @returns {string[]}
38
+ */
39
+ export function tableColumns(spec: Record<string, any> | undefined, rows: Record<string, unknown>[]): string[];
40
+ /**
41
+ * The props for one `spec.geoms` entry: the spec's channels unless the geom names its own,
42
+ * the spec's stack / orientation under the geom's options, and the geom's `props` over all.
43
+ *
44
+ * @param {Record<string, any>} geomSpec
45
+ * @param {Record<string, any> | undefined} spec
46
+ */
47
+ export function specGeomProps(geomSpec: Record<string, any>, spec: Record<string, any> | undefined): any;
48
+ /**
49
+ * The PlotState config fields read straight from spec and/or props, in config order.
50
+ * @type {Array<[string, keyof typeof SOURCES]>}
51
+ */
52
+ export const PLOT_CONFIG_FIELDS: Array<[string, keyof typeof SOURCES]>;
53
+ declare namespace SOURCES {
54
+ function spec(key: any, spec: any): any;
55
+ function prop(key: any, _spec: any, props: any): any;
56
+ function specFirst(key: any, spec: any, props: any): any;
57
+ function propFirst(key: any, spec: any, props: any): any;
58
+ }
59
+ export {};