@rokkit/chart 1.3.7 → 1.3.9

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 (162) hide show
  1. package/dist/AnimatedPlot.svelte +26 -8
  2. package/dist/AnimatedPlot.svelte.d.ts +5 -0
  3. package/dist/Chart.svelte +51 -65
  4. package/dist/FacetPlot.svelte +1 -1
  5. package/dist/Plot/Axis.svelte +19 -10
  6. package/dist/Plot/Root.svelte +67 -46
  7. package/dist/Plot/Root.svelte.d.ts +29 -2
  8. package/dist/Plot/Timeline.svelte +39 -13
  9. package/dist/Plot/index.d.ts +5 -5
  10. package/dist/Plot/index.js +5 -5
  11. package/dist/Plot.svelte +95 -178
  12. package/dist/Plot.svelte.d.ts +4 -0
  13. package/dist/PlotState.svelte.d.ts +29 -1
  14. package/dist/PlotState.svelte.js +163 -19
  15. package/dist/PlotSurface.svelte +162 -0
  16. package/dist/PlotSurface.svelte.d.ts +25 -0
  17. package/dist/charts/AreaChart.svelte +12 -2
  18. package/dist/charts/AreaChart.svelte.d.ts +6 -0
  19. package/dist/charts/BarChart.svelte +40 -4
  20. package/dist/charts/BarChart.svelte.d.ts +15 -1
  21. package/dist/charts/BoxPlot.svelte +7 -3
  22. package/dist/charts/BoxPlot.svelte.d.ts +2 -0
  23. package/dist/charts/BubbleChart.svelte +37 -4
  24. package/dist/charts/BubbleChart.svelte.d.ts +12 -1
  25. package/dist/charts/LineChart.svelte +4 -2
  26. package/dist/charts/LineChart.svelte.d.ts +1 -0
  27. package/dist/charts/PieChart.svelte +5 -3
  28. package/dist/charts/PieChart.svelte.d.ts +1 -0
  29. package/dist/charts/ScatterPlot.svelte +35 -4
  30. package/dist/charts/ScatterPlot.svelte.d.ts +11 -1
  31. package/dist/charts/ViolinPlot.svelte +7 -3
  32. package/dist/charts/ViolinPlot.svelte.d.ts +2 -0
  33. package/dist/examples/BarChartExample.svelte +1 -1
  34. package/dist/geoms/Arc.svelte +21 -54
  35. package/dist/geoms/Arc.svelte.d.ts +4 -4
  36. package/dist/geoms/Area.svelte +45 -44
  37. package/dist/geoms/Area.svelte.d.ts +8 -1
  38. package/dist/geoms/Bar.svelte +33 -45
  39. package/dist/geoms/Bar.svelte.d.ts +9 -0
  40. package/dist/geoms/Box.svelte +103 -61
  41. package/dist/geoms/Box.svelte.d.ts +12 -3
  42. package/dist/geoms/Candlestick.svelte +26 -75
  43. package/dist/geoms/Candlestick.svelte.d.ts +2 -0
  44. package/dist/geoms/Heatmap.svelte +21 -66
  45. package/dist/geoms/Heatmap.svelte.d.ts +2 -0
  46. package/dist/geoms/Hexbin.svelte +20 -100
  47. package/dist/geoms/Hexbin.svelte.d.ts +2 -0
  48. package/dist/geoms/Jitter.svelte +67 -0
  49. package/dist/geoms/Jitter.svelte.d.ts +17 -0
  50. package/dist/geoms/Line.svelte +27 -31
  51. package/dist/geoms/Line.svelte.d.ts +5 -0
  52. package/dist/geoms/Point.svelte +31 -60
  53. package/dist/geoms/Point.svelte.d.ts +5 -1
  54. package/dist/geoms/Ribbon.svelte +30 -124
  55. package/dist/geoms/Ribbon.svelte.d.ts +2 -0
  56. package/dist/geoms/Rule.svelte +117 -0
  57. package/dist/geoms/Rule.svelte.d.ts +19 -0
  58. package/dist/geoms/Violin.svelte +36 -31
  59. package/dist/geoms/Violin.svelte.d.ts +10 -3
  60. package/dist/geoms/Waterfall.svelte +25 -79
  61. package/dist/geoms/Waterfall.svelte.d.ts +2 -0
  62. package/dist/geoms/lib/GeomState.svelte.d.ts +51 -0
  63. package/dist/geoms/lib/GeomState.svelte.js +79 -0
  64. package/dist/geoms/lib/aesthetics.d.ts +36 -0
  65. package/dist/geoms/lib/aesthetics.js +45 -0
  66. package/dist/geoms/lib/areas.d.ts +6 -2
  67. package/dist/geoms/lib/areas.js +20 -12
  68. package/dist/geoms/lib/bars.d.ts +18 -3
  69. package/dist/geoms/lib/bars.js +80 -101
  70. package/dist/geoms/lib/marks/arc.d.ts +24 -0
  71. package/dist/geoms/lib/marks/arc.js +28 -0
  72. package/dist/geoms/lib/marks/area.d.ts +23 -0
  73. package/dist/geoms/lib/marks/area.js +38 -0
  74. package/dist/geoms/lib/marks/bar.d.ts +16 -0
  75. package/dist/geoms/lib/marks/bar.js +43 -0
  76. package/dist/geoms/lib/marks/box.d.ts +14 -0
  77. package/dist/geoms/lib/marks/box.js +30 -0
  78. package/dist/geoms/lib/marks/candlestick.d.ts +27 -0
  79. package/dist/geoms/lib/marks/candlestick.js +52 -0
  80. package/dist/geoms/lib/marks/heatmap.d.ts +24 -0
  81. package/dist/geoms/lib/marks/heatmap.js +41 -0
  82. package/dist/geoms/lib/marks/hexbin.d.ts +29 -0
  83. package/dist/geoms/lib/marks/hexbin.js +62 -0
  84. package/dist/geoms/lib/marks/jitter.d.ts +21 -0
  85. package/dist/geoms/lib/marks/jitter.js +36 -0
  86. package/dist/geoms/lib/marks/line.d.ts +26 -0
  87. package/dist/geoms/lib/marks/line.js +25 -0
  88. package/dist/geoms/lib/marks/point.d.ts +25 -0
  89. package/dist/geoms/lib/marks/point.js +58 -0
  90. package/dist/geoms/lib/marks/ribbon.d.ts +31 -0
  91. package/dist/geoms/lib/marks/ribbon.js +77 -0
  92. package/dist/geoms/lib/marks/violin.d.ts +13 -0
  93. package/dist/geoms/lib/marks/violin.js +31 -0
  94. package/dist/geoms/lib/marks/waterfall.d.ts +25 -0
  95. package/dist/geoms/lib/marks/waterfall.js +58 -0
  96. package/dist/index.d.ts +25 -12
  97. package/dist/index.js +26 -13
  98. package/dist/lib/brewing/marks/arcs.js +7 -1
  99. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  100. package/dist/lib/brewing/marks/boxes.js +32 -11
  101. package/dist/lib/brewing/marks/lines.d.ts +4 -1
  102. package/dist/lib/brewing/marks/lines.js +18 -15
  103. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  104. package/dist/lib/brewing/marks/swarm.js +125 -0
  105. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  106. package/dist/lib/brewing/marks/violins.js +41 -25
  107. package/dist/lib/brewing/stats.d.ts +3 -1
  108. package/dist/lib/brewing/stats.js +28 -11
  109. package/dist/lib/plot/scales.js +12 -7
  110. package/dist/lib/plot/types.d.ts +9 -1
  111. package/dist/lib/plot/types.js +3 -1
  112. package/dist/lib/preset.d.ts +11 -1
  113. package/dist/lib/preset.js +16 -4
  114. package/package.json +1 -1
  115. package/dist/Plot/Arc.svelte +0 -30
  116. package/dist/Plot/Arc.svelte.d.ts +0 -18
  117. package/dist/Plot/Area.svelte +0 -54
  118. package/dist/Plot/Area.svelte.d.ts +0 -14
  119. package/dist/Plot/Bar.svelte +0 -69
  120. package/dist/Plot/Bar.svelte.d.ts +0 -11
  121. package/dist/Plot/Line.svelte +0 -54
  122. package/dist/Plot/Line.svelte.d.ts +0 -13
  123. package/dist/Plot/Point.svelte +0 -58
  124. package/dist/Plot/Point.svelte.d.ts +0 -13
  125. package/dist/lib/brewer.d.ts +0 -9
  126. package/dist/lib/brewer.js +0 -25
  127. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +0 -8
  128. package/dist/lib/brewing/BoxBrewer.svelte.js +0 -14
  129. package/dist/lib/brewing/CartesianBrewer.svelte.d.ts +0 -8
  130. package/dist/lib/brewing/CartesianBrewer.svelte.js +0 -21
  131. package/dist/lib/brewing/PieBrewer.svelte.d.ts +0 -8
  132. package/dist/lib/brewing/PieBrewer.svelte.js +0 -14
  133. package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +0 -9
  134. package/dist/lib/brewing/QuartileBrewer.svelte.js +0 -51
  135. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +0 -8
  136. package/dist/lib/brewing/ViolinBrewer.svelte.js +0 -14
  137. package/dist/lib/brewing/axes.svelte.d.ts +0 -66
  138. package/dist/lib/brewing/axes.svelte.js +0 -270
  139. package/dist/lib/brewing/bars.svelte.d.ts +0 -56
  140. package/dist/lib/brewing/bars.svelte.js +0 -201
  141. package/dist/lib/brewing/brewer.svelte.d.ts +0 -124
  142. package/dist/lib/brewing/brewer.svelte.js +0 -277
  143. package/dist/lib/brewing/dimensions.svelte.d.ts +0 -35
  144. package/dist/lib/brewing/dimensions.svelte.js +0 -56
  145. package/dist/lib/brewing/index.svelte.d.ts +0 -118
  146. package/dist/lib/brewing/index.svelte.js +0 -205
  147. package/dist/lib/brewing/legends.svelte.d.ts +0 -48
  148. package/dist/lib/brewing/legends.svelte.js +0 -137
  149. package/dist/lib/brewing/marks/areas.d.ts +0 -31
  150. package/dist/lib/brewing/marks/areas.js +0 -72
  151. package/dist/lib/brewing/marks/bars.d.ts +0 -1
  152. package/dist/lib/brewing/marks/bars.js +0 -49
  153. package/dist/lib/brewing/scales.svelte.d.ts +0 -24
  154. package/dist/lib/brewing/scales.svelte.js +0 -82
  155. package/dist/lib/context.d.ts +0 -13
  156. package/dist/lib/context.js +0 -131
  157. package/dist/lib/plot/chartProps.d.ts +0 -177
  158. package/dist/lib/plot/chartProps.js +0 -76
  159. package/dist/lib/scales.svelte.d.ts +0 -35
  160. package/dist/lib/scales.svelte.js +0 -151
  161. package/dist/lib/ticks.d.ts +0 -36
  162. package/dist/lib/ticks.js +0 -46
