@rokkit/chart 1.3.6 → 1.3.8

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 (93) hide show
  1. package/dist/AnimatedPlot.svelte +26 -8
  2. package/dist/AnimatedPlot.svelte.d.ts +5 -0
  3. package/dist/Chart.svelte +1 -1
  4. package/dist/FacetPlot.svelte +1 -1
  5. package/dist/Plot/Axis.svelte +21 -10
  6. package/dist/Plot/Grid.svelte +32 -13
  7. package/dist/Plot/Grid.svelte.d.ts +6 -1
  8. package/dist/Plot/Root.svelte +64 -46
  9. package/dist/Plot/Root.svelte.d.ts +27 -2
  10. package/dist/Plot/Timeline.svelte +39 -13
  11. package/dist/Plot/index.d.ts +4 -4
  12. package/dist/Plot/index.js +4 -4
  13. package/dist/Plot.svelte +128 -164
  14. package/dist/Plot.svelte.d.ts +18 -2
  15. package/dist/PlotState.svelte.d.ts +23 -0
  16. package/dist/PlotState.svelte.js +155 -16
  17. package/dist/PlotSurface.svelte +162 -0
  18. package/dist/PlotSurface.svelte.d.ts +25 -0
  19. package/dist/charts/AreaChart.svelte +37 -4
  20. package/dist/charts/AreaChart.svelte.d.ts +12 -2
  21. package/dist/charts/BarChart.svelte +32 -3
  22. package/dist/charts/BarChart.svelte.d.ts +10 -1
  23. package/dist/charts/BoxPlot.svelte +4 -2
  24. package/dist/charts/BoxPlot.svelte.d.ts +1 -0
  25. package/dist/charts/BubbleChart.svelte +34 -3
  26. package/dist/charts/BubbleChart.svelte.d.ts +11 -1
  27. package/dist/charts/LineChart.svelte +35 -4
  28. package/dist/charts/LineChart.svelte.d.ts +11 -2
  29. package/dist/charts/PieChart.svelte +1 -1
  30. package/dist/charts/ScatterPlot.svelte +32 -3
  31. package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
  32. package/dist/charts/ViolinPlot.svelte +4 -2
  33. package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
  34. package/dist/examples/BarChartExample.svelte +1 -1
  35. package/dist/geoms/Arc.svelte +3 -2
  36. package/dist/geoms/Area.svelte +20 -3
  37. package/dist/geoms/Bar.svelte +57 -13
  38. package/dist/geoms/Box.svelte +131 -76
  39. package/dist/geoms/Box.svelte.d.ts +7 -0
  40. package/dist/geoms/Candlestick.svelte +17 -11
  41. package/dist/geoms/Highlight.svelte +105 -0
  42. package/dist/geoms/Highlight.svelte.d.ts +10 -0
  43. package/dist/geoms/Jitter.svelte +82 -0
  44. package/dist/geoms/Jitter.svelte.d.ts +15 -0
  45. package/dist/geoms/Line.svelte +26 -9
  46. package/dist/geoms/Point.svelte +37 -11
  47. package/dist/geoms/Rule.svelte +113 -0
  48. package/dist/geoms/Rule.svelte.d.ts +16 -0
  49. package/dist/geoms/Trend.svelte +66 -0
  50. package/dist/geoms/Trend.svelte.d.ts +12 -0
  51. package/dist/geoms/Violin.svelte +30 -6
  52. package/dist/geoms/Violin.svelte.d.ts +5 -0
  53. package/dist/geoms/Waterfall.svelte +16 -9
  54. package/dist/geoms/lib/areas.d.ts +6 -2
  55. package/dist/geoms/lib/areas.js +17 -11
  56. package/dist/geoms/lib/bars.d.ts +18 -3
  57. package/dist/geoms/lib/bars.js +70 -96
  58. package/dist/index.d.ts +30 -4
  59. package/dist/index.js +31 -5
  60. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
  61. package/dist/lib/brewing/brewer.svelte.d.ts +1 -0
  62. package/dist/lib/brewing/marks/arcs.js +7 -1
  63. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  64. package/dist/lib/brewing/marks/boxes.js +32 -11
  65. package/dist/lib/brewing/marks/lines.d.ts +6 -2
  66. package/dist/lib/brewing/marks/lines.js +19 -12
  67. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  68. package/dist/lib/brewing/marks/swarm.js +125 -0
  69. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  70. package/dist/lib/brewing/marks/violins.js +41 -25
  71. package/dist/lib/brewing/stats.d.ts +3 -1
  72. package/dist/lib/brewing/stats.js +28 -11
  73. package/dist/lib/highlight.d.ts +10 -0
  74. package/dist/lib/highlight.js +43 -0
  75. package/dist/lib/plot/scales.js +12 -7
  76. package/dist/lib/plot/types.d.ts +9 -1
  77. package/dist/lib/plot/types.js +3 -1
  78. package/dist/lib/scale.d.ts +10 -0
  79. package/dist/lib/scale.js +14 -0
  80. package/dist/lib/select.d.ts +22 -0
  81. package/dist/lib/select.js +22 -0
  82. package/dist/lib/trend.d.ts +27 -0
  83. package/dist/lib/trend.js +113 -0
  84. package/dist/patterns/README.md +1 -1
  85. package/package.json +1 -1
  86. package/dist/Plot/Area.svelte +0 -54
  87. package/dist/Plot/Area.svelte.d.ts +0 -14
  88. package/dist/Plot/Bar.svelte +0 -69
  89. package/dist/Plot/Bar.svelte.d.ts +0 -11
  90. package/dist/Plot/Line.svelte +0 -54
  91. package/dist/Plot/Line.svelte.d.ts +0 -13
  92. package/dist/Plot/Point.svelte +0 -58
  93. package/dist/Plot/Point.svelte.d.ts +0 -13
