@rokkit/chart 1.3.7 → 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 (76) 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 +19 -10
  6. package/dist/Plot/Root.svelte +64 -46
  7. package/dist/Plot/Root.svelte.d.ts +27 -2
  8. package/dist/Plot/Timeline.svelte +39 -13
  9. package/dist/Plot/index.d.ts +4 -4
  10. package/dist/Plot/index.js +4 -4
  11. package/dist/Plot.svelte +95 -178
  12. package/dist/Plot.svelte.d.ts +4 -0
  13. package/dist/PlotState.svelte.d.ts +16 -0
  14. package/dist/PlotState.svelte.js +118 -16
  15. package/dist/PlotSurface.svelte +162 -0
  16. package/dist/PlotSurface.svelte.d.ts +25 -0
  17. package/dist/charts/AreaChart.svelte +4 -1
  18. package/dist/charts/AreaChart.svelte.d.ts +1 -0
  19. package/dist/charts/BarChart.svelte +32 -3
  20. package/dist/charts/BarChart.svelte.d.ts +10 -1
  21. package/dist/charts/BoxPlot.svelte +4 -2
  22. package/dist/charts/BoxPlot.svelte.d.ts +1 -0
  23. package/dist/charts/BubbleChart.svelte +34 -3
  24. package/dist/charts/BubbleChart.svelte.d.ts +11 -1
  25. package/dist/charts/LineChart.svelte +1 -1
  26. package/dist/charts/PieChart.svelte +1 -1
  27. package/dist/charts/ScatterPlot.svelte +32 -3
  28. package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
  29. package/dist/charts/ViolinPlot.svelte +4 -2
  30. package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
  31. package/dist/examples/BarChartExample.svelte +1 -1
  32. package/dist/geoms/Arc.svelte +3 -2
  33. package/dist/geoms/Area.svelte +2 -2
  34. package/dist/geoms/Bar.svelte +17 -6
  35. package/dist/geoms/Box.svelte +131 -76
  36. package/dist/geoms/Box.svelte.d.ts +7 -0
  37. package/dist/geoms/Candlestick.svelte +17 -11
  38. package/dist/geoms/Jitter.svelte +82 -0
  39. package/dist/geoms/Jitter.svelte.d.ts +15 -0
  40. package/dist/geoms/Line.svelte +1 -1
  41. package/dist/geoms/Point.svelte +7 -1
  42. package/dist/geoms/Rule.svelte +113 -0
  43. package/dist/geoms/Rule.svelte.d.ts +16 -0
  44. package/dist/geoms/Violin.svelte +30 -6
  45. package/dist/geoms/Violin.svelte.d.ts +5 -0
  46. package/dist/geoms/Waterfall.svelte +16 -9
  47. package/dist/geoms/lib/areas.d.ts +6 -2
  48. package/dist/geoms/lib/areas.js +17 -11
  49. package/dist/geoms/lib/bars.d.ts +18 -3
  50. package/dist/geoms/lib/bars.js +70 -96
  51. package/dist/index.d.ts +24 -4
  52. package/dist/index.js +25 -5
  53. package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
  54. package/dist/lib/brewing/marks/arcs.js +7 -1
  55. package/dist/lib/brewing/marks/boxes.d.ts +11 -2
  56. package/dist/lib/brewing/marks/boxes.js +32 -11
  57. package/dist/lib/brewing/marks/lines.d.ts +4 -1
  58. package/dist/lib/brewing/marks/lines.js +18 -15
  59. package/dist/lib/brewing/marks/swarm.d.ts +32 -0
  60. package/dist/lib/brewing/marks/swarm.js +125 -0
  61. package/dist/lib/brewing/marks/violins.d.ts +4 -20
  62. package/dist/lib/brewing/marks/violins.js +41 -25
  63. package/dist/lib/brewing/stats.d.ts +3 -1
  64. package/dist/lib/brewing/stats.js +28 -11
  65. package/dist/lib/plot/scales.js +12 -7
  66. package/dist/lib/plot/types.d.ts +9 -1
  67. package/dist/lib/plot/types.js +3 -1
  68. package/package.json +1 -1
  69. package/dist/Plot/Area.svelte +0 -54
  70. package/dist/Plot/Area.svelte.d.ts +0 -14
  71. package/dist/Plot/Bar.svelte +0 -69
  72. package/dist/Plot/Bar.svelte.d.ts +0 -11
  73. package/dist/Plot/Line.svelte +0 -54
  74. package/dist/Plot/Line.svelte.d.ts +0 -13
  75. package/dist/Plot/Point.svelte +0 -58
  76. package/dist/Plot/Point.svelte.d.ts +0 -13
