@rokkit/chart 1.0.0-next.151 → 1.0.0-next.158

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 (86) hide show
  1. package/dist/PlotState.svelte.d.ts +26 -0
  2. package/dist/index.d.ts +6 -1
  3. package/dist/lib/brewing/BoxBrewer.svelte.d.ts +3 -5
  4. package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +9 -0
  5. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +3 -4
  6. package/dist/lib/brewing/colors.d.ts +10 -1
  7. package/dist/lib/brewing/marks/points.d.ts +17 -2
  8. package/dist/lib/keyboard-nav.d.ts +15 -0
  9. package/dist/lib/plot/preset.d.ts +1 -1
  10. package/dist/lib/preset.d.ts +30 -0
  11. package/package.json +2 -1
  12. package/src/AnimatedPlot.svelte +375 -207
  13. package/src/Chart.svelte +81 -84
  14. package/src/ChartProvider.svelte +10 -0
  15. package/src/FacetPlot/Panel.svelte +30 -16
  16. package/src/FacetPlot.svelte +100 -76
  17. package/src/Plot/Area.svelte +26 -19
  18. package/src/Plot/Axis.svelte +81 -59
  19. package/src/Plot/Bar.svelte +47 -89
  20. package/src/Plot/Grid.svelte +23 -19
  21. package/src/Plot/Legend.svelte +213 -147
  22. package/src/Plot/Line.svelte +31 -21
  23. package/src/Plot/Point.svelte +35 -22
  24. package/src/Plot/Root.svelte +46 -91
  25. package/src/Plot/Timeline.svelte +82 -82
  26. package/src/Plot/Tooltip.svelte +68 -62
  27. package/src/Plot.svelte +290 -174
  28. package/src/PlotState.svelte.js +338 -265
  29. package/src/Sparkline.svelte +95 -56
  30. package/src/charts/AreaChart.svelte +22 -20
  31. package/src/charts/BarChart.svelte +23 -21
  32. package/src/charts/BoxPlot.svelte +15 -15
  33. package/src/charts/BubbleChart.svelte +17 -17
  34. package/src/charts/LineChart.svelte +20 -20
  35. package/src/charts/PieChart.svelte +30 -20
  36. package/src/charts/ScatterPlot.svelte +20 -19
  37. package/src/charts/ViolinPlot.svelte +15 -15
  38. package/src/crossfilter/CrossFilter.svelte +33 -29
  39. package/src/crossfilter/FilterBar.svelte +17 -25
  40. package/src/crossfilter/FilterHistogram.svelte +290 -0
  41. package/src/crossfilter/FilterSlider.svelte +69 -65
  42. package/src/crossfilter/createCrossFilter.svelte.js +94 -90
  43. package/src/geoms/Arc.svelte +114 -69
  44. package/src/geoms/Area.svelte +67 -39
  45. package/src/geoms/Bar.svelte +184 -126
  46. package/src/geoms/Box.svelte +101 -91
  47. package/src/geoms/LabelPill.svelte +11 -11
  48. package/src/geoms/Line.svelte +110 -86
  49. package/src/geoms/Point.svelte +130 -90
  50. package/src/geoms/Violin.svelte +51 -41
  51. package/src/geoms/lib/areas.js +122 -99
  52. package/src/geoms/lib/bars.js +195 -144
  53. package/src/index.js +21 -14
  54. package/src/lib/brewing/BoxBrewer.svelte.js +8 -50
  55. package/src/lib/brewing/CartesianBrewer.svelte.js +11 -7
  56. package/src/lib/brewing/PieBrewer.svelte.js +5 -5
  57. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  58. package/src/lib/brewing/ViolinBrewer.svelte.js +8 -49
  59. package/src/lib/brewing/brewer.svelte.js +242 -195
  60. package/src/lib/brewing/colors.js +34 -5
  61. package/src/lib/brewing/marks/arcs.js +28 -28
  62. package/src/lib/brewing/marks/areas.js +54 -41
  63. package/src/lib/brewing/marks/bars.js +34 -34
  64. package/src/lib/brewing/marks/boxes.js +51 -51
  65. package/src/lib/brewing/marks/lines.js +37 -30
  66. package/src/lib/brewing/marks/points.js +74 -26
  67. package/src/lib/brewing/marks/violins.js +57 -57
  68. package/src/lib/brewing/patterns.js +25 -11
  69. package/src/lib/brewing/scales.js +17 -17
  70. package/src/lib/brewing/stats.js +37 -29
  71. package/src/lib/brewing/symbols.js +1 -1
  72. package/src/lib/chart.js +2 -1
  73. package/src/lib/keyboard-nav.js +37 -0
  74. package/src/lib/plot/crossfilter.js +5 -5
  75. package/src/lib/plot/facet.js +30 -30
  76. package/src/lib/plot/frames.js +30 -29
  77. package/src/lib/plot/helpers.js +4 -4
  78. package/src/lib/plot/preset.js +48 -34
  79. package/src/lib/plot/scales.js +64 -39
  80. package/src/lib/plot/stat.js +47 -47
  81. package/src/lib/preset.js +41 -0
  82. package/src/patterns/DefinePatterns.svelte +24 -24
  83. package/src/patterns/README.md +3 -0
  84. package/src/patterns/patterns.js +328 -176
  85. package/src/patterns/scale.js +61 -32
  86. package/src/spec/chart-spec.js +64 -21