@@ -0,0 +1,125 @@
1
+ import { ascending } from 'd3-array'
2
+
3
+ /**
4
+ * Deterministic pseudo-random in [0, 1) from a non-negative integer seed.
5
+ * Avoids Math.random so layouts are stable across renders (repo rule).
6
+ *
7
+ * @param {number} n
8
+ * @returns {number}
9
+ */
10
+ function hashUnit(n) {
11
+ const x = Math.sin(n + 1) * 43758.5453
12
+ return x - Math.floor(x)
13
+ }
14
+
15
+ /**
16
+ * Places one point per datum within each x-band. Two layouts:
17
+ * - 'jitter': deterministic horizontal offset seeded by index-in-group.
18
+ * - 'swarm': 1-D beeswarm — sort by y, dodge horizontally to avoid overlap.
19
+ *
20
+ * @param {Object[]} data - raw rows (stat=identity)
21
+ * @param {{ x?: string, y?: string, fill?: string }} channels
22
+ * @param {import('d3-scale').ScaleBand} xScale
23
+ * @param {import('d3-scale').ScaleLinear} yScale
24
+ * @param {Map<unknown, {fill:string, stroke:string}>} colors
25
+ * @param {{ method?: 'jitter'|'swarm', r?: number, side?: 'left'|'right'|'center' }} opts
26
+ * `side` confines points to one half of the band (for raincloud/half-plots).
27
+ * @returns {Array<{ cx:number, cy:number, fill:string, stroke:string, data:Record<string, unknown> }>}
28
+ */
29
+ export function buildSwarm(data, channels, xScale, yScale, colors, opts = {}) {
30
+ const { x: xf, y: yf, fill: ff } = channels
31
+ const { method = 'jitter', r = 2, side = 'center' } = opts
32
+ const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
33
+ // One-sided layouts get the full half-band on their side; centred straddles ±halfBand.
34
+ const halfBand = (bw / 2) * 0.8 // leave a small gutter
35
+
36
+ // Group rows by x value, preserving first-seen order.
37
+ const groups = new Map()
38
+ for (const d of data) {
39
+ const key = d[xf]
40
+ if (!groups.has(key)) groups.set(key, [])
41
+ groups.get(key).push(d)
42
+ }
43
+
44
+ const result = []
45
+ for (const [xVal, rows] of groups) {
46
+ const bandStart = typeof xScale.bandwidth === 'function' ? (xScale(xVal) ?? 0) : (xScale(xVal) ?? 0) - bw / 2
47
+ const center = bandStart + bw / 2
48
+ const offsets =
49
+ method === 'swarm'
50
+ ? swarmOffsets(rows, yf, yScale, r, halfBand, side)
51
+ : rows.map((_, i) => jitterOffset(hashUnit(i), halfBand, side))
52
+
53
+ rows.forEach((d, i) => {
54
+ const fillKey = ff ? d[ff] : xVal
55
+ const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
56
+ result.push({
57
+ data: d,
58
+ cx: center + offsets[i],
59
+ cy: yScale(d[yf]),
60
+ fill: colorEntry.fill,
61
+ stroke: colorEntry.stroke
62
+ })
63
+ })
64
+ }
65
+ return result
66
+ }
67
+
68
+ // Map a [0,1) hash to a horizontal offset, confined to the requested side.
69
+ // center → [-halfBand, halfBand]; right → [0, halfBand]; left → [-halfBand, 0]
70
+ function jitterOffset(u, halfBand, side) {
71
+ if (side === 'right') return u * halfBand
72
+ if (side === 'left') return -u * halfBand
73
+ return (u * 2 - 1) * halfBand
74
+ }
75
+
76
+ /**
77
+ * Computes horizontal offsets for a beeswarm: sort by y, then for each point
78
+ * pick the smallest-magnitude offset that doesn't collide (distance >= 2r) with
79
+ * any already-placed point in the group. When the band is over capacity (more
80
+ * points than fit at 2r spacing), fall back to the least-crowded slot (max
81
+ * separation) so overflow points spread across the band instead of stacking at
82
+ * the center — a best-effort layout, not a hard non-overlap guarantee.
83
+ * `side` confines candidates to one half of the band. Returns offsets in INPUT order.
84
+ */
85
+ function swarmOffsets(rows, yf, yScale, r, halfBand, side = 'center') {
86
+ const order = rows
87
+ .map((d, i) => ({ i, cy: yScale(d[yf]) }))
88
+ .sort((a, b) => ascending(a.cy, b.cy))
89
+ const placed = []
90
+ const offsetByIndex = new Array(rows.length).fill(0)
91
+ const step = r / 2
92
+ const kMax = Math.ceil((2 * halfBand) / step)
93
+
94
+ for (const { i, cy } of order) {
95
+ let chosen = 0
96
+ let placedOne = false
97
+ let best = { offset: 0, sep: -Infinity }
98
+ for (let k = 0; k <= kMax; k++) {
99
+ const candidate =
100
+ side === 'right'
101
+ ? k * step
102
+ : side === 'left'
103
+ ? -k * step
104
+ : k === 0
105
+ ? 0
106
+ : (k % 2 === 1 ? 1 : -1) * Math.ceil(k / 2) * step
107
+ if (Math.abs(candidate) > halfBand) continue
108
+ const sep = placed.length
109
+ ? Math.min(...placed.map((p) => Math.hypot(candidate - p.offset, cy - p.cy)))
110
+ : Infinity
111
+ if (sep >= 2 * r) {
112
+ chosen = candidate
113
+ placedOne = true
114
+ break
115
+ }
116
+ if (sep > best.sep) best = { offset: candidate, sep }
117
+ }
118
+ // Band over capacity: fall back to the least-crowded slot (max separation)
119
+ // rather than stacking every overflow point at the center.
120
+ if (!placedOne) chosen = best.offset
121
+ placed.push({ offset: chosen, cy })
122
+ offsetByIndex[i] = chosen
123
+ }
124
+ return offsetByIndex
125
+ }
@@ -1,20 +1,4 @@
1
- /**
2
- * Builds a closed violin shape path for each group.
3
- * Input rows must have { q1, median, q3, iqr_min, iqr_max } from applyBoxStat.
4
- *
5
- * When `fill` differs from `x`, violins are sub-grouped within each x-band
6
- * (one narrower violin per fill value per x category, like grouped bars).
7
- * Violin body uses the lighter fill shade; outline uses the darker stroke shade.
8
- *
9
- * @param {Object[]} data
10
- * @param {{ x?: string, fill?: string }} channels
11
- * `fill` drives violin interior and outline (defaults to x-field).
12
- * @param {import('d3-scale').ScaleBand} xScale
13
- * @param {import('d3-scale').ScaleLinear} yScale
14
- * @param {Map} colors
15
- * @returns {Array}
16
- */
17
- export function buildViolins(data: Object[], channels: {
18
- x?: string;
19
- fill?: string;
20
- }, xScale: import("d3-scale").ScaleBand<any>, yScale: import("d3-scale").ScaleLinear<any, any, never>, colors: Map<any, any>): any[];
1
+ export function buildViolins(data: any, channels: any, xScale: any, yScale: any, colors: any, place: (x: any, y: any) => {
2
+ x: any;
3
+ y: any;
4
+ }, side: string | undefined, patterns: any): any;
@@ -1,4 +1,5 @@
1
1
  import { line, curveCatmullRom } from 'd3-shape'
