@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
@@ -8,21 +8,32 @@
8
8
  y?: string
9
9
  fill?: string
10
10
  stat?: string
11
+ /** Offset the box to one half of the band (raincloud/split plots). Default 'center'. */
12
+ side?: 'left' | 'right' | 'center'
13
+ /** Box thickness as a fraction of the (sub-)band — use a small value (e.g. 0.16) for a
14
+ * thin raincloud box. Defaults to the full box width. */
15
+ width?: number
16
+ /** Data field driving a texture fill (from the shared pattern set) instead of a solid fill. */
17
+ pattern?: string
11
18
  options?: { opacity?: number }
12
19
  }
13
20
 
14
- let { x, y, fill, stat = 'boxplot', options = {} }: Props = $props()
21
+ let { x, y, fill, stat = 'boxplot', side = 'center', width, pattern, options = {} }: Props = $props()
15
22
 
16
23
  const plotState = getContext<PlotState>('plot-state')
17
24
  let id = $state<string | null>(null)
18
25
 
19
- // fill ?? x drives the colors map for both box interior and whisker strokes
20
- const fillChannel = $derived(fill ?? x)
26
+ // Fall back to the root Plot channels when the geom's own x/y are omitted, so
27
+ // <Plot.Box> composes under <Plot.Root x y> without silently NaN-ing.
28
+ const xf = $derived(x ?? plotState.channels?.x)
29
+ const yf = $derived(y ?? plotState.channels?.y)
30
+ // fill ?? effective-x drives the colors map for both box interior and whisker strokes
31
+ const fillChannel = $derived(fill ?? xf)
21
32
 
22
33
  onMount(() => {
23
34
  id = plotState.registerGeom({
24
35
  type: 'box',
25
- channels: { x, y, color: fillChannel },
36
+ channels: { x: xf, y: yf, color: fillChannel, pattern },
26
37
  stat,
27
38
  options
28
39
  })
@@ -32,91 +43,135 @@
32
43
  })
33
44
 
34
45
  $effect(() => {
35
- if (id) plotState.updateGeom(id, { channels: { x, y, color: fillChannel }, stat })
46
+ if (id)
47
+ plotState.updateGeom(id, { channels: { x: xf, y: yf, color: fillChannel, pattern }, stat })
36
48
  })
37
49
 
38
50
  const data = $derived(id ? plotState.geomData(id) : [])
39
51
  const xScale = $derived(plotState.xScale)
40
52
  const yScale = $derived(plotState.yScale)
41
53
  const colors = $derived(plotState.colors)
54
+ const patterns = $derived(plotState.patterns)
42
55
 
43
56
  const boxes = $derived.by(() => {
44
57
  if (!data?.length || !xScale || !yScale) return []
45
- return buildBoxes(data, { x, fill: fillChannel }, xScale, yScale, colors)
58
+ return buildBoxes(data, { x: xf, fill: fillChannel, pattern }, xScale, yScale, colors, {
59
+ side,
60
+ width,
61
+ patterns
62
+ })
46
63
  })
47
64
  </script>
48
65
 
