@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
@@ -1,7 +1,8 @@
1
1
  import { scaleBand } from 'd3-scale'
2
2
  import { stack, stackOffsetExpand } from 'd3-shape'
3
3
  import { toPatternId } from '../../lib/brewing/patterns.js'
4
- import { isLiteralColor, literalColor, markEntry } from '../../lib/brewing/colors.js'
4
+ import { literalColor, markEntry } from '../../lib/brewing/colors.js'
5
+ import { subBandFields } from '../../lib/plot/stacking.js'
5
6
 
6
7
  /**
7
8
  * Returns a band scale suitable for bar x-positioning.
@@ -16,24 +17,6 @@ function ensureBandX(xScale, data, xField) {
16
17
  return scaleBand().domain(domain).range([r0, r1]).padding(0.2)
17
18
  }
18
19
 
19
- /**
20
- * Returns the sub-band fields: distinct non-x fields among [group, color, pattern].
21
- * These are the fields that cause multiple bars within a single x-band (dodge/grouping).
22
- * `group` (the explicit grouping channel) takes precedence as the first sub-band.
23
- */
24
- function subBandFields(channels) {
25
- const { x: xf, group: gf, color: cf, pattern: pf } = channels
26
- const seen = new Set()
27
- const out = []
28
- for (const f of [gf, cf, pf]) {
29
- if (f && f !== xf && !seen.has(f) && !isLiteralColor(f)) {
30
- seen.add(f)
31
- out.push(f)
32
- }
33
- }
34
- return out
35
- }
36
-
37
20
  /**
38
21
  * The one bar builder. Expresses each bar in abstract (category, value) space and maps it to
39
22
  * screen via `place()`, so it renders vertical or horizontal (flip) with the same code.
@@ -141,7 +124,17 @@ export function buildBars(
141
124
  })
142
125
  }
143
126
 
144
- export function buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns, place = (x, y) => ({ x, y }), normalize = false) {
127
+ export function buildStackedBars(
128
+ data,
129
+ channels,
130
+ xScale,
131
+ yScale,
132
+ colors,
133
+ innerHeight,
134
+ patterns,
135
+ place = (x, y) => ({ x, y }),
136
+ normalize = false
137
+ ) {
145
138
  const { x: xf, y: yf, color: cf, pattern: pf } = channels
146
139
  const lit = literalColor(cf)
147
140
  const field = lit ? undefined : cf
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared by the geoms that summarise GROUPS of points (Hull, Contour) rather than drawing one
3
+ * mark per row: bucket rows by the group field, and place each row on screen — or drop it
4
+ * when it has no usable coordinate, which is a normal state in a partially-measured dataset.
5
+ */
6
+ /**
7
+ * Screen position of one value on a scale, or null when the row has no usable coordinate
8
+ * (null / empty / a category the scale does not know / NaN). A band category resolves to its
9
+ * centre, where a point in that category is drawn.
10
+ *
11
+ * @param {any} scale
12
+ * @param {unknown} v
13
+ * @returns {number | null}
14
+ */
15
+ export function coordOf(scale: any, v: unknown): number | null;
16
+ /**
17
+ * Rows bucketed by `field`, in first-seen order. One bucket (key `null`) without a field.
18
+ *
19
+ * @param {Record<string, unknown>[]} data
20
+ * @param {string | undefined} field
21
+ * @returns {Map<unknown, Record<string, unknown>[]>}
22
+ */
23
+ export function groupRows(data: Record<string, unknown>[], field: string | undefined): Map<unknown, Record<string, unknown>[]>;
24
+ /**
25
+ * Screen points for rows, after orientation `place`, skipping rows with no coordinate.
26
+ *
27
+ * @param {Record<string, unknown>[]} rows
28
+ * @param {any} plot
29
+ * @param {{ x?: string, y?: string }} channels
30
+ * @returns {Array<[number, number]>}
31
+ */
32
+ export function screenPoints(rows: Record<string, unknown>[], plot: any, channels: {
33
+ x?: string;
34
+ y?: string;
35
+ }): Array<[number, number]>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared by the geoms that summarise GROUPS of points (Hull, Contour) rather than drawing one
3
+ * mark per row: bucket rows by the group field, and place each row on screen — or drop it
4
+ * when it has no usable coordinate, which is a normal state in a partially-measured dataset.
5
+ */
6
+
7
+ /**
8
+ * Screen position of one value on a scale, or null when the row has no usable coordinate
9
+ * (null / empty / a category the scale does not know / NaN). A band category resolves to its
10
+ * centre, where a point in that category is drawn.
11
+ *
12
+ * @param {any} scale
13
+ * @param {unknown} v
14
+ * @returns {number | null}
15
+ */
16
+ export function coordOf(scale, v) {
17
+ if (v === null || v === undefined || v === '') return null
18
+ const s = scale(v)
19
+ if (s === null || s === undefined || Number.isNaN(s)) return null
20
+ return typeof scale.bandwidth === 'function' ? s + scale.bandwidth() / 2 : s
21
+ }
22
+
23
+ /**
24
+ * Rows bucketed by `field`, in first-seen order. One bucket (key `null`) without a field.
25
+ *
26
+ * @param {Record<string, unknown>[]} data
27
+ * @param {string | undefined} field
28
+ * @returns {Map<unknown, Record<string, unknown>[]>}
29
+ */
30
+ export function groupRows(data, field) {
31
+ const groups = new Map()
32
+ for (const row of data) {
33
+ const key = field ? row[field] : null
34
+ if (!groups.has(key)) groups.set(key, [])
35
+ groups.get(key).push(row)
36
+ }
37
+ return groups
38
+ }
39
+
40
+ /**
41
+ * Screen points for rows, after orientation `place`, skipping rows with no coordinate.
42
+ *
43
+ * @param {Record<string, unknown>[]} rows
44
+ * @param {any} plot
45
+ * @param {{ x?: string, y?: string }} channels
46
+ * @returns {Array<[number, number]>}
47
+ */
48
+ export function screenPoints(rows, plot, channels) {
49
+ /** @type {Array<[number, number]>} */
50
+ const pts = []
51
+ for (const row of rows) {
52
+ const u = coordOf(plot.xScale, row[channels.x ?? ''])
53
+ const v = coordOf(plot.yScale, row[channels.y ?? ''])
54
+ if (u === null || v === null) continue
55
+ const p = plot.place(u, v)
56
+ pts.push([p.x, p.y])
57
+ }
58
+ return pts
59
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SVG path data for a GeoJSON MultiPolygon's coordinates — every ring of every polygon a
3
+ * closed subpath, so holes cut out under the default nonzero/evenodd rules d3-contour's
4
+ * winding produces.
5
+ *
6
+ * @param {number[][][][]} coordinates
7
+ */
8
+ export function multiPolygonPath(coordinates: number[][][][]): string;
9
+ /**
10
+ * Kernel-density contours per group of the `fill` (else `color`) field.
11
+ *
12
+ * Density is estimated in SCREEN space over the plot area, so `bandwidth` is in pixels — the
13
+ * unit a reader judges smoothness in — and the result is the same at any data scale. Level 0
14
+ * is the outermost (lowest-density) ring; filled bands ramp opacity toward `alpha` at the
15
+ * densest core, so pile-ups read darkest.
16
+ *
17
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
18
+ */
19
+ export function buildContourMarks({ data, plot, channels, options, alpha, type }: {
20
+ data: any[];
21
+ plot: any;
22
+ channels: any;
23
+ options?: any;
24
+ alpha?: number;
25
+ type?: string;
26
+ }): any[];
@@ -0,0 +1,67 @@
1
+ import { contourDensity } from 'd3-contour'
2
+ import { resolveFillStroke, resolveAlpha } from '../aesthetics.js'
3
+ import { groupRows, screenPoints } from '../grouping.js'
4
+
5
+ /**
6
+ * SVG path data for a GeoJSON MultiPolygon's coordinates — every ring of every polygon a
7
+ * closed subpath, so holes cut out under the default nonzero/evenodd rules d3-contour's
8
+ * winding produces.
9
+ *
10
+ * @param {number[][][][]} coordinates
11
+ */
12
+ export function multiPolygonPath(coordinates) {
13
+ const fmt = (/** @type {number} */ n) => Math.round(n * 100) / 100
14
+ return coordinates
15
+ .flatMap((polygon) =>
16
+ polygon.map((ring) => `M${ring.map(([x, y]) => `${fmt(x)},${fmt(y)}`).join('L')}Z`)
17
+ )
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Kernel-density contours per group of the `fill` (else `color`) field.
23
+ *
24
+ * Density is estimated in SCREEN space over the plot area, so `bandwidth` is in pixels — the
25
+ * unit a reader judges smoothness in — and the result is the same at any data scale. Level 0
26
+ * is the outermost (lowest-density) ring; filled bands ramp opacity toward `alpha` at the
27
+ * densest core, so pile-ups read darkest.
28
+ *
29
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
30
+ */
31
+ export function buildContourMarks({ data, plot, channels, options = {}, alpha, type = 'contour' }) {
32
+ if (!data?.length || !plot.xScale || !plot.yScale) return []
33
+ const groupField = channels.fill ?? channels.color
34
+ const a = resolveAlpha(alpha, type, plot.chartPreset)
35
+ const w = Math.max(1, Math.round(plot.innerWidth))
36
+ const h = Math.max(1, Math.round(plot.innerHeight))
37
+
38
+ const marks = []
39
+ for (const [key, rows] of groupRows(data, groupField)) {
40
+ const pts = screenPoints(rows, plot, channels)
41
+ if (pts.length === 0) continue
42
+ const density = contourDensity()
43
+ .x((d) => d[0])
44
+ .y((d) => d[1])
45
+ .size([w, h])
46
+ .bandwidth(options.bandwidth ?? 20)
47
+ .thresholds(options.thresholds ?? 8)(pts)
48
+ const { fill, stroke } = resolveFillStroke(
49
+ rows[0],
50
+ { fill: channels.fill, color: channels.color },
51
+ plot.colors
52
+ )
53
+ density.forEach((level, i) => {
54
+ marks.push({
55
+ key: `contour-${String(key)}-${i}`,
56
+ group: key,
57
+ level: i,
58
+ value: level.value,
59
+ d: multiPolygonPath(level.coordinates),
60
+ fill,
61
+ stroke,
62
+ fillAlpha: (a * (i + 1)) / density.length
63
+ })
64
+ })
65
+ }
66
+ return marks
67
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One outline per group of the `fill` (else `color`) channel — ggplot's discrete-colour
3
+ * grouping. The hull is computed in SCREEN space after `place`, so it is convex as drawn,
4
+ * whatever the scales (a hull in data space is not convex on a log axis).
5
+ *
6
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
7
+ */
8
+ export function buildHullMarks({ data, plot, channels, alpha, type }: {
9
+ data: any[];
10
+ plot: any;
11
+ channels: any;
12
+ options?: any;
13
+ alpha?: number;
14
+ type?: string;
15
+ }): {
16
+ key: string;
17
+ group: unknown;
18
+ d: string;
19
+ fill: string;
20
+ alpha: number;
21
+ at: {
22
+ x: number;
23
+ y: number;
24
+ };
25
+ count: number;
26
+ }[];
@@ -0,0 +1,36 @@
1
+ import { convexHull, hullPath } from '../../../lib/hull.js'
2
+ import { centroid } from '../../../lib/region.js'
3
+ import { resolveFillStroke, resolveAlpha } from '../aesthetics.js'
4
+ import { groupRows, screenPoints } from '../grouping.js'
5
+
6
+ /**
7
+ * One outline per group of the `fill` (else `color`) channel — ggplot's discrete-colour
8
+ * grouping. The hull is computed in SCREEN space after `place`, so it is convex as drawn,
9
+ * whatever the scales (a hull in data space is not convex on a log axis).
10
+ *
11
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
12
+ */
13
+ export function buildHullMarks({ data, plot, channels, alpha, type = 'hull' }) {
14
+ const { xScale, yScale, colors } = plot
15
+ if (!data?.length || !xScale || !yScale) return []
16
+ const groupField = channels.fill ?? channels.color
17
+ const a = resolveAlpha(alpha, type, plot.chartPreset)
18
+
19
+ const marks = []
20
+ for (const [key, rows] of groupRows(data, groupField)) {
21
+ const pts = screenPoints(rows, plot, channels)
22
+ if (pts.length === 0) continue
23
+ const hull = convexHull(pts)
24
+ const { fill } = resolveFillStroke(rows[0], { fill: groupField }, colors)
25
+ marks.push({
26
+ key: `hull-${String(key)}`,
27
+ group: key,
28
+ d: hullPath(hull),
29
+ fill,
30
+ alpha: a,
31
+ at: centroid(hull.map(([x, y]) => ({ x, y }))),
32
+ count: rows.length
33
+ })
34
+ }
35
+ return marks
36
+ }
@@ -1,11 +1,19 @@
1
1
  import { resolveAlpha } from '../aesthetics.js'
2
+ import { runningSpans } from '../../../lib/plot/running.js'
2
3
 
3
4
  /**
4
5
  * Build renderable waterfall bars. Bar color is semantic (positive/negative/total from
5
6
  * options), not the shared palette. Adds fixed `alpha` and carries connector anchors.
6
7
  * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
7
8
  */
8
- export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha, type = 'waterfall' }) {
9
+ export function buildWaterfallMarks({
10
+ data,
11
+ plot,
12
+ channels,
13
+ options = {},
14
+ alpha,
15
+ type = 'waterfall'
16
+ }) {
9
17
  const { xScale, yScale } = plot
10
18
  if (!data?.length || !xScale || !yScale) return []
11
19
 
@@ -16,28 +24,15 @@ export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha,
16
24
  const a = resolveAlpha(alpha, type, plot.chartPreset)
17
25
 
18
26
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
19
- let cumulative = 0
27
+ const spans = runningSpans(data, channels.y ?? '', totalField)
20
28
 
21
29
  return data.map((d, i) => {
22
30
  const xVal = d[channels.x ?? '']
23
- const yVal = Number(d[channels.y ?? ''])
24
- const isTotal = totalField ? Boolean(d[totalField]) : false
31
+ const { lo, hi, total, isTotal, delta } = spans[i]
25
32
  const xPos = xScale(xVal) ?? 0
26
- let barTop
27
- let barBottom
28
- let fill
29
-
30
- if (isTotal) {
31
- barTop = yScale(Math.max(0, cumulative)) ?? 0
32
- barBottom = yScale(0) ?? 0
33
- fill = totalColor
34
- } else {
35
- const start = cumulative
36
- cumulative += yVal
37
- barTop = yScale(Math.max(start, cumulative)) ?? 0
38
- barBottom = yScale(Math.min(start, cumulative)) ?? 0
39
- fill = yVal >= 0 ? positiveColor : negativeColor
40
- }
33
+ const barTop = yScale(hi) ?? 0
34
+ const barBottom = yScale(lo) ?? 0
35
+ const fill = isTotal ? totalColor : delta >= 0 ? positiveColor : negativeColor
41
36
 
42
37
  const c1 = plot.place(xPos, barTop)
43
38
  const c2 = plot.place(xPos + bw, barBottom)
@@ -51,7 +46,7 @@ export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha,
51
46
  alpha: a,
52
47
  bandStart: xPos,
53
48
  bandEnd: xPos + bw,
54
- cumY: yScale(cumulative) ?? 0,
49
+ cumY: yScale(total) ?? 0,
55
50
  data: d
56
51
  }
57
52
  })
package/dist/index.d.ts CHANGED
@@ -19,6 +19,9 @@ export namespace Plot {
19
19
  export { Ribbon };
20
20
  export { Waterfall };
21
21
  export { Rule };
22
+ export { Region };
23
+ export { Hull };
24
+ export { Contour };
22
25
  export { Radar };
23
26
  }
24
27
  export namespace PlotLayers { }
@@ -45,6 +48,9 @@ export { default as GeomHighlight } from "./geoms/Highlight.svelte";
45
48
  export { default as GeomTrend } from "./geoms/Trend.svelte";
46
49
  export { default as GeomJitter } from "./geoms/Jitter.svelte";
47
50
  export { default as GeomRule } from "./geoms/Rule.svelte";
51
+ export { default as GeomRegion } from "./geoms/Region.svelte";
52
+ export { default as GeomHull } from "./geoms/Hull.svelte";
53
+ export { default as GeomContour } from "./geoms/Contour.svelte";
48
54
  export { default as Chart } from "./Chart.svelte";
49
55
  export { default as Sparkline } from "./Sparkline.svelte";
50
56
  export { default as Spark } from "./Spark.svelte";
@@ -83,6 +89,9 @@ import Hexbin from './geoms/Hexbin.svelte';
83
89
  import Ribbon from './geoms/Ribbon.svelte';
84
90
  import Waterfall from './geoms/Waterfall.svelte';
85
91
  import Rule from './geoms/Rule.svelte';
92
+ import Region from './geoms/Region.svelte';
93
+ import Hull from './geoms/Hull.svelte';
94
+ import Contour from './geoms/Contour.svelte';
86
95
  import Radar from './geoms/Radar.svelte';
87
96
  export { createChartPreset, defaultPreset } from "./lib/preset.js";
88
97
  export { SparkState, GEOM_CONTRACT } from "./SparkState.svelte.js";
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ import Hexbin from './geoms/Hexbin.svelte'
18
18
  import Ribbon from './geoms/Ribbon.svelte'
19
19
  import Waterfall from './geoms/Waterfall.svelte'
20
20
  import Rule from './geoms/Rule.svelte'
21
+ import Region from './geoms/Region.svelte'
22
+ import Hull from './geoms/Hull.svelte'
23
+ import Contour from './geoms/Contour.svelte'
21
24
  import Radar from './geoms/Radar.svelte'
22
25
 
23
26
  // Composable Plot primitives — use as <Plot.Root>, <Plot.Axis>, <Plot.Bar>, etc.
@@ -42,6 +45,9 @@ export const Plot = {
42
45
  Ribbon,
43
46
  Waterfall,
44
47
  Rule,
48
+ Region,
49
+ Hull,
50
+ Contour,
45
51
  Radar
46
52
  }
47
53
 
@@ -77,6 +83,9 @@ export { default as GeomHighlight } from './geoms/Highlight.svelte'
77
83
  export { default as GeomTrend } from './geoms/Trend.svelte'
78
84
  export { default as GeomJitter } from './geoms/Jitter.svelte'
79
85
  export { default as GeomRule } from './geoms/Rule.svelte'
86
+ export { default as GeomRegion } from './geoms/Region.svelte'
87
+ export { default as GeomHull } from './geoms/Hull.svelte'
88
+ export { default as GeomContour } from './geoms/Contour.svelte'
80
89
 
81
90
  // Export standalone components
82
91
  export { default as Chart } from './Chart.svelte'
@@ -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
+ }
@@ -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;