@@ -12,48 +12,61 @@ import { toPatternId } from '../patterns.js'
12
12
  * @param {Map} [patternMap]
13
13
  */
14
14
  export function buildAreas(data, channels, xScale, yScale, colors, curve, patternMap) {
15
- const { x: xf, y: yf, pattern: pf } = channels
16
- const cf = channels.fill // fill is the primary aesthetic for area charts
17
- const innerHeight = yScale.range()[0]
18
- const xPos = (d) => typeof xScale.bandwidth === 'function'
19
- ? xScale(d[xf]) + xScale.bandwidth() / 2
20
- : xScale(d[xf])
21
- const makeGen = () => {
22
- const gen = area().x(xPos).y0(innerHeight).y1((d) => yScale(d[yf]))
23
- if (curve === 'smooth') gen.curve(curveCatmullRom)
24
- else if (curve === 'step') gen.curve(curveStep)
25
- return gen
26
- }
27
- if (!cf) {
28
- const colorEntry = colors?.values().next().value ?? { fill: '#888', stroke: '#444' }
29
- return [{ d: makeGen()(data), fill: colorEntry.fill, stroke: 'none', colorKey: null, patternKey: null, patternId: null }]
30
- }
31
- const groups = groupBy(data, cf)
32
- return [...groups.entries()].map(([key, rows]) => {
33
- const colorEntry = colors?.get(key) ?? { fill: '#888', stroke: '#444' }
34
- const patternKey = pf ? (pf === cf ? key : rows[0]?.[pf]) : null
35
- const patternName = patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
36
- const compositePatternKey = (cf && pf && cf !== pf && patternKey !== null && patternKey !== undefined)
37
- ? `${key}::${patternKey}`
38
- : patternKey
39
- return {
40
- d: makeGen()(rows),
41
- fill: colorEntry.fill,
42
- stroke: 'none',
43
- key,
44
- colorKey: key,
45
- patternKey,
46
- patternId: patternName ? toPatternId(compositePatternKey) : null
47
- }
48
- })
15
+ const { x: xf, y: yf, pattern: pf } = channels
16
+ const cf = channels.fill // fill is the primary aesthetic for area charts
17
+ const innerHeight = yScale.range()[0]
18
+ const xPos = (d) =>
19
+ typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
20
+ const makeGen = () => {
21
+ const gen = area()
22
+ .x(xPos)
23
+ .y0(innerHeight)
24
+ .y1((d) => yScale(d[yf]))
25
+ if (curve === 'smooth') gen.curve(curveCatmullRom)
26
+ else if (curve === 'step') gen.curve(curveStep)
27
+ return gen
28
+ }
29
+ if (!cf) {
30
+ const colorEntry = colors?.values().next().value ?? { fill: '#888', stroke: '#444' }
31
+ return [
32
+ {
33
+ d: makeGen()(data),
34
+ fill: colorEntry.fill,
35
+ stroke: 'none',
36
+ colorKey: null,
37
+ patternKey: null,
38
+ patternId: null
39
+ }
40
+ ]
41
+ }
42
+ const groups = groupBy(data, cf)
43
+ return [...groups.entries()].map(([key, rows]) => {
44
+ const colorEntry = colors?.get(key) ?? { fill: '#888', stroke: '#444' }
45
+ const patternKey = pf ? (pf === cf ? key : rows[0]?.[pf]) : null
46
+ const patternName =
47
+ patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
48
+ const compositePatternKey =
49
+ cf && pf && cf !== pf && patternKey !== null && patternKey !== undefined
50
+ ? `${key}::${patternKey}`
51
+ : patternKey
52
+ return {
53
+ d: makeGen()(rows),
54
+ fill: colorEntry.fill,
55
+ stroke: 'none',
56
+ key,
57
+ colorKey: key,
58
+ patternKey,
59
+ patternId: patternName ? toPatternId(compositePatternKey) : null
60
+ }
61
+ })
49
62
  }