2
+ import { toPatternId } from '../patterns.js'
2
3
 
3
4
  // Relative widths at each stat anchor (fraction of max half-width)
4
5
  const DENSITY_AT = { iqr_min: 0.08, q1: 0.55, median: 1.0, q3: 0.55, iqr_max: 0.08 }
@@ -13,17 +14,48 @@ const ANCHOR_ORDER = ['iqr_max', 'q3', 'median', 'q1', 'iqr_min']
13
14
  * Violin body uses the lighter fill shade; outline uses the darker stroke shade.
14
15
  *
15
16
  * @param {Object[]} data
16
- * @param {{ x?: string, fill?: string }} channels
17
+ * @param {{ x?: string, fill?: string, pattern?: string }} channels
17
18
  * `fill` drives violin interior and outline (defaults to x-field).
18
19
  * @param {import('d3-scale').ScaleBand} xScale
19
20
  * @param {import('d3-scale').ScaleLinear} yScale
20
21
  * @param {Map} colors
21
22
  * @returns {Array}
22
23
  */
23
- export function buildViolins(data, channels, xScale, yScale, colors) {
24
- const { x: xf, fill: ff } = channels
24
+ // Build the silhouette points for one violin. `side` selects half vs full:
25
+ // 'right' → density bulges right of the centre line, flat edge at cx
26
+ // 'left' → density bulges left, flat edge at cx
27
+ // 'center' → symmetric (both sides) — the default
28
+ function violinPoints(cx, halfMax, d, yScale, side) {
29
+ const right = ANCHOR_ORDER.map((key) => ({ x: cx + halfMax * DENSITY_AT[key], y: yScale(d[key]) }))
30
+ const left = [...ANCHOR_ORDER].reverse().map((key) => ({
31
+ x: cx - halfMax * DENSITY_AT[key],
32
+ y: yScale(d[key])
33
+ }))
34
+ const topY = yScale(d.iqr_max)
35
+ const botY = yScale(d.iqr_min)
36
+ if (side === 'right') return [...right, { x: cx, y: botY }, { x: cx, y: topY }]
37
+ if (side === 'left') return [...left, { x: cx, y: topY }, { x: cx, y: botY }]
38
+ return [...right, ...left, right[0]]
39
+ }
40
+
41
+ export function buildViolins(
42
+ data,
43
+ channels,
44
+ xScale,
45
+ yScale,
46
+ colors,
47
+ place = (x, y) => ({ x, y }),
48
+ side = 'center',
49
+ patterns
50
+ ) {
51
+ const { x: xf, fill: ff, pattern: pf } = channels
25
52
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
26
53
  const grouped = ff && ff !== xf
54
+ // Texture fill: patternId per violin from the pattern channel value (null = solid fill).
55
+ const patternIdFor = (d) => {
56
+ const key = pf ? d[pf] : null
57
+ return key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
58
+ }
27
59
 
28
60
  const pathGen = line()
29
61
  .x((pt) => pt.x)
@@ -43,21 +75,13 @@ export function buildViolins(data, channels, xScale, yScale, colors) {
43
75
  const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
44
76
  const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
45
77
 
46
- const rightPts = ANCHOR_ORDER.map((key) => ({
47
- x: cx + halfMax * DENSITY_AT[key],
48
- y: yScale(d[key])
49
- }))
50
- const leftPts = [...ANCHOR_ORDER].reverse().map((key) => ({
51
- x: cx - halfMax * DENSITY_AT[key],
52
- y: yScale(d[key])
53
- }))
54
-
55
78
  return {
56
79
  data: d,
57
80
  cx,
58
- d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
81
+ d: pathGen(violinPoints(cx, halfMax, d, yScale, side).map((pt) => place(pt.x, pt.y))),
59
82
  fill: colorEntry.fill,
60
- stroke: colorEntry.stroke
83
+ stroke: colorEntry.stroke,
84
+ patternId: patternIdFor(d)
61
85
  }
62
86
  })
63
87
  }