@@ -2,7 +2,7 @@
2
2
  import { getContext, onMount, onDestroy } from 'svelte'
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import type { createCrossFilter } from '../crossfilter/createCrossFilter.svelte.js'
5
- import { buildGroupedBars, buildStackedBars, buildHorizontalBars } from './lib/bars.js'
5
+ import { buildBars, buildStackedBars } from './lib/bars.js'
6
6
  import { keyboardNav } from '../lib/keyboard-nav.js'
7
7
  import { buildSelectDetail } from '../lib/select.js'
8
8
  import LabelPill from './LabelPill.svelte'
@@ -102,13 +102,24 @@
102
102
  const bars = $derived.by(() => {
103
103
  if (!data?.length || !xScale || !yScale) return []
104
104
  const channels = { x, y, color: colorChannel, pattern }
105
- if (effectiveOrientation === 'horizontal') {
106
- return buildHorizontalBars(data, channels, xScale, yScale, colors, innerHeight)
107
- }
105
+ // One builder, one path: express bars in (category, value) space and map to screen via
106
+ // place() (identity vertical, swap when flipped). `continuousCategory` keeps a linear
107
+ // category axis (the bar-chart race's tweened rank) so it positions/tweens smoothly.
108
+ const place = plotState.place.bind(plotState)
108
109
  if (options.stack) {
109
- return buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
110
+ return buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns, place)
110
111
  }
111
- return buildGroupedBars(data, channels, xScale, yScale, colors, innerHeight, patterns)
112
+ return buildBars(
113
+ data,
114
+ channels,
115
+ xScale,
116
+ yScale,
117
+ colors,
118
+ innerHeight,
119
+ patterns,
120
+ place,
121
+ plotState.continuousCategory
122
+ )
112
123
  })
113
124
 
114
125
  let dimmedByKey = $state<Record<string, boolean>>({})
@@ -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,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;
@@ -85,7 +85,7 @@
85
85
 
86
86
  const lines = $derived.by(() => {
87
87
  if (!data?.length || !xScale || !yScale) return []
88
- 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))
89
89
  })
90
90
 
91
91
  const markerRadius = $derived(options.markerRadius ?? 4)
@@ -107,7 +107,7 @@
107
107
 