50
63
 
51
64
  function groupBy(arr, field) {
52
- const map = new Map()
53
- for (const item of arr) {
54
- const key = item[field]
55
- if (!map.has(key)) map.set(key, [])
56
- map.get(key).push(item)
57
- }
58
- return map
65
+ const map = new Map()
66
+ for (const item of arr) {
67
+ const key = item[field]
68
+ if (!map.has(key)) map.set(key, [])
69
+ map.get(key).push(item)
70
+ }
71
+ return map
59
72
  }
@@ -11,39 +11,39 @@
11
11
  import { toPatternId } from '../patterns.js'
12
12
 
13
13
  export function buildBars(data, channels, xScale, yScale, colors, patternMap) {
14
- const { x: xf, y: yf, fill: ff, color: cf, pattern: pf } = channels
15
- const barWidth = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
16
- const innerHeight = yScale.range()[0]
14
+ const { x: xf, y: yf, fill: ff, color: cf, pattern: pf } = channels
15
+ const barWidth = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
16
+ const innerHeight = yScale.range()[0]
17
17
 
18
- return data.map((d) => {
19
- const xVal = d[xf]
20
- const fillKey = ff ? d[ff] : xVal // fill channel drives interior color
21
- const strokeKey = cf ? d[cf] : null // color channel drives border; null = no border
22
- const colorEntry = colors?.get(fillKey) ?? { fill: '#888', stroke: '#444' }
23
- const strokeEntry = colors?.get(strokeKey) ?? colorEntry
24
- const patternKey = pf ? d[pf] : null
25
- const patternName = patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
26
- // When fill and pattern are different fields, bars need a composite pattern def id
27
- // so each (region, category) pair gets its uniquely colored+textured pattern.
28
- const compositePatternKey = (ff && pf && ff !== pf && patternKey !== null && patternKey !== undefined)
29
- ? `${d[ff]}::${patternKey}`
30
- : patternKey
31
- const barX = typeof xScale.bandwidth === 'function'
32
- ? xScale(xVal)
33
- : xScale(xVal) - barWidth / 2
34
- const barY = yScale(d[yf])
35
- return {
36
- data: d,
37
- key: `${xVal}::${fillKey ?? ''}::${patternKey ?? ''}`,
38
- x: barX,
39
- y: barY,
40
- width: barWidth,
41
- height: innerHeight - barY,
42
- fill: colorEntry.fill,
43
- stroke: strokeKey !== null ? strokeEntry.stroke : null,
44
- colorKey: fillKey,
45
- patternKey,
46
- patternId: patternName ? toPatternId(compositePatternKey) : null
47
- }
48
- })
18
+ return data.map((d) => {
19
+ const xVal = d[xf]
20
+ const fillKey = ff ? d[ff] : xVal // fill channel drives interior color
21
+ const strokeKey = cf ? d[cf] : null // color channel drives border; null = no border
22
+ const colorEntry = colors?.get(fillKey) ?? { fill: '#888', stroke: '#444' }
23
+ const strokeEntry = colors?.get(strokeKey) ?? colorEntry
24
+ const patternKey = pf ? d[pf] : null
25
+ const patternName =
26
+ patternKey !== null && patternKey !== undefined ? patternMap?.get(patternKey) : null
27
+ // When fill and pattern are different fields, bars need a composite pattern def id
28
+ // so each (region, category) pair gets its uniquely colored+textured pattern.
29
+ const compositePatternKey =
30
+ ff && pf && ff !== pf && patternKey !== null && patternKey !== undefined
31
+ ? `${d[ff]}::${patternKey}`
32
+ : patternKey
33
+ const barX = typeof xScale.bandwidth === 'function' ? xScale(xVal) : xScale(xVal) - barWidth / 2
34
+ const barY = yScale(d[yf])
35
+ return {
36
+ data: d,
37
+ key: `${xVal}::${fillKey ?? ''}::${patternKey ?? ''}`,
38
+ x: barX,
39
+ y: barY,
40
+ width: barWidth,
41
+ height: innerHeight - barY,
42
+ fill: colorEntry.fill,
43
+ stroke: strokeKey !== null ? strokeEntry.stroke : null,
44
+ colorKey: fillKey,
45
+ patternKey,
46
+ patternId: patternName ? toPatternId(compositePatternKey) : null
47
+ }
48
+ })
49
49
  }