@@ -8,17 +8,26 @@
8
8
  * Box body uses the lighter fill shade; whiskers and median use the darker stroke shade.
9
9
  *
10
10
  * @param {Object[]} data - Pre-aggregated rows with quartile fields
11
- * @param {{ x?: string, fill?: string }} channels
11
+ * @param {{ x?: string, fill?: string, pattern?: string }} channels
12
12
  * `fill` drives the box and whisker color (defaults to x-field).
13
13
  * @param {import('d3-scale').ScaleBand} xScale
14
14
  * @param {import('d3-scale').ScaleLinear} yScale
15
15
  * @param {Map<unknown, {fill:string, stroke:string}>} colors
16
+ * @param {{ side?: 'left'|'right'|'center', width?: number, patterns?: Map<unknown, string> }} [opts]
17
+ * `side` offsets the box to one half of the band; `width` is the box thickness as a
18
+ * fraction of the (sub-)band (for a thin raincloud box); `patterns` maps a pattern-channel
19
+ * value to a pattern name for texture fills. Defaults preserve the full box.
16
20
  * @returns {Array}
17
21
  */
18
22
  export function buildBoxes(data: Object[], channels: {
19
23
  x?: string;
20
24
  fill?: string;
25
+ pattern?: string;
21
26
  }, xScale: import("d3-scale").ScaleBand<any>, yScale: import("d3-scale").ScaleLinear<any, any, never>, colors: Map<unknown, {
22
27
  fill: string;
23
28
  stroke: string;
24
- }>): any[];
29
+ }>, opts?: {
30
+ side?: "left" | "right" | "center";
31
+ width?: number;
32
+ patterns?: Map<unknown, string>;
33
+ }): any[];
@@ -1,3 +1,5 @@
1
+ import { toPatternId } from '../patterns.js'
2
+
1
3
  /**
2
4
  * Builds box geometry for box plot charts.
3
5
  * Input data rows must already contain { q1, median, q3, iqr_min, iqr_max } —
@@ -8,30 +10,42 @@
8
10
  * Box body uses the lighter fill shade; whiskers and median use the darker stroke shade.
9
11
  *
10
12
  * @param {Object[]} data - Pre-aggregated rows with quartile fields
11
- * @param {{ x?: string, fill?: string }} channels
13
+ * @param {{ x?: string, fill?: string, pattern?: string }} channels
12
14
  * `fill` drives the box and whisker color (defaults to x-field).
13
15
  * @param {import('d3-scale').ScaleBand} xScale
14
16
  * @param {import('d3-scale').ScaleLinear} yScale
15
17
  * @param {Map<unknown, {fill:string, stroke:string}>} colors
18
+ * @param {{ side?: 'left'|'right'|'center', width?: number, patterns?: Map<unknown, string> }} [opts]
19
+ * `side` offsets the box to one half of the band; `width` is the box thickness as a
20
+ * fraction of the (sub-)band (for a thin raincloud box); `patterns` maps a pattern-channel
21
+ * value to a pattern name for texture fills. Defaults preserve the full box.
16
22
  * @returns {Array}
17
23
  */