@@ -70,21 +94,13 @@ export function buildViolins(data, channels, xScale, yScale, colors) {
70
94
  const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
71
95
  const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
72
96
 
73
- const rightPts = ANCHOR_ORDER.map((key) => ({
74
- x: cx + halfMax * DENSITY_AT[key],
75
- y: yScale(d[key])
76
- }))
77
- const leftPts = [...ANCHOR_ORDER].reverse().map((key) => ({
78
- x: cx - halfMax * DENSITY_AT[key],
79
- y: yScale(d[key])
80
- }))
81
-
82
97
  return {
83
98
  data: d,
84
99
  cx,
85
- d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
100
+ d: pathGen(violinPoints(cx, halfMax, d, yScale, side).map((pt) => place(pt.x, pt.y))),
86
101
  fill: colorEntry.fill,
87
- stroke: colorEntry.stroke
102
+ stroke: colorEntry.stroke,
103
+ patternId: patternIdFor(d)
88
104
  }
89
105
  })
90
106
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Computes box plot quartile statistics grouped by x (and optionally color).
3
- * Output rows have { q1, median, q3, iqr_min, iqr_max } replacing the raw y values.
3
+ * Output rows have { q1, median, q3, iqr_min, iqr_max, outliers } replacing the raw y values.
4
+ * iqr_min/iqr_max are Tukey-clamped whisker endpoints (most extreme datum within
5
+ * the 1.5·IQR fence); outliers holds values outside the fence.
4
6
  *