@@ -16,60 +16,60 @@
16
16
  * @returns {Array}
17
17
  */
18
18
  export function buildBoxes(data, channels, xScale, yScale, colors) {
19
- const { x: xf, fill: ff } = channels
20
- const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 20
21
- const grouped = ff && ff !== xf
19
+ const { x: xf, fill: ff } = channels
20
+ const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 20
21
+ const grouped = ff && ff !== xf
22
22
 
23
- if (grouped) {
24
- const fillValues = [...new Set(data.map((d) => d[ff]))]
25
- const n = fillValues.length
26
- const subBandWidth = bw / n
27
- const boxWidth = subBandWidth * 0.75
28
- const whiskerWidth = subBandWidth * 0.4
23
+ if (grouped) {
24
+ const fillValues = [...new Set(data.map((d) => d[ff]))]
25
+ const n = fillValues.length
26
+ const subBandWidth = bw / n
27
+ const boxWidth = subBandWidth * 0.75
28
+ const whiskerWidth = subBandWidth * 0.4
29
29
 
30
- return data.map((d) => {
31
- const fillVal = d[ff]
32
- const subIndex = fillValues.indexOf(fillVal)
33
- const bandStart = xScale(d[xf]) ?? 0
34
- const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
35
- const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
30
+ return data.map((d) => {
31
+ const fillVal = d[ff]
32
+ const subIndex = fillValues.indexOf(fillVal)
33
+ const bandStart = xScale(d[xf]) ?? 0
34
+ const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
35
+ const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
36
36
 
37
- return {
38
- data: d,
39
- cx,
40
- q1: yScale(d.q1),
41
- median: yScale(d.median),
42
- q3: yScale(d.q3),
43
- iqr_min: yScale(d.iqr_min),
44
- iqr_max: yScale(d.iqr_max),
45
- width: boxWidth,
46
- whiskerWidth,
47
- fill: colorEntry.fill,
48
- stroke: colorEntry.stroke
49
- }
50
- })
51
- }
37
+ return {
38
+ data: d,
39
+ cx,
40
+ q1: yScale(d.q1),
41
+ median: yScale(d.median),
42
+ q3: yScale(d.q3),
43
+ iqr_min: yScale(d.iqr_min),
44
+ iqr_max: yScale(d.iqr_max),
45
+ width: boxWidth,
46
+ whiskerWidth,
47
+ fill: colorEntry.fill,
48
+ stroke: colorEntry.stroke
49
+ }
50
+ })
51
+ }
52
52
 
53
- // Non-grouped: one box per x category
54
- const boxWidth = bw * 0.6
55
- const whiskerWidth = bw * 0.3
53
+ // Non-grouped: one box per x category
54
+ const boxWidth = bw * 0.6
55
+ const whiskerWidth = bw * 0.3
56
56
 
57
- return data.map((d) => {
58
- const fillKey = ff ? d[ff] : d[xf]
59
- const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
60
- const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
61
- return {
62
- data: d,
63
- cx,
64
- q1: yScale(d.q1),
65
- median: yScale(d.median),
66
- q3: yScale(d.q3),
67
- iqr_min: yScale(d.iqr_min),
68
- iqr_max: yScale(d.iqr_max),
69
- width: boxWidth,
70
- whiskerWidth,
71
- fill: colorEntry.fill,
72
- stroke: colorEntry.stroke
73
- }
74
- })
57
+ return data.map((d) => {
58
+ const fillKey = ff ? d[ff] : d[xf]
59
+ const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
60
+ const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
61
+ return {
62
+ data: d,
63
+ cx,
64
+ q1: yScale(d.q1),
65
+ median: yScale(d.median),
66
+ q3: yScale(d.q3),
67
+ iqr_min: yScale(d.iqr_min),
68
+ iqr_max: yScale(d.iqr_max),
69
+ width: boxWidth,
70
+ whiskerWidth,
71
+ fill: colorEntry.fill,
72
+ stroke: colorEntry.stroke
73
+ }
74
+ })
75
75
  }