18
- export function buildBoxes(data, channels, xScale, yScale, colors) {
19
- const { x: xf, fill: ff } = channels
24
+ export function buildBoxes(data, channels, xScale, yScale, colors, opts = {}) {
25
+ const { x: xf, fill: ff, pattern: pf } = channels
26
+ const { side = 'center', width, patterns } = opts
20
27
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 20
21
28
  const grouped = ff && ff !== xf
29
+ // Offset the box centre to the middle of the chosen half-lane.
30
+ const laneOffset = (unit) => (side === 'left' ? -unit / 4 : side === 'right' ? unit / 4 : 0)
31
+ // Texture fill: patternId per box from the pattern channel value (null = solid fill).
32
+ const patternIdFor = (d) => {
33
+ const key = pf ? d[pf] : null
34
+ return key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
35
+ }
22
36
 
23
37
  if (grouped) {
24
38
  const fillValues = [...new Set(data.map((d) => d[ff]))]
25
39
  const n = fillValues.length
26
40
  const subBandWidth = bw / n
27
- const boxWidth = subBandWidth * 0.75
28
- const whiskerWidth = subBandWidth * 0.4
41
+ const boxWidth = width !== undefined ? subBandWidth * width : subBandWidth * 0.75
42
+ const whiskerWidth = width !== undefined ? boxWidth * 0.53 : subBandWidth * 0.4
29
43
 
30
44
  return data.map((d) => {
31
45
  const fillVal = d[ff]
32
46
  const subIndex = fillValues.indexOf(fillVal)
33
47
  const bandStart = xScale(d[xf]) ?? 0
34
- const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
48
+ const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2 + laneOffset(subBandWidth)
35
49
  const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
36
50
 
37
51
  return {
@@ -42,22 +56,27 @@ export function buildBoxes(data, channels, xScale, yScale, colors) {
42
56
  q3: yScale(d.q3),
43
57
  iqr_min: yScale(d.iqr_min),
44
58
  iqr_max: yScale(d.iqr_max),
59
+ outliers: (d.outliers ?? []).map((v) => ({ cy: yScale(v), value: v })),
45
60
  width: boxWidth,
46
61
  whiskerWidth,
47
62
  fill: colorEntry.fill,
48
- stroke: colorEntry.stroke
63
+ stroke: colorEntry.stroke,
64
+ patternId: patternIdFor(d)
49
65
  }
50
66
  })
51
67
  }
52
68
 
53
69
  // Non-grouped: one box per x category
54
- const boxWidth = bw * 0.6
55
- const whiskerWidth = bw * 0.3
70
+ const boxWidth = width !== undefined ? bw * width : bw * 0.6
71
+ const whiskerWidth = width !== undefined ? boxWidth * 0.5 : bw * 0.3
56
72
 
57
73
  return data.map((d) => {
58
74
  const fillKey = ff ? d[ff] : d[xf]
59
75
  const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
60
- const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
76
+ const cx =
77
+ (xScale(d[xf]) ?? 0) +
78
+ (typeof xScale.bandwidth === 'function' ? bw / 2 : 0) +
79
+ laneOffset(bw)
61
80
  return {
62
81
  data: d,
63
82
  cx,
@@ -66,10 +85,12 @@ export function buildBoxes(data, channels, xScale, yScale, colors) {
66
85
  q3: yScale(d.q3),
67
86
  iqr_min: yScale(d.iqr_min),
68
87
  iqr_max: yScale(d.iqr_max),
88
+ outliers: (d.outliers ?? []).map((v) => ({ cy: yScale(v), value: v })),
69
89
  width: boxWidth,
70
90
  whiskerWidth,
71
91
  fill: colorEntry.fill,
72
- stroke: colorEntry.stroke
92
+ stroke: colorEntry.stroke,
93
+ patternId: patternIdFor(d)
73
94
  }
74
95
  })
75
96
  }
@@ -11,7 +11,10 @@ export function buildLines(data: Object[], channels: {
11
11
  x?: string;
12
12
  y?: string;
13
13
  color?: string;
14
- }, xScale: Function, yScale: Function, colors: Map<any, any>, curve?: "linear" | "smooth" | "step"): {
14
+ }, xScale: Function, yScale: Function, colors: Map<any, any>, curve?: "linear" | "smooth" | "step", place?: (x: any, y: any) => {
15
+ x: any;
16
+ y: any;
17
+ }): {
15
18
  d: string | null;
16
19
  fill: string;
17
20
  stroke: string;
@@ -9,40 +9,43 @@ import { line, curveCatmullRom, curveStep } from 'd3-shape'
9
9
  * @param {'linear'|'smooth'|'step'} [curve]
10
10
  * @returns {{ d: string | null, fill: string, stroke: string, points: {x:number, y:number, data: Record<string, unknown>, i: number}[], key?: unknown }[]}
11
11
  */
12
- export function buildLines(data, channels, xScale, yScale, colors, curve) {
12
+ export function buildLines(data, channels, xScale, yScale, colors, curve, place = (x, y) => ({ x, y })) {
13
13
  const { x: xf, y: yf, color: cf } = channels
14
14
  const xPos = (d) =>
15
15
  typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
16
+ // Screen points, sorted by the x-channel position, each mapped through place() so the
17
+ // whole line transposes under orientation='horizontal' (identity default → unchanged).
18
+ const screenPoints = (rows) => {
19
+ const withIndices = rows.map((d, i) => ({ data: d, i }))
20
+ withIndices.sort((a, b) => xPos(a.data) - xPos(b.data))
21
+ return withIndices.map(({ data: d, i }) => {
22
+ const p = place(xPos(d), yScale(d[yf]))
23
+ return { x: p.x, y: p.y, data: d, i }
24
+ })
25
+ }
16
26
  const makeGen = () => {
17
27
  const gen = line()
18
- .x((d) => xPos(d.data ?? d))
19
- .y((d) => yScale((d.data ?? d)[yf]))
28
+ .x((p) => p.x)
29
+ .y((p) => p.y)
20
30
  if (curve === 'smooth') gen.curve(curveCatmullRom)
21
31
  else if (curve === 'step') gen.curve(curveStep)
22
32
  return gen
23
33
  }
24
- const toPoints = (rowsWithIndices) => rowsWithIndices.map(({ data: d, i }) => ({ x: xPos(d), y: yScale(d[yf]), data: d, i }))
25
-
26
- const sortByX = (rows) => {
27
- const withIndices = rows.map((d, i) => ({ data: d, i }))
28
- withIndices.sort((a, b) => xPos(a.data) - xPos(b.data))
29
- return withIndices
30
- }
31
34
 
32
35
  if (!cf) {
33
- const sorted = sortByX(data)
36
+ const pts = screenPoints(data)
34
37
  const stroke = colors?.values().next().value?.stroke ?? '#888'
35
- return [{ d: makeGen()(sorted.map(d => d.data)), fill: 'none', stroke, points: toPoints(sorted) }]
38
+ return [{ d: makeGen()(pts), fill: 'none', stroke, points: pts }]
36
39
  }
37
40
  const groups = groupBy(data, cf)
38
41
  return [...groups.entries()].map(([key, rows]) => {
39
- const sorted = sortByX(rows)
42
+ const pts = screenPoints(rows)
40
43
  const colorEntry = colors?.get(key) ?? { fill: 'none', stroke: '#888' }
41
44
  return {
42
- d: makeGen()(sorted.map(d => d.data)),
45
+ d: makeGen()(pts),
43
46
  fill: 'none',
44
47
  stroke: colorEntry.stroke,
45
- points: toPoints(sorted),
48
+ points: pts,
46
49
  key
47
50
  }
48
51
  })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Places one point per datum within each x-band. Two layouts:
3
+ * - 'jitter': deterministic horizontal offset seeded by index-in-group.
4
+ * - 'swarm': 1-D beeswarm — sort by y, dodge horizontally to avoid overlap.
5
+ *
6
+ * @param {Object[]} data - raw rows (stat=identity)
7
+ * @param {{ x?: string, y?: string, fill?: string }} channels
8
+ * @param {import('d3-scale').ScaleBand} xScale
9
+ * @param {import('d3-scale').ScaleLinear} yScale
10
+ * @param {Map<unknown, {fill:string, stroke:string}>} colors
11
+ * @param {{ method?: 'jitter'|'swarm', r?: number, side?: 'left'|'right'|'center' }} opts
12
+ * `side` confines points to one half of the band (for raincloud/half-plots).
13
+ * @returns {Array<{ cx:number, cy:number, fill:string, stroke:string, data:Record<string, unknown> }>}
14
+ */
15
+ export function buildSwarm(data: Object[], channels: {
16
+ x?: string;
17
+ y?: string;
18
+ fill?: string;
19
+ }, xScale: import("d3-scale").ScaleBand<any>, yScale: import("d3-scale").ScaleLinear<any, any, never>, colors: Map<unknown, {
20
+ fill: string;
21
+ stroke: string;
22
+ }>, opts?: {
23
+ method?: "jitter" | "swarm";
24
+ r?: number;
25
+ side?: "left" | "right" | "center";
26
+ }): Array<{
27
+ cx: number;
28
+ cy: number;
29
+ fill: string;
30
+ stroke: string;
31
+ data: Record<string, unknown>;
32
+ }>;
@@ -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()
@@ -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]