5
7
  * @param {Object[]} data
6
8
  * @param {{ x?: string, y?: string, color?: string }} channels
@@ -5,6 +5,28 @@ function sortedQuantile(values, p) {
5
5
  return quantile([...values].sort(ascending), p)
6
6
  }
7
7
 
8
+ /**
9
+ * Tukey whisker bounds for a group of numeric values.
10
+ * Whiskers clamp to the most extreme datum within the 1.5·IQR fence;
11
+ * values outside the fence are outliers.
12
+ *
13
+ * @param {number[]} values
14
+ * @returns {{ min: number, max: number, outliers: number[] }}
15
+ */
16
+ function whiskerBounds(values) {
17
+ const q1 = sortedQuantile(values, 0.25)
18
+ const q3 = sortedQuantile(values, 0.75)
19
+ const iqr = q3 - q1
20
+ const lo = q1 - 1.5 * iqr
21
+ const hi = q3 + 1.5 * iqr
22
+ const within = values.filter((v) => v >= lo && v <= hi)
23
+ return {
24
+ min: within.length ? min(within) : q1,
25
+ max: within.length ? max(within) : q3,
26
+ outliers: values.filter((v) => v < lo || v > hi)
27
+ }
28
+ }
29
+
8
30
  /**
9
31
  * Built-in reduction functions. Each receives an array of numeric values.
10
32
  * @type {Record<string, (values: number[]) => number>}
@@ -19,7 +41,9 @@ export const STAT_FNS = {
19
41
 
20
42
  /**
21
43
  * Computes box plot quartile statistics grouped by x (and optionally color).
22
- * Output rows have { q1, median, q3, iqr_min, iqr_max } replacing the raw y values.
44
+ * Output rows have { q1, median, q3, iqr_min, iqr_max, outliers } replacing the raw y values.
45
+ * iqr_min/iqr_max are Tukey-clamped whisker endpoints (most extreme datum within
46
+ * the 1.5·IQR fence); outliers holds values outside the fence.
23
47
  *
24
48
  * @param {Object[]} data
25
49
  * @param {{ x?: string, y?: string, color?: string }} channels
@@ -35,16 +59,9 @@ export function applyBoxStat(data, channels) {
35
59
  q1: (v) => sortedQuantile(v, 0.25),
36
60
  median: (v) => sortedQuantile(v, 0.5),
37
61
  q3: (v) => sortedQuantile(v, 0.75),
38
- iqr_min: (v) => {
39
- const q1 = sortedQuantile(v, 0.25)
40
- const q3 = sortedQuantile(v, 0.75)
41
- return q1 - 1.5 * (q3 - q1)
42
- },
43
- iqr_max: (v) => {
44
- const q1 = sortedQuantile(v, 0.25)
45
- const q3 = sortedQuantile(v, 0.75)
46
- return q3 + 1.5 * (q3 - q1)
47
- }
62
+ iqr_min: (v) => whiskerBounds(v).min,
63
+ iqr_max: (v) => whiskerBounds(v).max,
64
+ outliers: (v) => whiskerBounds(v).outliers
48
65
  })
49
66
  .rollup()
50
67
  .select()
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve a highlight selector to zero or more row indices.
3
+ * @param {Array<Record<string, unknown>>} rows
4
+ * @param {'first'|'last'|'min'|'max'|number|((row:any,i:number)=>boolean)} selector
5
+ * @param {{ y?: string }} [opts]
6
+ * @returns {number[]}
7
+ */
8
+ export function resolveHighlight(rows: Array<Record<string, unknown>>, selector: "first" | "last" | "min" | "max" | number | ((row: any, i: number) => boolean), opts?: {
9
+ y?: string;
10
+ }): number[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Resolve a highlight selector to zero or more row indices.
3
+ * @param {Array<Record<string, unknown>>} rows
4
+ * @param {'first'|'last'|'min'|'max'|number|((row:any,i:number)=>boolean)} selector
5
+ * @param {{ y?: string }} [opts]
6
+ * @returns {number[]}
7
+ */
8
+ export function resolveHighlight(rows, selector, opts = {}) {
9
+ if (!Array.isArray(rows) || rows.length === 0 || selector === null || selector === undefined)
10
+ return []
11
+ const { y } = opts
12
+
13
+ if (typeof selector === 'function') {
14
+ const out = []
15
+ for (let i = 0; i < rows.length; i++) if (selector(rows[i], i)) out.push(i)
16
+ return out
17
+ }
18
+
19
+ if (typeof selector === 'number') {
20
+ const i = selector < 0 ? rows.length + selector : selector
21
+ return i >= 0 && i < rows.length ? [i] : []
22
+ }
23
+
24
+ if (selector === 'first') return [0]
25
+ if (selector === 'last') return [rows.length - 1]
26
+
27
+ if (selector === 'min' || selector === 'max') {
28
+ if (!y) return []
29
+ let best = -1
30
+ let bestVal = null
31
+ for (let i = 0; i < rows.length; i++) {
32
+ const v = Number(rows[i][y])
33
+ if (Number.isNaN(v)) continue
34
+ if (bestVal === null || (selector === 'min' ? v < bestVal : v > bestVal)) {
35
+ bestVal = v
36
+ best = i
37
+ }
38
+ }
39
+ return best === -1 ? [] : [best]
40
+ }
41
+
42
+ return []
43
+ }
@@ -17,6 +17,7 @@ export function inferOrientation(xType, yType) {
17
17
  }
18
18
 
19
19
  export function buildUnifiedXScale(datasets, field, width, opts = {}) {
20
+ const range = opts.range ?? [0, width]
20
21
  const allValues = datasets.flatMap((d) => d.map((r) => r[field]))
21
22
  const isNumeric = allValues.every(
22
23
  (v) => typeof v === 'number' || (!isNaN(Number(v)) && String(v).trim() !== '')
@@ -26,11 +27,14 @@ export function buildUnifiedXScale(datasets, field, width, opts = {}) {
26
27
  if (opts.domain) {
27
28
  const domainIsNumeric = opts.domain.every((v) => typeof v === 'number')
28
29
  if (!opts.band && (domainIsNumeric || isNumeric)) {
29
- return scaleLinear().domain(opts.domain).range([0, width]).nice()
30
+ const s = scaleLinear().domain(opts.domain).range(range)
31
+ // nice() rounds the domain to pretty numbers — wrong for a synthetic position axis
32
+ // (a bar-race rank) whose domain is deliberately padded (e.g. [-0.5, N-0.5]).
33
+ return opts.nice === false ? s : s.nice()
30
34
  }
31
35
  return scaleBand()
32
36
  .domain(opts.domain)
33
- .range([0, width])
37
+ .range(range)
34
38
  .padding(opts.padding ?? 0.2)
35
39
  }
36
40
 
@@ -40,20 +44,21 @@ export function buildUnifiedXScale(datasets, field, width, opts = {}) {
40
44
  const domainMin = (opts.includeZero ?? false) ? 0 : (minVal ?? 0)
41
45
  return scaleLinear()
42
46
  .domain([domainMin, maxVal ?? 0])
43
- .range([0, width])
47
+ .range(range)
44
48
  .nice()
45
49
  }
46
50
 
47
51
  const domain = [...new Set(allValues)].filter((v) => v !== null && v !== undefined)
48
52
  return scaleBand()
49
53
  .domain(domain)
50
- .range([0, width])
54
+ .range(range)
51
55
  .padding(opts.padding ?? 0.2)
52
56
  }
53
57
 
54
58
  export function buildUnifiedYScale(datasets, field, height, opts = {}) {
59
+ const yrange = opts.range ?? [height, 0]
55
60
  if (opts.domain) {
56
- return scaleLinear().domain(opts.domain).range([height, 0]).nice()
61
+ return scaleLinear().domain(opts.domain).range(yrange).nice()
57
62
  }
58
63
  const rawValues = datasets.flatMap((d) => d.map((r) => r[field])).filter((v) => v !== null && v !== undefined)
59
64
  const isNumeric = rawValues.length > 0 && rawValues.every(
@@ -62,14 +67,14 @@ export function buildUnifiedYScale(datasets, field, height, opts = {}) {
62
67
  if (!isNumeric) {
63
68
  // Categorical y-axis (e.g. horizontal bar chart) — use scaleBand
64
69
  const domain = [...new Set(rawValues.map(String))]
65
- return scaleBand().domain(domain).range([height, 0]).padding(0.2)
70
+ return scaleBand().domain(domain).range(yrange).padding(0.2)
66
71
  }
67
72
  const numericValues = rawValues.map(Number)
68
73
  const [minVal, maxVal] = extent(numericValues)
69
74
  const domainMin = (opts.includeZero ?? true) ? 0 : (minVal ?? 0)
70
75
  return scaleLinear()
71
76
  .domain([domainMin, maxVal ?? 0])
72
- .range([height, 0])
77
+ .range(yrange)
73
78
  .nice()
74
79
  }
75
80
 
@@ -29,7 +29,7 @@ export type PlotSpec = {
29
29
  theta?: string | undefined;
30
30
  labels?: Record<string, string> | undefined;
31
31
  xDomain?: unknown[] | undefined;
32
- yDomain?: number[] | undefined;
32
+ yDomain?: unknown[] | undefined;
33
33
  xLabel?: string | undefined;
34
34
  yLabel?: string | undefined;
35
35
  axisOrigin?: [number, number] | undefined;
@@ -54,6 +54,14 @@ export type PlotSpec = {
54
54
  title?: string | undefined;
55
55
  summary?: string | undefined;
56
56
  orientation?: "horizontal" | "vertical" | undefined;
57
+ /**
58
+ * Order the category (band) axis by aggregated value — bars sorted by size (histogram-style).
59
+ */
60
+ sort?: "asc" | "desc" | undefined;
61
+ /**
62
+ * Keep the category (x) axis linear (a continuous position scale) instead of band-forcing it — a bar-chart race's tweened rank. Set only by AnimatedPlot; not a public authoring flag.
63
+ */
64
+ continuousCategory?: boolean | undefined;
57
65
  /**
58
66
  * Stack (true) or group (false) multi-series bars. Forwarded to spec-driven bar geoms via `options.stack`. Default: false.
59
67
  */
@@ -29,7 +29,7 @@
29
29
  * @property {string} [theta]
30
30
  * @property {Record<string, string>} [labels]
31
31
  * @property {unknown[]} [xDomain]
32
- * @property {number[]} [yDomain]
32
+ * @property {unknown[]} [yDomain]
33
33
  * @property {string} [xLabel]
34
34
  * @property {string} [yLabel]
35
35
  * @property {[number, number]} [axisOrigin]
@@ -46,6 +46,8 @@
46
46
  * @property {string} [title]
47
47
  * @property {string} [summary]
48
48
  * @property {'horizontal'|'vertical'} [orientation]
49
+ * @property {'asc'|'desc'} [sort] Order the category (band) axis by aggregated value — bars sorted by size (histogram-style).
50
+ * @property {boolean} [continuousCategory] Keep the category (x) axis linear (a continuous position scale) instead of band-forcing it — a bar-chart race's tweened rank. Set only by AnimatedPlot; not a public authoring flag.
49
51
  * @property {boolean} [stack] Stack (true) or group (false) multi-series bars. Forwarded to spec-driven bar geoms via `options.stack`. Default: false.
50
52
  * @property {string} [preset]
51
53
  * @property {number} [width]
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Map a value through a d3 scale to its plotted position, centering on band
3
+ * scales (adds `bandwidth/2`). Returns `NaN` when the scale yields `undefined`
4
+ * (value outside the domain) so overlay callers can drop the point via
5
+ * `Number.isFinite`.
6
+ * @param {(v: unknown) => number | undefined} scale
7
+ * @param {unknown} v
8
+ * @returns {number}
9
+ */
10
+ export function scalePos(scale: (v: unknown) => number | undefined, v: unknown): number;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Map a value through a d3 scale to its plotted position, centering on band
3
+ * scales (adds `bandwidth/2`). Returns `NaN` when the scale yields `undefined`
4
+ * (value outside the domain) so overlay callers can drop the point via
5
+ * `Number.isFinite`.
6
+ * @param {(v: unknown) => number | undefined} scale
7
+ * @param {unknown} v
8
+ * @returns {number}
9
+ */
10
+ export function scalePos(scale, v) {
11
+ const p = scale(v)
12
+ if (p === undefined) return NaN
13
+ return typeof scale.bandwidth === 'function' ? p + scale.bandwidth() / 2 : p
14
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Assemble the SelectDetail handed to a chart's onselect callback.
3
+ * @param {Record<string, unknown>} datum
4
+ * @param {number} index index within the geom's rendered data
5
+ * @param {{ x?: string, y?: string }} channels
6
+ * @param {'line'|'point'|'area'|'bar'} geom
7
+ * @param {unknown} series color/group value, or undefined
8
+ * @param {MouseEvent|KeyboardEvent} event
9
+ */
10
+ export function buildSelectDetail(datum: Record<string, unknown>, index: number, channels: {
11
+ x?: string;
12
+ y?: string;
13
+ }, geom: "line" | "point" | "area" | "bar", series: unknown, event: MouseEvent | KeyboardEvent): {
14
+ datum: Record<string, unknown>;
15
+ index: number;
16
+ series: {} | undefined;
17
+ value: unknown;
18
+ x: unknown;
19
+ y: unknown;
20
+ geom: "bar" | "line" | "area" | "point";
21
+ event: MouseEvent | KeyboardEvent;
22
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Assemble the SelectDetail handed to a chart's onselect callback.
3
+ * @param {Record<string, unknown>} datum
4
+ * @param {number} index index within the geom's rendered data
5
+ * @param {{ x?: string, y?: string }} channels
6
+ * @param {'line'|'point'|'area'|'bar'} geom
7
+ * @param {unknown} series color/group value, or undefined
8
+ * @param {MouseEvent|KeyboardEvent} event
9
+ */
10
+ export function buildSelectDetail(datum, index, channels, geom, series, event) {
11
+ const { x, y } = channels
12
+ return {
13
+ datum,
14
+ index,
15
+ series: series ?? undefined,
16
+ value: y ? datum[y] : undefined,
17
+ x: x ? datum[x] : undefined,
18
+ y: y ? datum[y] : undefined,
19
+ geom,
20
+ event
21
+ }
22
+ }
@@ -0,0 +1,27 @@
1
+ export function mean(ys: any): number | null;
2
+ export function median(ys: any): any;
3
+ /** Least-squares line. Returns { m, b } or null (< 2 points or zero x-variance). */
4
+ export function linearRegression(xs: any, ys: any): {
5
+ m: number;
6
+ b: number;
7
+ } | null;
8
+ /** Trailing simple moving average; length preserved (partial window at the head). */
9
+ export function movingAverage(ys: any, window: any): number[];
10
+ /** Exponential moving average; alpha = alpha ?? 2/(span+1). Length preserved. */
11
+ export function ema(ys: any, { span, alpha }?: {}): any[];
12
+ /** Fit y = a·e^(bx) via log-linear least squares. Null if any y ≤ 0 or < 2 points. */
13
+ export function expRegression(xs: any, ys: any): {
14
+ a: number;
15
+ b: number;
16
+ } | null;
17
+ /**
18
+ * Compute a trend for `rows` on channels {x, y}.
19
+ * @returns {{kind:'constant', value:number} | {kind:'series', values:number[]} | null}
20
+ */
21
+ export function computeTrend(rows: any, channels: any, method: any): {
22
+ kind: "constant";
23
+ value: number;
24
+ } | {
25
+ kind: "series";
26
+ values: number[];
27
+ } | null;