@@ -10,39 +10,46 @@ import { line, curveCatmullRom, curveStep } from 'd3-shape'
10
10
  * @returns {{ d: string, fill: string, stroke: string, points: {x:number, y:number, data:Object}[], key?: unknown }[]}
11
11
  */
12
12
  export function buildLines(data, channels, xScale, yScale, colors, curve) {
13
- const { x: xf, y: yf, color: cf } = channels
14
- const xPos = (d) => typeof xScale.bandwidth === 'function'
15
- ? xScale(d[xf]) + xScale.bandwidth() / 2
16
- : xScale(d[xf])
17
- const makeGen = () => {
18
- const gen = line().x(xPos).y((d) => yScale(d[yf]))
19
- if (curve === 'smooth') gen.curve(curveCatmullRom)
20
- else if (curve === 'step') gen.curve(curveStep)
21
- return gen
22
- }
23
- const toPoints = (rows) => rows.map((d) => ({ x: xPos(d), y: yScale(d[yf]), data: d }))
13
+ const { x: xf, y: yf, color: cf } = channels
14
+ const xPos = (d) =>
15
+ typeof xScale.bandwidth === 'function' ? xScale(d[xf]) + xScale.bandwidth() / 2 : xScale(d[xf])
16
+ const makeGen = () => {
17
+ const gen = line()
18
+ .x(xPos)
19
+ .y((d) => yScale(d[yf]))
20
+ if (curve === 'smooth') gen.curve(curveCatmullRom)
21
+ else if (curve === 'step') gen.curve(curveStep)
22
+ return gen
23
+ }
24
+ const toPoints = (rows) => rows.map((d) => ({ x: xPos(d), y: yScale(d[yf]), data: d }))
24
25
 
25
- const sortByX = (rows) => [...rows].sort((a, b) => a[xf] < b[xf] ? -1 : a[xf] > b[xf] ? 1 : 0)
26
+ const sortByX = (rows) => [...rows].sort((a, b) => xPos(a) - xPos(b))
26
27
 
27
- if (!cf) {
28
- const sorted = sortByX(data)
29
- const stroke = colors?.values().next().value?.stroke ?? '#888'
30
- return [{ d: makeGen()(sorted), fill: 'none', stroke, points: toPoints(sorted) }]
31
- }
32
- const groups = groupBy(data, cf)
33
- return [...groups.entries()].map(([key, rows]) => {
34
- const sorted = sortByX(rows)
35
- const colorEntry = colors?.get(key) ?? { fill: 'none', stroke: '#888' }
36
- return { d: makeGen()(sorted), fill: 'none', stroke: colorEntry.stroke, points: toPoints(sorted), key }
37
- })
28
+ if (!cf) {
29
+ const sorted = sortByX(data)
30
+ const stroke = colors?.values().next().value?.stroke ?? '#888'
31
+ return [{ d: makeGen()(sorted), fill: 'none', stroke, points: toPoints(sorted) }]
32
+ }
33
+ const groups = groupBy(data, cf)
34
+ return [...groups.entries()].map(([key, rows]) => {
35
+ const sorted = sortByX(rows)
36
+ const colorEntry = colors?.get(key) ?? { fill: 'none', stroke: '#888' }
37
+ return {
38
+ d: makeGen()(sorted),
39
+ fill: 'none',
40
+ stroke: colorEntry.stroke,
41
+ points: toPoints(sorted),
42
+ key
43
+ }
44
+ })
38
45
  }
