@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.
- package/dist/AnimatedPlot.svelte +26 -8
- package/dist/AnimatedPlot.svelte.d.ts +5 -0
- package/dist/Chart.svelte +1 -1
- package/dist/FacetPlot.svelte +1 -1
- package/dist/Plot/Axis.svelte +21 -10
- package/dist/Plot/Grid.svelte +32 -13
- package/dist/Plot/Grid.svelte.d.ts +6 -1
- package/dist/Plot/Root.svelte +64 -46
- package/dist/Plot/Root.svelte.d.ts +27 -2
- package/dist/Plot/Timeline.svelte +39 -13
- package/dist/Plot/index.d.ts +4 -4
- package/dist/Plot/index.js +4 -4
- package/dist/Plot.svelte +128 -164
- package/dist/Plot.svelte.d.ts +18 -2
- package/dist/PlotState.svelte.d.ts +23 -0
- package/dist/PlotState.svelte.js +155 -16
- package/dist/PlotSurface.svelte +162 -0
- package/dist/PlotSurface.svelte.d.ts +25 -0
- package/dist/charts/AreaChart.svelte +37 -4
- package/dist/charts/AreaChart.svelte.d.ts +12 -2
- package/dist/charts/BarChart.svelte +32 -3
- package/dist/charts/BarChart.svelte.d.ts +10 -1
- package/dist/charts/BoxPlot.svelte +4 -2
- package/dist/charts/BoxPlot.svelte.d.ts +1 -0
- package/dist/charts/BubbleChart.svelte +34 -3
- package/dist/charts/BubbleChart.svelte.d.ts +11 -1
- package/dist/charts/LineChart.svelte +35 -4
- package/dist/charts/LineChart.svelte.d.ts +11 -2
- package/dist/charts/PieChart.svelte +1 -1
- package/dist/charts/ScatterPlot.svelte +32 -3
- package/dist/charts/ScatterPlot.svelte.d.ts +10 -1
- package/dist/charts/ViolinPlot.svelte +4 -2
- package/dist/charts/ViolinPlot.svelte.d.ts +1 -0
- package/dist/examples/BarChartExample.svelte +1 -1
- package/dist/geoms/Arc.svelte +3 -2
- package/dist/geoms/Area.svelte +20 -3
- package/dist/geoms/Bar.svelte +57 -13
- package/dist/geoms/Box.svelte +131 -76
- package/dist/geoms/Box.svelte.d.ts +7 -0
- package/dist/geoms/Candlestick.svelte +17 -11
- package/dist/geoms/Highlight.svelte +105 -0
- package/dist/geoms/Highlight.svelte.d.ts +10 -0
- package/dist/geoms/Jitter.svelte +82 -0
- package/dist/geoms/Jitter.svelte.d.ts +15 -0
- package/dist/geoms/Line.svelte +26 -9
- package/dist/geoms/Point.svelte +37 -11
- package/dist/geoms/Rule.svelte +113 -0
- package/dist/geoms/Rule.svelte.d.ts +16 -0
- package/dist/geoms/Trend.svelte +66 -0
- package/dist/geoms/Trend.svelte.d.ts +12 -0
- package/dist/geoms/Violin.svelte +30 -6
- package/dist/geoms/Violin.svelte.d.ts +5 -0
- package/dist/geoms/Waterfall.svelte +16 -9
- package/dist/geoms/lib/areas.d.ts +6 -2
- package/dist/geoms/lib/areas.js +17 -11
- package/dist/geoms/lib/bars.d.ts +18 -3
- package/dist/geoms/lib/bars.js +70 -96
- package/dist/index.d.ts +30 -4
- package/dist/index.js +31 -5
- package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +1 -1
- package/dist/lib/brewing/brewer.svelte.d.ts +1 -0
- package/dist/lib/brewing/marks/arcs.js +7 -1
- package/dist/lib/brewing/marks/boxes.d.ts +11 -2
- package/dist/lib/brewing/marks/boxes.js +32 -11
- package/dist/lib/brewing/marks/lines.d.ts +6 -2
- package/dist/lib/brewing/marks/lines.js +19 -12
- package/dist/lib/brewing/marks/swarm.d.ts +32 -0
- package/dist/lib/brewing/marks/swarm.js +125 -0
- package/dist/lib/brewing/marks/violins.d.ts +4 -20
- package/dist/lib/brewing/marks/violins.js +41 -25
- package/dist/lib/brewing/stats.d.ts +3 -1
- package/dist/lib/brewing/stats.js +28 -11
- package/dist/lib/highlight.d.ts +10 -0
- package/dist/lib/highlight.js +43 -0
- package/dist/lib/plot/scales.js +12 -7
- package/dist/lib/plot/types.d.ts +9 -1
- package/dist/lib/plot/types.js +3 -1
- package/dist/lib/scale.d.ts +10 -0
- package/dist/lib/scale.js +14 -0
- package/dist/lib/select.d.ts +22 -0
- package/dist/lib/select.js +22 -0
- package/dist/lib/trend.d.ts +27 -0
- package/dist/lib/trend.js +113 -0
- package/dist/patterns/README.md +1 -1
- package/package.json +1 -1
- package/dist/Plot/Area.svelte +0 -54
- package/dist/Plot/Area.svelte.d.ts +0 -14
- package/dist/Plot/Bar.svelte +0 -69
- package/dist/Plot/Bar.svelte.d.ts +0 -11
- package/dist/Plot/Line.svelte +0 -54
- package/dist/Plot/Line.svelte.d.ts +0 -13
- package/dist/Plot/Point.svelte +0 -58
- package/dist/Plot/Point.svelte.d.ts +0 -13
package/dist/geoms/Box.svelte
CHANGED
|
@@ -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
|
-
//
|
|
20
|
-
|
|
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)
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
88
|
-
bodyY:
|
|
89
|
-
bodyWidth,
|
|
90
|
-
bodyHeight: Math.max(1,
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
108
|
-
y1={c.
|
|
109
|
-
x2={c.
|
|
110
|
-
y2={c.
|
|
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;
|
package/dist/geoms/Line.svelte
CHANGED
|
@@ -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 (
|
|
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.
|
|
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.
|
|
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
|
|
134
|
-
|
|
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}
|