49
66
  {#if boxes.length > 0}
50
67
  <g data-plot-geom="box">
51
- {#each boxes as box, i (`${String(box.cx)}::${i}`)}
52
- {@const x0 = box.cx - box.width / 2}
53
- {@const xMid = box.cx}
54
- {@const xCap0 = box.cx - box.whiskerWidth / 2}
55
- {@const xCap1 = box.cx + box.whiskerWidth / 2}
56
- <!-- Box body (IQR): lighter fill shade -->
57
- <rect
58
- x={x0}
59
- y={box.q3}
60
- width={box.width}
61
- height={Math.max(0, box.q1 - box.q3)}
62
- fill={box.fill}
63
- fill-opacity={options?.opacity ?? plotState.chartPreset.opacity.box}
64
- stroke={box.stroke}
65
- stroke-width="1"
66
- data-plot-element="box-body"
67
- role="presentation"
68
- onmouseenter={() => plotState.setHovered(box.data)}
69
- onmouseleave={() => plotState.clearHovered()}
70
- />
71
- <!-- Median line: darker stroke shade -->
72
- <line
73
- x1={x0}
74
- y1={box.median}
75
- x2={x0 + box.width}
76
- y2={box.median}
77
- stroke={box.stroke}
78
- stroke-width="2"
79
- data-plot-element="box-median"
80
- />
81
- <!-- Lower whisker (q1 to iqr_min) -->
82
- <line
83
- x1={xMid}
84
- y1={box.q1}
85
- x2={xMid}
86
- y2={box.iqr_min}
87
- stroke={box.stroke}
88
- stroke-width="1"
89
- data-plot-element="box-whisker"
90
- />
91
- <!-- Upper whisker (q3 to iqr_max) -->
92
- <line
93
- x1={xMid}
94
- y1={box.q3}
95
- x2={xMid}
96
- y2={box.iqr_max}
97
- stroke={box.stroke}
98
- stroke-width="1"
99
- data-plot-element="box-whisker"
100
- />
101
- <!-- Lower whisker cap -->
102
- <line
103
- x1={xCap0}
104
- y1={box.iqr_min}
105
- x2={xCap1}
106
- y2={box.iqr_min}
107
- stroke={box.stroke}
108
- stroke-width="1"
109
- />
110
- <!-- Upper whisker cap -->
111
- <line
112
- x1={xCap0}
113
- y1={box.iqr_max}
114
- x2={xCap1}
115
- y2={box.iqr_max}
116
- stroke={box.stroke}
117
- stroke-width="1"
118
- />
119
- <!-- Outlier rendering deferred: buildBoxes does not compute outliers yet -->
120
- {/each}
68
+ {#each boxes as box, i (`${String(box.cx)}::${i}`)}
69
+ <!-- Each primitive is placed through plotState.place(band, value) so the whole
70
+ box transposes correctly under orientation='horizontal' (flip). -->
71
+ {@const c1 = plotState.place(box.cx - box.width / 2, box.q3)}
72
+ {@const c2 = plotState.place(box.cx + box.width / 2, box.q1)}
73
+ {@const mA = plotState.place(box.cx - box.width / 2, box.median)}
74
+ {@const mB = plotState.place(box.cx + box.width / 2, box.median)}
75
+ {@const wLoA = plotState.place(box.cx, box.q1)}
76
+ {@const wLoB = plotState.place(box.cx, box.iqr_min)}
77
+ {@const wHiA = plotState.place(box.cx, box.q3)}
78
+ {@const wHiB = plotState.place(box.cx, box.iqr_max)}
79
+ {@const capLoA = plotState.place(box.cx - box.whiskerWidth / 2, box.iqr_min)}
80
+ {@const capLoB = plotState.place(box.cx + box.whiskerWidth / 2, box.iqr_min)}
81
+ {@const capHiA = plotState.place(box.cx - box.whiskerWidth / 2, box.iqr_max)}
82
+ {@const capHiB = plotState.place(box.cx + box.whiskerWidth / 2, box.iqr_max)}
83
+ <!-- Box body (IQR): lighter fill shade -->
84
+ <rect
85
+ x={Math.min(c1.x, c2.x)}
86
+ y={Math.min(c1.y, c2.y)}
87
+ width={Math.abs(c2.x - c1.x)}
88
+ height={Math.abs(c2.y - c1.y)}
89
+ fill={box.fill}
90
+ fill-opacity={options?.opacity ?? plotState.chartPreset.opacity.box}
91
+ stroke={box.stroke}
92
+ stroke-width="1"
93
+ data-plot-element="box-body"
94
+ role="graphics-symbol"
95
+ aria-label={`${String(box.data[xf ?? ''] ?? 'box')}: median ${box.data.median}, IQR ${box.data.q1}–${box.data.q3}`}
96
+ onmouseenter={() => plotState.setHovered(box.data)}
97
+ onmouseleave={() => plotState.clearHovered()}
98
+ />
99
+ {#if box.patternId}
100
+ <!-- Texture fill overlay (matches the box body) -->
101
+ <rect
102
+ x={Math.min(c1.x, c2.x)}
103
+ y={Math.min(c1.y, c2.y)}
104
+ width={Math.abs(c2.x - c1.x)}
105
+ height={Math.abs(c2.y - c1.y)}
106
+ fill="url(#{box.patternId})"
107
+ pointer-events="none"
108
+ />
109
+ {/if}
110
+ <!-- Median line: darker stroke shade -->
111
+ <line
112
+ x1={mA.x}
113
+ y1={mA.y}
114
+ x2={mB.x}
115
+ y2={mB.y}
116
+ stroke={box.stroke}
117
+ stroke-width="2"
118
+ data-plot-element="box-median"
119
+ />
120
+ <!-- Lower whisker (q1 to iqr_min) -->
121
+ <line
122
+ x1={wLoA.x}
123
+ y1={wLoA.y}
124
+ x2={wLoB.x}
125
+ y2={wLoB.y}
126
+ stroke={box.stroke}
127
+ stroke-width="1"
128
+ data-plot-element="box-whisker"
129
+ />
130
+ <!-- Upper whisker (q3 to iqr_max) -->
131
+ <line
132
+ x1={wHiA.x}
133
+ y1={wHiA.y}
134
+ x2={wHiB.x}
135
+ y2={wHiB.y}
136
+ stroke={box.stroke}
137
+ stroke-width="1"
138
+ data-plot-element="box-whisker"
139
+ />
140
+ <!-- Lower whisker cap -->
141
+ <line
142
+ x1={capLoA.x}
143
+ y1={capLoA.y}
144
+ x2={capLoB.x}
145
+ y2={capLoB.y}
146
+ stroke={box.stroke}
147
+ stroke-width="1"
148
+ />
149
+ <!-- Upper whisker cap -->
150
+ <line
151
+ x1={capHiA.x}
152
+ y1={capHiA.y}
153
+ x2={capHiB.x}
154
+ y2={capHiB.y}
155
+ stroke={box.stroke}
156
+ stroke-width="1"
157
+ />
158
+ <!-- Outliers: individual points beyond the 1.5·IQR fence -->
159
+ {#each box.outliers as o, oi (`${i}-outlier-${oi}`)}
160
+ {@const op = plotState.place(box.cx, o.cy)}
161
+ <circle
162
+ cx={op.x}
163
+ cy={op.y}
164
+ r="2"
165
+ fill="none"
166
+ stroke={box.stroke}
167
+ stroke-width="1"
168
+ data-plot-element="box-outlier"
169
+ role="graphics-symbol"
170
+ aria-label={`outlier: ${o.value}`}
171
+ onmouseenter={() => plotState.setHovered({ ...box.data, value: o.value })}
172
+ onmouseleave={() => plotState.clearHovered()}
173
+ />
174
+ {/each}
175
+ {/each}
121
176
  </g>
122
177
  {/if}
@@ -3,6 +3,13 @@ type Props = {
3
3
  y?: string;
4
4
  fill?: string;
5
5
  stat?: string;
6
+ /** Offset the box to one half of the band (raincloud/split plots). Default 'center'. */
7
+ side?: 'left' | 'right' | 'center';
8
+ /** Box thickness as a fraction of the (sub-)band — use a small value (e.g. 0.16) for a
9
+ * thin raincloud box. Defaults to the full box width. */
10
+ width?: number;
11
+ /** Data field driving a texture fill (from the shared pattern set) instead of a solid fill. */
12
+ pattern?: string;
6
13
  options?: {
7
14
  opacity?: number;
8
15
  };
@@ -81,18 +81,24 @@
81
81
  const bodyTop = yScale(isUp ? closeVal : openVal)
82
82
  const bodyBottom = yScale(isUp ? openVal : closeVal)
83
83
 
84
+ // Place (band, value) points so the candle transposes under orientation flip.
85
+ const b1 = plotState.place(xPos, bodyTop)
86
+ const b2 = plotState.place(xPos + bodyWidth, bodyBottom)
87
+ const wa = plotState.place(xPos + bodyWidth / 2, yScale(highVal))
88
+ const wb = plotState.place(xPos + bodyWidth / 2, yScale(lowVal))
84
89
  return {
85
90
  key: `${d[x ?? '']}-${i}`,
86
91
  // Body
87
- bodyX: xPos,
88
- bodyY: bodyTop,
89
- bodyWidth,
90
- bodyHeight: Math.max(1, bodyBottom - bodyTop),
92
+ bodyX: Math.min(b1.x, b2.x),
93
+ bodyY: Math.min(b1.y, b2.y),
94
+ bodyWidth: Math.abs(b2.x - b1.x),
95
+ bodyHeight: Math.max(1, Math.abs(b2.y - b1.y)),
91
96
  fill: isUp ? upColor : downColor,
92
97
  // Wick
93
- wickX: xPos + bodyWidth / 2,
94
- wickTop: yScale(highVal),
95
- wickBottom: yScale(lowVal),
98
+ wickX1: wa.x,
99
+ wickY1: wa.y,
100
+ wickX2: wb.x,
101
+ wickY2: wb.y,
96
102
  data: d
97
103
  }
98
104
  })
@@ -104,10 +110,10 @@
104
110
  {#each candles as c (c.key)}
105
111
  <!-- Wick (high to low) -->
106
112
  <line
107
- x1={c.wickX}
108
- y1={c.wickTop}
109
- x2={c.wickX}
110
- y2={c.wickBottom}
113
+ x1={c.wickX1}
114
+ y1={c.wickY1}
115
+ x2={c.wickX2}
116
+ y2={c.wickY2}
111
117
  stroke={c.fill}
112
118
  stroke-width={wickWidth}
113
119
  data-plot-element="wick"
@@ -0,0 +1,105 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte'
3
+ import type { PlotState } from '../PlotState.svelte.js'
4
+ import { resolveHighlight } from '../lib/highlight.js'
5
+ import { scalePos } from '../lib/scale.js'
6
+
7
+ type Row = Record<string, unknown>
8
+ type Props = {
9
+ x?: string
10
+ y?: string
11
+ highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean)
12
+ label?: boolean | string | ((row: Row) => unknown)
13
+ }
14
+ let { x, y, highlight = undefined, label = false }: Props = $props()
15
+
16
+ const state = getContext<PlotState>('plot-state')
17
+
18
+ const marks = $derived.by(() => {
19
+ const rows = state?.data ?? []
20
+ const xs = state?.xScale
21
+ const ys = state?.yScale
22
+ if (!xs || !ys || !x || !y) return []
23
+ const selectedRows = state?.selectedRows ?? []
24
+ const selectedSet = new Set(selectedRows)
25
+ const out = []
26
+ const seen = new Set()
27
+ if (highlight !== null && highlight !== undefined && rows.length) {
28
+ for (const i of resolveHighlight(rows, highlight, { y })) {
29
+ const row = rows[i]
30
+ out.push({
31
+ key: `h${i}`,
32
+ cx: scalePos(xs, row[x]),
33
+ cy: ys(Number(row[y])),
34
+ row,
35
+ // A statically-highlighted point that is also selected renders as
36
+ // selected — selection is the stronger, interactive state.
37
+ selected: selectedSet.has(row)
38
+ })
39
+ seen.add(row)
40
+ }
41
+ }
42
+ selectedRows.forEach((row, j) => {
43
+ if (seen.has(row)) return
44
+ out.push({
45
+ key: `s${j}`,
46
+ cx: scalePos(xs, row[x]),
47
+ cy: ys(Number(row[y])),
48
+ row,
49
+ selected: true
50
+ })
51
+ })
52
+ return out.filter((m) => Number.isFinite(m.cx) && Number.isFinite(m.cy))
53
+ })
54
+
55
+ function resolveLabel(row: Row): string | null {
56
+ if (!label) return null
57
+ if (label === true) return String((y ? row[y] : '') ?? '')
58
+ if (typeof label === 'function') return String(label(row) ?? '')
59
+ return String(row[label] ?? '')
60
+ }
61
+ </script>
62
+
63
+ {#if marks.length}
64
+ <g data-plot-geom="highlight">
65
+ {#each marks as m (m.key)}
66
+ <circle
67
+ cx={m.cx}
68
+ cy={m.cy}
69
+ data-plot-highlight
70
+ data-plot-selected={m.selected ? 'true' : undefined}
71
+ />
72
+ {#if label}
73
+ {@const text = resolveLabel(m.row)}
74
+ {#if text}
75
+ <text x={m.cx} y={m.cy} dy="-8" text-anchor="middle" data-plot-highlight-label
76
+ >{text}</text
77
+ >
78
+ {/if}
79
+ {/if}
80
+ {/each}
81
+ </g>
82
+ {/if}
83
+
84
+ <style>
85
+ [data-plot-highlight] {
86
+ fill: var(--chart-highlight-color, rgb(var(--color-accent-500, 194 65 12)));
87
+ stroke: var(--chart-highlight-ring, none);
88
+ r: var(--chart-highlight-radius, 4);
89
+ /* Visual only — let clicks pass through to the data hit target beneath so the
90
+ highlighted/selected observation stays selectable (and de-selectable). */
91
+ pointer-events: none;
92
+ }
93
+ [data-plot-highlight-label] {
94
+ fill: currentColor;
95
+ font-size: 11px;
96
+ }
97
+ [data-plot-highlight][data-plot-selected='true'] {
98
+ stroke: var(
99
+ --chart-selected-ring,
100
+ var(--chart-highlight-color, rgb(var(--color-accent-500, 194 65 12)))
101
+ );
102
+ stroke-width: var(--chart-selected-ring-width, 2);
103
+ fill: var(--chart-selected-fill, var(--color-paper, #fff));
104
+ }
105
+ </style>
@@ -0,0 +1,10 @@
1
+ type Row = Record<string, unknown>;
2
+ type Props = {
3
+ x?: string;
4
+ y?: string;
5
+ highlight?: 'first' | 'last' | 'min' | 'max' | number | ((row: Row, i: number) => boolean);
6
+ label?: boolean | string | ((row: Row) => unknown);
7
+ };
8
+ declare const Highlight: import("svelte").Component<Props, {}, "">;
9
+ type Highlight = ReturnType<typeof Highlight>;
10
+ export default Highlight;
@@ -0,0 +1,82 @@
1
+ <script lang="ts">
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import type { PlotState } from '../PlotState.svelte.js'
4
+ import { buildSwarm } from '../lib/brewing/marks/swarm.js'
5
+
6
+ type Props = {
7
+ x?: string
8
+ y?: string
9
+ fill?: string
10
+ r?: number
11
+ method?: 'jitter' | 'swarm'
12
+ /** Confine points to one half of the band (raincloud/split plots). Default 'center'. */
13
+ side?: 'left' | 'right' | 'center'
14
+ options?: { opacity?: number }
15
+ }
16
+
17
+ let { x, y, fill, r = 2, method = 'jitter', side = 'center', options = {} }: Props = $props()
18
+
19
+ const plotState = getContext<PlotState>('plot-state')
20
+ let id = $state<string | null>(null)
21
+
22
+ // Fall back to the root Plot channels when the geom omits x/y (composes under
23
+ // <Plot.Root x y> without silently NaN-ing).
24
+ const xf = $derived(x ?? plotState.channels?.x)
25
+ const yf = $derived(y ?? plotState.channels?.y)
26
+ // fill ?? effective-x drives the color lookup
27
+ const fillChannel = $derived(fill ?? xf)
28
+
29
+ onMount(() => {
30
+ id = plotState.registerGeom({
31
+ type: 'jitter',
32
+ channels: { x: xf, y: yf, color: fillChannel },
33
+ stat: 'identity',
34
+ options
35
+ })
36
+ })
37
+ onDestroy(() => {
38
+ if (id) plotState.unregisterGeom(id)
39
+ })
40
+
41
+ $effect(() => {
42
+ if (id) plotState.updateGeom(id, { channels: { x: xf, y: yf, color: fillChannel }, stat: 'identity' })
43
+ })
44
+
45
+ const data = $derived(id ? plotState.geomData(id) : [])
46
+ const xScale = $derived(plotState.xScale)
47
+ const yScale = $derived(plotState.yScale)
48
+ const colors = $derived(plotState.colors)
49
+
50
+ const points = $derived.by(() => {
51
+ if (!data?.length || !xScale || !yScale) return []
52
+ const raw = buildSwarm(data, { x: xf, y: yf, fill: fillChannel }, xScale, yScale, colors, { method, r, side })
53
+ if (!plotState.isFlipped) return raw
54
+ // Horizontal: the band axis (swarm spread) stands up on screen-Y and the value
55
+ // axis runs along screen-X — swap the computed coords through place().
56
+ return raw.map((p) => {
57
+ const s = plotState.place(p.cx, p.cy)
58
+ return { ...p, cx: s.x, cy: s.y }
59
+ })
60
+ })
61
+ </script>
62
+
63
+ {#if points.length > 0}
64
+ <g data-plot-geom="jitter">
65
+ {#each points as pt, i (`${String(pt.cx)}::${i}`)}
66
+ <circle
67
+ cx={pt.cx}
68
+ cy={pt.cy}
69
+ {r}
70
+ fill={pt.fill}
71
+ fill-opacity={options?.opacity ?? plotState.chartPreset.opacity.point}
72
+ stroke={pt.stroke}
73
+ stroke-width="0.5"
74
+ data-plot-element="jitter-point"
75
+ role="graphics-symbol"
76
+ aria-label={`(${String(pt.data[xf ?? ''])}, ${String(pt.data[yf ?? ''])})`}
77
+ onmouseenter={() => plotState.setHovered(pt.data)}
78
+ onmouseleave={() => plotState.clearHovered()}
79
+ />
80
+ {/each}
81
+ </g>
82
+ {/if}
@@ -0,0 +1,15 @@
1
+ type Props = {
2
+ x?: string;
3
+ y?: string;
4
+ fill?: string;
5
+ r?: number;
6
+ method?: 'jitter' | 'swarm';
7
+ /** Confine points to one half of the band (raincloud/split plots). Default 'center'. */
8
+ side?: 'left' | 'right' | 'center';
9
+ options?: {
10
+ opacity?: number;
11
+ };
12
+ };
13
+ declare const Jitter: import("svelte").Component<Props, {}, "">;
14
+ type Jitter = ReturnType<typeof Jitter>;
15
+ export default Jitter;
@@ -4,6 +4,7 @@
4
4
  import { buildLines } from '../lib/brewing/marks/lines.js'
5
5
  import { buildSymbolPath } from '../lib/brewing/marks/points.js'
6
6
  import { keyboardNav } from '../lib/keyboard-nav.js'
7
+ import { buildSelectDetail } from '../lib/select.js'
7
8
  import LabelPill from './LabelPill.svelte'
8
9
 
9
10
  type Row = Record<string, unknown>
@@ -48,6 +49,18 @@
48
49
  const plotState = getContext<PlotState>('plot-state')
49
50
  let id = $state<string | null>(null)
50
51
 
52
+ function selectPoint(
53
+ pt: { data: Row },
54
+ seg: { key?: unknown },
55
+ event: MouseEvent | KeyboardEvent
56
+ ) {
57
+ onselect?.(pt.data)
58
+ if (plotState.interactive)
59
+ plotState.handleSelect(
60
+ buildSelectDetail(pt.data, plotState.data.indexOf(pt.data), { x, y }, 'line', seg?.key, event)
61
+ )
62
+ }
63
+
51
64
  onMount(() => {
52
65
  id = plotState.registerGeom({
53
66
  type: 'line',
@@ -72,7 +85,7 @@
72
85
 
73
86
  const lines = $derived.by(() => {
74
87
  if (!data?.length || !xScale || !yScale) return []
75
- return buildLines(data, { x, y, color }, xScale, yScale, colors, options.curve)
88
+ return buildLines(data, { x, y, color }, xScale, yScale, colors, options.curve, plotState.place.bind(plotState))
76
89
  })
77
90
 
78
91
  const markerRadius = $derived(options.markerRadius ?? 4)
@@ -91,7 +104,7 @@
91
104
  data-plot-element="line"
92
105
  />
93
106
  {#if symbolField && symbolMap}
94
- {#each seg.points as pt (`${pt.x}::${pt.y}`)}
107
+ {#each seg.points as pt (pt.i)}
95
108
  <path
96
109
  transform="translate({pt.x},{pt.y})"
97
110
  d={buildSymbolPath(symbolMap.get(pt.data[symbolField]) ?? 'circle', markerRadius)}
@@ -103,7 +116,7 @@
103
116
  {/each}
104
117
  {/if}
105
118
  {#if label}
106
- {#each seg.points as pt (`label::${pt.x}::${pt.y}`)}
119
+ {#each seg.points as pt (`label::${pt.i}`)}
107
120
  {@const text = resolveLabel(pt.data)}
108
121
  {#if text}
109
122
  <LabelPill
@@ -117,21 +130,25 @@
117
130
  {/if}
118
131
  <!-- Invisible hit areas for tooltip and selection -->
119
132
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
120
- {#each seg.points as pt (`hover::${pt.x}::${pt.y}`)}
133
+ {#each seg.points as pt (`hover::${pt.i}`)}
121
134
  <circle
122
135
  cx={pt.x}
123
136
  cy={pt.y}
124
137
  r="8"
125
138
  fill="transparent"
126
139
  stroke="none"
127
- role={onselect || keyboard ? 'button' : 'presentation'}
128
- tabindex={onselect || keyboard ? 0 : undefined}
129
- style:cursor={onselect ? 'pointer' : undefined}
140
+ role={onselect || keyboard || plotState.interactive ? 'button' : 'presentation'}
141
+ tabindex={onselect || keyboard || plotState.interactive ? 0 : undefined}
142
+ style:cursor={onselect || keyboard || plotState.interactive ? 'pointer' : undefined}
130
143
  data-plot-element="line-hover"
131
144
  onmouseenter={() => plotState.setHovered(pt.data)}
132
145
  onmouseleave={() => plotState.clearHovered()}
133
- onclick={onselect ? () => onselect(pt.data) : undefined}
134
- onkeydown={onselect ? (e) => (e.key === 'Enter' || e.key === ' ') && onselect(pt.data) : undefined}
146
+ onclick={onselect || keyboard || plotState.interactive
147
+ ? (e) => selectPoint(pt, seg, e)
148
+ : undefined}
149
+ onkeydown={onselect || keyboard || plotState.interactive
150
+ ? (e) => (e.key === 'Enter' || e.key === ' ') && selectPoint(pt, seg, e)
151
+ : undefined}
135
152
  use:keyboardNav={keyboard}
136
153
  />
137
154
  {/each}