39
46
 
40
47
  function groupBy(arr, field) {
41
- const map = new Map()
42
- for (const item of arr) {
43
- const key = item[field]
44
- if (!map.has(key)) map.set(key, [])
45
- map.get(key).push(item)
46
- }
47
- return map
48
+ const map = new Map()
49
+ for (const item of arr) {
50
+ const key = item[field]
51
+ if (!map.has(key)) map.set(key, [])
52
+ map.get(key).push(item)
53
+ }
54
+ return map
48
55
  }
@@ -1,15 +1,33 @@
1
- import { symbol, symbolCircle, symbolSquare, symbolTriangle, symbolDiamond, symbolCross, symbolStar } from 'd3-shape'
1
+ import {
2
+ symbol,
3
+ symbolCircle,
4
+ symbolSquare,
5
+ symbolTriangle,
6
+ symbolDiamond,
7
+ symbolCross,
8
+ symbolStar
9
+ } from 'd3-shape'
10
+ import { defaultPreset } from '../../preset.js'
2
11
 
3
- const SYMBOL_TYPES = [symbolCircle, symbolSquare, symbolTriangle, symbolDiamond, symbolCross, symbolStar]
12
+ const SYMBOL_TYPES = [
13
+ symbolCircle,
14
+ symbolSquare,
15
+ symbolTriangle,
16
+ symbolDiamond,
17
+ symbolCross,
18
+ symbolStar
19
+ ]
4
20
  const SYMBOL_NAMES = ['circle', 'square', 'triangle', 'diamond', 'cross', 'star']
5
21
 
6
22
  /**
7
23
  * Returns a Map assigning shape names to distinct values, cycling through available shapes.
8
24
  * @param {unknown[]} values
25
+ * @param {typeof defaultPreset} preset
9
26
  * @returns {Map<unknown, string>}
10
27
  */
