@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.
- package/README.md +13 -0
- package/dist/Plot/DataTable.svelte +54 -0
- package/dist/Plot/DataTable.svelte.d.ts +12 -0
- package/dist/Plot/SpecGeoms.svelte +78 -0
- package/dist/Plot/SpecGeoms.svelte.d.ts +11 -0
- package/dist/Plot.svelte +47 -151
- package/dist/Plot.svelte.d.ts +5 -0
- 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/Arc.svelte +1 -1
- package/dist/geoms/Bar.svelte +23 -32
- 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/arc.d.ts +1 -0
- package/dist/geoms/lib/marks/contour.d.ts +26 -0
- package/dist/geoms/lib/marks/contour.js +67 -0
- package/dist/geoms/lib/marks/hull.d.ts +26 -0
- package/dist/geoms/lib/marks/hull.js +36 -0
- package/dist/geoms/lib/marks/waterfall.js +15 -20
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lib/abline.d.ts +41 -0
- package/dist/lib/abline.js +51 -0
- package/dist/lib/brewing/marks/arcs.d.ts +6 -1
- package/dist/lib/brewing/marks/arcs.js +9 -1
- package/dist/lib/brewing/polar.js +8 -3
- 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/spec.d.ts +59 -0
- package/dist/lib/plot/spec.js +131 -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,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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 === '
|
|
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
|
-
|
|
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];
|
package/dist/lib/hull.js
ADDED
|
@@ -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 {};
|