108
108
  const points = $derived.by(() => {
109
109
  if (!data?.length || !xScale || !yScale) return []
110
- return buildPoints(
110
+ const raw = buildPoints(
111
111
  data,
112
112
  { x, y, color, size, symbol: symbolField },
113
113
  xScale,
@@ -118,6 +118,12 @@
118
118
  defaultRadius,
119
119
  options?.jitter ?? null
120
120
  )
121
+ if (!plotState.isFlipped) return raw
122
+ // Horizontal: swap each point's screen coords (value → screen-X, category → screen-Y).
123
+ return raw.map((p) => {
124
+ const s = plotState.place(p.cx, p.cy)
125
+ return { ...p, cx: s.x, cy: s.y }
126
+ })
121
127
  })
122
128
  </script>
123
129
 
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte'
3
+ import type { PlotState } from '../PlotState.svelte.js'
4
+
5
+ type RuleValue = number | string
6
+ type Props = {
7
+ /** Vertical reference line(s) at these x-axis value(s) (a value, or a band category). */
8
+ x?: RuleValue | RuleValue[]
9
+ /** Horizontal reference line(s) at these y-axis value(s). */
10
+ y?: RuleValue | RuleValue[]
11
+ stroke?: string
12
+ /** SVG stroke-dasharray (default dashed). Pass '' for a solid line. */
13
+ dash?: string
14
+ strokeWidth?: number
15
+ /** Optional label drawn at the line's end. String, or a fn of the value. */
16
+ label?: string | ((value: RuleValue) => string)
17
+ }
18
+
19
+ let {
20
+ x,
21
+ y,
22
+ stroke = undefined,
23
+ dash = '4 4',
24
+ strokeWidth = 1,
25
+ label = undefined
26
+ }: Props = $props()
27
+
28
+ const plotState = getContext<PlotState>('plot-state')
29
+ const xScale = $derived(plotState.xScale)
30
+ const yScale = $derived(plotState.yScale)
31
+
32
+ const toArray = (v: RuleValue | RuleValue[] | undefined): RuleValue[] =>
33
+ v === undefined ? [] : Array.isArray(v) ? v : [v]
34
+
35
+ // Position along an axis: band scales resolve to the band centre, linear scales map directly.
36
+ function axisPos(scale: { (v: unknown): number | undefined; bandwidth?: () => number }, v: unknown) {
37
+ const base = scale(v) ?? 0
38
+ return typeof scale.bandwidth === 'function' ? base + scale.bandwidth() / 2 : base
39
+ }
40
+ // The full [min, max] screen extent of a scale's range (spans the perpendicular axis).
41
+ function axisExtent(scale: { range: () => number[] }) {
42
+ const r = scale.range()
43
+ return [Math.min(...r), Math.max(...r)]
44
+ }
45
+
46
+ const labelFor = (v: RuleValue) =>
47
+ typeof label === 'function' ? label(v) : typeof label === 'string' ? label : null
48
+
49
+ // Each rule is expressed in (x-channel, y-channel) space and placed to screen, so it
50
+ // transposes under orientation flip: a y-value reference is horizontal when vertical and
51
+ // vertical when flipped (it stays perpendicular to the value axis).
52
+ const rules = $derived.by(() => {
53
+ if (!xScale || !yScale) return []
54
+ const place = plotState.place.bind(plotState)
55
+ const out: Array<{
56
+ key: string
57
+ x1: number
58
+ y1: number
59
+ x2: number
60
+ y2: number
61
+ value: RuleValue
62
+ text: string | null
63
+ }> = []
64
+ const [xLo, xHi] = axisExtent(xScale)
65
+ const [yLo, yHi] = axisExtent(yScale)
66
+
67
+ for (const yv of toArray(y)) {
68
+ const yc = axisPos(yScale, yv)
69
+ const a = place(xLo, yc)
70
+ const b = place(xHi, yc)
71
+ out.push({ key: `y-${yv}`, x1: a.x, y1: a.y, x2: b.x, y2: b.y, value: yv, text: labelFor(yv) })
72
+ }
73
+ for (const xv of toArray(x)) {
74
+ const xc = axisPos(xScale, xv)
75
+ const a = place(xc, yLo)
76
+ const b = place(xc, yHi)
77
+ out.push({ key: `x-${xv}`, x1: a.x, y1: a.y, x2: b.x, y2: b.y, value: xv, text: labelFor(xv) })
78
+ }
79
+ return out
80
+ })
81
+
82
+ const strokeColor = $derived(stroke ?? 'currentColor')
83
+ </script>
84
+
85
+ {#if rules.length > 0}
86
+ <g data-plot-geom="rule">
87
+ {#each rules as rule (rule.key)}
88
+ <line
89
+ x1={rule.x1}
90
+ y1={rule.y1}
91
+ x2={rule.x2}
92
+ y2={rule.y2}
93
+ stroke={strokeColor}
94
+ stroke-width={strokeWidth}
95
+ stroke-dasharray={dash || undefined}
96
+ data-plot-element="rule"
97
+ data-plot-value={rule.value}
98
+ />
99
+ {#if rule.text}
100
+ <text
101
+ x={rule.x2}
102
+ y={rule.y2}
103
+ dx="-4"
104
+ dy="-4"
105
+ text-anchor="end"
106
+ font-size="10"
107
+ fill={strokeColor}
108
+ data-plot-element="rule-label"
109
+ >{rule.text}</text>
110
+ {/if}
111
+ {/each}
112
+ </g>
113
+ {/if}
@@ -0,0 +1,16 @@
1
+ type RuleValue = number | string;
2
+ type Props = {
3
+ /** Vertical reference line(s) at these x-axis value(s) (a value, or a band category). */
4
+ x?: RuleValue | RuleValue[];
5
+ /** Horizontal reference line(s) at these y-axis value(s). */
6
+ y?: RuleValue | RuleValue[];
7
+ stroke?: string;
8
+ /** SVG stroke-dasharray (default dashed). Pass '' for a solid line. */
9
+ dash?: string;
10
+ strokeWidth?: number;
11
+ /** Optional label drawn at the line's end. String, or a fn of the value. */
12
+ label?: string | ((value: RuleValue) => string);
13
+ };
14
+ declare const Rule: import("svelte").Component<Props, {}, "">;
15
+ type Rule = ReturnType<typeof Rule>;
16
+ export default Rule;