11
- export function assignSymbols(values) {
12
- return new Map(values.map((v, i) => [v, SYMBOL_NAMES[i % SYMBOL_NAMES.length]]))
28
+ export function assignSymbols(values, preset = defaultPreset) {
29
+ const names = preset.symbols
30
+ return new Map(values.map((v, i) => [v, names[i % names.length]]))
13
31
  }
14
32
 
15
33
  /**
@@ -19,9 +37,26 @@ export function assignSymbols(values) {
19
37
  * @returns {string}
20
38
  */
21
39
  export function buildSymbolPath(shapeName, r) {
22
- const idx = SYMBOL_NAMES.indexOf(shapeName)
23
- const type = idx >= 0 ? SYMBOL_TYPES[idx] : symbolCircle
24
- return symbol().type(type).size(Math.PI * r * r)() ?? ''
40
+ const idx = SYMBOL_NAMES.indexOf(shapeName)
41
+ const type = idx >= 0 ? SYMBOL_TYPES[idx] : symbolCircle
42
+ return (
43
+ symbol()
44
+ .type(type)
45
+ .size(Math.PI * r * r)() ?? ''
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Returns a stable pseudo-random offset for a given index.
51
+ * Uses a linear congruential generator seeded by index — no external dependency,
52
+ * stable across re-renders.
53
+ * @param {number} i - row index (seed)
54
+ * @param {number} range - total spread (jitter is ±range/2)
55
+ * @returns {number}
56
+ */
57
+ export function jitterOffset(i, range) {
58
+ const r = ((i * 1664525 + 1013904223) >>> 0) / 0xffffffff
59
+ return (r - 0.5) * range
25
60
  }
26
61
 
27
62
  /**
@@ -34,24 +69,37 @@ export function buildSymbolPath(shapeName, r) {
34
69
  * @param {Function|null} sizeScale
35
70
  * @param {Map<unknown, string>|null} symbolMap — maps symbol field value → shape name
36
71
  * @param {number} defaultRadius
72
+ * @param {{ width?: number, height?: number }|null} jitter
37
73
  */
38
- export function buildPoints(data, channels, xScale, yScale, colors, sizeScale, symbolMap, defaultRadius = 5) {
39
- const { x: xf, y: yf, color: cf, size: sf, symbol: symf } = channels
40
- return data.map((d) => {
41
- const colorKey = cf ? d[cf] : null
42
- const colorEntry = colors?.get(colorKey) ?? { fill: '#888', stroke: '#444' }
43
- const r = sf && sizeScale ? sizeScale(d[sf]) : defaultRadius
44
- const shapeName = symf && symbolMap ? (symbolMap.get(d[symf]) ?? 'circle') : null
45
- const symbolPath = shapeName ? buildSymbolPath(shapeName, r) : null
46
- return {
47
- data: d,
48
- cx: xScale(d[xf]),
49
- cy: yScale(d[yf]),
50
- r,
51
- fill: colorEntry.fill,
52
- stroke: colorEntry.stroke,
53
- symbolPath,
54
- key: colorKey
55
- }
56
- })
74
+ export function buildPoints(
75
+ data,
76
+ channels,
77
+ xScale,
78
+ yScale,
79
+ colors,
80
+ sizeScale,
81
+ symbolMap,
82
+ defaultRadius = 5,
83
+ jitter = null
84
+ ) {
85
+ const { x: xf, y: yf, color: cf, size: sf, symbol: symf } = channels
86
+ return data.map((d, i) => {
87
+ const colorKey = cf ? d[cf] : null
88
+ const colorEntry = colors?.get(colorKey) ?? { fill: '#888', stroke: '#444' }
89
+ const r = sf && sizeScale ? sizeScale(d[sf]) : defaultRadius
90
+ const shapeName = symf && symbolMap ? (symbolMap.get(d[symf]) ?? 'circle') : null
91
+ const symbolPath = shapeName ? buildSymbolPath(shapeName, r) : null
92
+ const jx = jitter?.width ? jitterOffset(i, jitter.width) : 0
93
+ const jy = jitter?.height ? jitterOffset(i + 100000, jitter.height) : 0
94
+ return {
95
+ data: d,
96
+ cx: xScale(d[xf]) + jx,
97
+ cy: yScale(d[yf]) + jy,
98
+ r,
99
+ fill: colorEntry.fill,
100
+ stroke: colorEntry.stroke,
101
+ symbolPath,
102
+ key: colorKey
103
+ }
104
+ })
57
105
  }
@@ -21,70 +21,70 @@ const ANCHOR_ORDER = ['iqr_max', 'q3', 'median', 'q1', 'iqr_min']
21
21
  * @returns {Array}
22
22
  */
23
23
  export function buildViolins(data, channels, xScale, yScale, colors) {
24
- const { x: xf, fill: ff } = channels
25
- const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
26
- const grouped = ff && ff !== xf
24
+ const { x: xf, fill: ff } = channels
25
+ const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 40
26
+ const grouped = ff && ff !== xf
27
27
 
28
- const pathGen = line()
29
- .x((pt) => pt.x)
30
- .y((pt) => pt.y)
31
- .curve(curveCatmullRom.alpha(0.5))
28
+ const pathGen = line()
29
+ .x((pt) => pt.x)
30
+ .y((pt) => pt.y)
31
+ .curve(curveCatmullRom.alpha(0.5))
32
32
 
33
- if (grouped) {
34
- const fillValues = [...new Set(data.map((d) => d[ff]))]
35
- const n = fillValues.length
36
- const subBandWidth = bw / n
37
- const halfMax = subBandWidth * 0.45
33
+ if (grouped) {
34
+ const fillValues = [...new Set(data.map((d) => d[ff]))]
35
+ const n = fillValues.length
36
+ const subBandWidth = bw / n
37
+ const halfMax = subBandWidth * 0.45
38
38
 
39
- return data.map((d) => {
40
- const fillVal = d[ff]
41
- const subIndex = fillValues.indexOf(fillVal)
42
- const bandStart = xScale(d[xf]) ?? 0
43
- const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
44
- const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
39
+ return data.map((d) => {
40
+ const fillVal = d[ff]
41
+ const subIndex = fillValues.indexOf(fillVal)
42
+ const bandStart = xScale(d[xf]) ?? 0
43
+ const cx = bandStart + subIndex * subBandWidth + subBandWidth / 2
44
+ const colorEntry = colors?.get(fillVal) ?? { fill: '#aaa', stroke: '#666' }
45
45
 
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
- }))
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
54
 
55
- return {
56
- data: d,
57
- cx,
58
- d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
59
- fill: colorEntry.fill,
60
- stroke: colorEntry.stroke
61
- }
62
- })
63
- }
55
+ return {
56
+ data: d,
57
+ cx,
58
+ d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
59
+ fill: colorEntry.fill,
60
+ stroke: colorEntry.stroke
61
+ }
62
+ })
63
+ }
64
64
 
65
- // Non-grouped: one violin per x category
66
- const halfMax = bw * 0.45
65
+ // Non-grouped: one violin per x category
66
+ const halfMax = bw * 0.45
67
67
 
68
- return data.map((d) => {
69
- const fillKey = ff ? d[ff] : d[xf]
70
- const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
71
- const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
68
+ return data.map((d) => {
69
+ const fillKey = ff ? d[ff] : d[xf]
70
+ const colorEntry = colors?.get(fillKey) ?? { fill: '#aaa', stroke: '#666' }
71
+ const cx = (xScale(d[xf]) ?? 0) + (typeof xScale.bandwidth === 'function' ? bw / 2 : 0)
72
72
 
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
- }))
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
81
 
82
- return {
83
- data: d,
84
- cx,
85
- d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
86
- fill: colorEntry.fill,
87
- stroke: colorEntry.stroke
88
- }
89
- })
82
+ return {
83
+ data: d,
84
+ cx,
85
+ d: pathGen([...rightPts, ...leftPts, rightPts[0]]),
86
+ fill: colorEntry.fill,
87
+ stroke: colorEntry.stroke
88
+ }
89
+ })
90
90
  }