@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.
- package/dist/PlotState.svelte.d.ts +26 -0
- package/dist/index.d.ts +6 -1
- package/dist/lib/brewing/BoxBrewer.svelte.d.ts +3 -5
- package/dist/lib/brewing/QuartileBrewer.svelte.d.ts +9 -0
- package/dist/lib/brewing/ViolinBrewer.svelte.d.ts +3 -4
- package/dist/lib/brewing/colors.d.ts +10 -1
- package/dist/lib/brewing/marks/points.d.ts +17 -2
- package/dist/lib/keyboard-nav.d.ts +15 -0
- package/dist/lib/plot/preset.d.ts +1 -1
- package/dist/lib/preset.d.ts +30 -0
- package/package.json +2 -1
- package/src/AnimatedPlot.svelte +375 -207
- package/src/Chart.svelte +81 -84
- package/src/ChartProvider.svelte +10 -0
- package/src/FacetPlot/Panel.svelte +30 -16
- package/src/FacetPlot.svelte +100 -76
- package/src/Plot/Area.svelte +26 -19
- package/src/Plot/Axis.svelte +81 -59
- package/src/Plot/Bar.svelte +47 -89
- package/src/Plot/Grid.svelte +23 -19
- package/src/Plot/Legend.svelte +213 -147
- package/src/Plot/Line.svelte +31 -21
- package/src/Plot/Point.svelte +35 -22
- package/src/Plot/Root.svelte +46 -91
- package/src/Plot/Timeline.svelte +82 -82
- package/src/Plot/Tooltip.svelte +68 -62
- package/src/Plot.svelte +290 -174
- package/src/PlotState.svelte.js +338 -265
- package/src/Sparkline.svelte +95 -56
- package/src/charts/AreaChart.svelte +22 -20
- package/src/charts/BarChart.svelte +23 -21
- package/src/charts/BoxPlot.svelte +15 -15
- package/src/charts/BubbleChart.svelte +17 -17
- package/src/charts/LineChart.svelte +20 -20
- package/src/charts/PieChart.svelte +30 -20
- package/src/charts/ScatterPlot.svelte +20 -19
- package/src/charts/ViolinPlot.svelte +15 -15
- package/src/crossfilter/CrossFilter.svelte +33 -29
- package/src/crossfilter/FilterBar.svelte +17 -25
- package/src/crossfilter/FilterHistogram.svelte +290 -0
- package/src/crossfilter/FilterSlider.svelte +69 -65
- package/src/crossfilter/createCrossFilter.svelte.js +94 -90
- package/src/geoms/Arc.svelte +114 -69
- package/src/geoms/Area.svelte +67 -39
- package/src/geoms/Bar.svelte +184 -126
- package/src/geoms/Box.svelte +101 -91
- package/src/geoms/LabelPill.svelte +11 -11
- package/src/geoms/Line.svelte +110 -86
- package/src/geoms/Point.svelte +130 -90
- package/src/geoms/Violin.svelte +51 -41
- package/src/geoms/lib/areas.js +122 -99
- package/src/geoms/lib/bars.js +195 -144
- package/src/index.js +21 -14
- package/src/lib/brewing/BoxBrewer.svelte.js +8 -50
- package/src/lib/brewing/CartesianBrewer.svelte.js +11 -7
- package/src/lib/brewing/PieBrewer.svelte.js +5 -5
- package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
- package/src/lib/brewing/ViolinBrewer.svelte.js +8 -49
- package/src/lib/brewing/brewer.svelte.js +242 -195
- package/src/lib/brewing/colors.js +34 -5
- package/src/lib/brewing/marks/arcs.js +28 -28
- package/src/lib/brewing/marks/areas.js +54 -41
- package/src/lib/brewing/marks/bars.js +34 -34
- package/src/lib/brewing/marks/boxes.js +51 -51
- package/src/lib/brewing/marks/lines.js +37 -30
- package/src/lib/brewing/marks/points.js +74 -26
- package/src/lib/brewing/marks/violins.js +57 -57
- package/src/lib/brewing/patterns.js +25 -11
- package/src/lib/brewing/scales.js +17 -17
- package/src/lib/brewing/stats.js +37 -29
- package/src/lib/brewing/symbols.js +1 -1
- package/src/lib/chart.js +2 -1
- package/src/lib/keyboard-nav.js +37 -0
- package/src/lib/plot/crossfilter.js +5 -5
- package/src/lib/plot/facet.js +30 -30
- package/src/lib/plot/frames.js +30 -29
- package/src/lib/plot/helpers.js +4 -4
- package/src/lib/plot/preset.js +48 -34
- package/src/lib/plot/scales.js +64 -39
- package/src/lib/plot/stat.js +47 -47
- package/src/lib/preset.js +41 -0
- package/src/patterns/DefinePatterns.svelte +24 -24
- package/src/patterns/README.md +3 -0
- package/src/patterns/patterns.js +328 -176
- package/src/patterns/scale.js +61 -32
- package/src/spec/chart-spec.js +64 -21
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
import { getContext } from 'svelte'
|
|
4
|
+
import { bin, extent, max } from 'd3-array'
|
|
5
|
+
import { scaleLinear } from 'd3-scale'
|
|
6
|
+
import { format } from 'd3-format'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interactive histogram with brush selection for crossfilter range dimensions.
|
|
10
|
+
* Renders bins as bars; mouse drag selects a range and calls cf.setRange().
|
|
11
|
+
* Click without dragging clears the filter.
|
|
12
|
+
*/
|
|
13
|
+
let {
|
|
14
|
+
data = [],
|
|
15
|
+
field = '',
|
|
16
|
+
label = '',
|
|
17
|
+
bins: binCount = 20,
|
|
18
|
+
width = 280,
|
|
19
|
+
height = 120,
|
|
20
|
+
color = 'rgb(var(--color-primary, 249 115 22))',
|
|
21
|
+
dimColor = 'rgb(var(--color-surface-z3, 203 213 225))'
|
|
22
|
+
} = $props()
|
|
23
|
+
|
|
24
|
+
const cf = getContext('crossfilter')
|
|
25
|
+
|
|
26
|
+
const margin = { top: 8, right: 10, bottom: 22, left: 32 }
|
|
27
|
+
|
|
28
|
+
const innerWidth = $derived(width - margin.left - margin.right)
|
|
29
|
+
const innerHeight = $derived(height - margin.top - margin.bottom)
|
|
30
|
+
|
|
31
|
+
// Compute histogram bins from data
|
|
32
|
+
const binner = $derived(
|
|
33
|
+
bin()
|
|
34
|
+
.value((d) => d[field])
|
|
35
|
+
.thresholds(binCount)
|
|
36
|
+
)
|
|
37
|
+
const binned = $derived(data.length > 0 ? binner(data) : [])
|
|
38
|
+
const domainExtent = $derived(data.length > 0 ? extent(data, (d) => d[field]) : [0, 1])
|
|
39
|
+
|
|
40
|
+
// Scales
|
|
41
|
+
const xScale = $derived(
|
|
42
|
+
scaleLinear()
|
|
43
|
+
.domain(domainExtent)
|
|
44
|
+
.range([0, innerWidth])
|
|
45
|
+
.nice()
|
|
46
|
+
)
|
|
47
|
+
const yScale = $derived(
|
|
48
|
+
binned.length > 0
|
|
49
|
+
? scaleLinear()
|
|
50
|
+
.domain([0, max(binned, (d) => d.length) ?? 1])
|
|
51
|
+
.range([innerHeight, 0])
|
|
52
|
+
.nice()
|
|
53
|
+
: scaleLinear().domain([0, 1]).range([innerHeight, 0])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Active range from crossfilter
|
|
57
|
+
const activeRange = $derived(cf?.filters?.get(field))
|
|
58
|
+
|
|
59
|
+
// Brush state
|
|
60
|
+
let brushStartPx = $state(null)
|
|
61
|
+
let brushEndPx = $state(null)
|
|
62
|
+
let brushing = $state(false)
|
|
63
|
+
|
|
64
|
+
// Brush rect in pixel space
|
|
65
|
+
const brushRect = $derived.by(() => {
|
|
66
|
+
// Show committed range if not currently brushing
|
|
67
|
+
if (brushStartPx === null && activeRange) {
|
|
68
|
+
const [lo, hi] = activeRange
|
|
69
|
+
return {
|
|
70
|
+
x: xScale(lo),
|
|
71
|
+
width: Math.max(1, xScale(hi) - xScale(lo))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (brushStartPx === null) return null
|
|
75
|
+
const lo = Math.min(brushStartPx, brushEndPx ?? brushStartPx)
|
|
76
|
+
const hi = Math.max(brushStartPx, brushEndPx ?? brushStartPx)
|
|
77
|
+
return { x: lo, width: Math.max(1, hi - lo) }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function pixelToValue(px) {
|
|
81
|
+
return xScale.invert(Math.max(0, Math.min(innerWidth, px)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getLocalX(e) {
|
|
85
|
+
const svgEl = e.currentTarget.closest('svg')
|
|
86
|
+
const rect = svgEl.getBoundingClientRect()
|
|
87
|
+
return e.clientX - rect.left - margin.left
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onMouseDown(e) {
|
|
91
|
+
brushStartPx = getLocalX(e)
|
|
92
|
+
brushEndPx = brushStartPx
|
|
93
|
+
brushing = true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function onMouseMove(e) {
|
|
97
|
+
if (!brushing) return
|
|
98
|
+
brushEndPx = getLocalX(e)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onMouseUp() {
|
|
102
|
+
if (!brushing) return
|
|
103
|
+
brushing = false
|
|
104
|
+
const lo = Math.min(brushStartPx, brushEndPx)
|
|
105
|
+
const hi = Math.max(brushStartPx, brushEndPx)
|
|
106
|
+
if (hi - lo < 3) {
|
|
107
|
+
// Treat as click — clear filter
|
|
108
|
+
cf?.clearFilter(field)
|
|
109
|
+
brushStartPx = null
|
|
110
|
+
brushEndPx = null
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const loVal = pixelToValue(lo)
|
|
114
|
+
const hiVal = pixelToValue(hi)
|
|
115
|
+
cf?.setRange(field, [loVal, hiVal])
|
|
116
|
+
brushStartPx = null
|
|
117
|
+
brushEndPx = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if a bin is within active range
|
|
121
|
+
function binInRange(b) {
|
|
122
|
+
if (!activeRange) return true
|
|
123
|
+
const [lo, hi] = activeRange
|
|
124
|
+
return b.x1 > lo && b.x0 < hi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Y-axis tick values (2-3 ticks)
|
|
128
|
+
const yTicks = $derived(yScale.ticks(3))
|
|
129
|
+
// X-axis tick values (4 ticks)
|
|
130
|
+
const xTicks = $derived(xScale.ticks(4))
|
|
131
|
+
|
|
132
|
+
const fmt = format('.2~s')
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<div data-filter-histogram data-filter-field={field} style="width:{width}px">
|
|
136
|
+
{#if label}
|
|
137
|
+
<div data-filter-histogram-label>{label}</div>
|
|
138
|
+
{/if}
|
|
139
|
+
<svg
|
|
140
|
+
{width}
|
|
141
|
+
{height}
|
|
142
|
+
style="display:block;cursor:crosshair;user-select:none"
|
|
143
|
+
role="img"
|
|
144
|
+
aria-label="Histogram filter for {label || field}"
|
|
145
|
+
onmousedown={onMouseDown}
|
|
146
|
+
onmousemove={onMouseMove}
|
|
147
|
+
onmouseup={onMouseUp}
|
|
148
|
+
onmouseleave={onMouseUp}
|
|
149
|
+
>
|
|
150
|
+
<g transform="translate({margin.left},{margin.top})">
|
|
151
|
+
<!-- Y axis ticks -->
|
|
152
|
+
{#each yTicks as tick}
|
|
153
|
+
<line
|
|
154
|
+
x1={0}
|
|
155
|
+
x2={innerWidth}
|
|
156
|
+
y1={yScale(tick)}
|
|
157
|
+
y2={yScale(tick)}
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
stroke-opacity="0.1"
|
|
160
|
+
stroke-width="1"
|
|
161
|
+
/>
|
|
162
|
+
<text
|
|
163
|
+
x={-4}
|
|
164
|
+
y={yScale(tick)}
|
|
165
|
+
text-anchor="end"
|
|
166
|
+
dominant-baseline="middle"
|
|
167
|
+
font-size="9"
|
|
168
|
+
fill="currentColor"
|
|
169
|
+
opacity="0.5">{tick}</text
|
|
170
|
+
>
|
|
171
|
+
{/each}
|
|
172
|
+
|
|
173
|
+
<!-- X axis line -->
|
|
174
|
+
<line
|
|
175
|
+
x1={0}
|
|
176
|
+
x2={innerWidth}
|
|
177
|
+
y1={innerHeight}
|
|
178
|
+
y2={innerHeight}
|
|
179
|
+
stroke="currentColor"
|
|
180
|
+
stroke-opacity="0.2"
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
<!-- X axis ticks -->
|
|
184
|
+
{#each xTicks as tick}
|
|
185
|
+
<text
|
|
186
|
+
x={xScale(tick)}
|
|
187
|
+
y={innerHeight + 12}
|
|
188
|
+
text-anchor="middle"
|
|
189
|
+
font-size="9"
|
|
190
|
+
fill="currentColor"
|
|
191
|
+
opacity="0.5">{fmt(tick)}</text
|
|
192
|
+
>
|
|
193
|
+
{/each}
|
|
194
|
+
|
|
195
|
+
<!-- Bars -->
|
|
196
|
+
{#each binned as b}
|
|
197
|
+
{@const bx = xScale(b.x0) + 1}
|
|
198
|
+
{@const bw = Math.max(0, xScale(b.x1) - xScale(b.x0) - 1)}
|
|
199
|
+
{@const by = yScale(b.length)}
|
|
200
|
+
{@const bh = innerHeight - by}
|
|
201
|
+
<rect
|
|
202
|
+
x={bx}
|
|
203
|
+
y={by}
|
|
204
|
+
width={bw}
|
|
205
|
+
height={bh}
|
|
206
|
+
fill={activeRange ? (binInRange(b) ? color : dimColor) : color}
|
|
207
|
+
opacity="0.85"
|
|
208
|
+
data-filter-histogram-bar
|
|
209
|
+
/>
|
|
210
|
+
{/each}
|
|
211
|
+
|
|
212
|
+
<!-- Brush overlay rect -->
|
|
213
|
+
{#if brushRect}
|
|
214
|
+
<rect
|
|
215
|
+
x={brushRect.x}
|
|
216
|
+
y={0}
|
|
217
|
+
width={brushRect.width}
|
|
218
|
+
height={innerHeight}
|
|
219
|
+
fill={color}
|
|
220
|
+
opacity="0.15"
|
|
221
|
+
pointer-events="none"
|
|
222
|
+
/>
|
|
223
|
+
<rect
|
|
224
|
+
x={brushRect.x}
|
|
225
|
+
y={0}
|
|
226
|
+
width={1}
|
|
227
|
+
height={innerHeight}
|
|
228
|
+
fill={color}
|
|
229
|
+
opacity="0.6"
|
|
230
|
+
pointer-events="none"
|
|
231
|
+
/>
|
|
232
|
+
<rect
|
|
233
|
+
x={brushRect.x + brushRect.width - 1}
|
|
234
|
+
y={0}
|
|
235
|
+
width={1}
|
|
236
|
+
height={innerHeight}
|
|
237
|
+
fill={color}
|
|
238
|
+
opacity="0.6"
|
|
239
|
+
pointer-events="none"
|
|
240
|
+
/>
|
|
241
|
+
{/if}
|
|
242
|
+
</g>
|
|
243
|
+
</svg>
|
|
244
|
+
{#if activeRange}
|
|
245
|
+
<div data-filter-histogram-range>
|
|
246
|
+
{fmt(activeRange[0])} – {fmt(activeRange[1])}
|
|
247
|
+
<button
|
|
248
|
+
data-filter-histogram-clear
|
|
249
|
+
onclick={() => cf?.clearFilter(field)}
|
|
250
|
+
aria-label="Clear {label || field} filter"
|
|
251
|
+
>✕</button>
|
|
252
|
+
</div>
|
|
253
|
+
{/if}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<style>
|
|
257
|
+
[data-filter-histogram] {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 4px;
|
|
261
|
+
font-size: 12px;
|
|
262
|
+
color: rgb(var(--color-surface-z6, 100 116 139));
|
|
263
|
+
}
|
|
264
|
+
[data-filter-histogram-label] {
|
|
265
|
+
font-weight: 600;
|
|
266
|
+
font-size: 11px;
|
|
267
|
+
text-transform: uppercase;
|
|
268
|
+
letter-spacing: 0.05em;
|
|
269
|
+
opacity: 0.7;
|
|
270
|
+
}
|
|
271
|
+
[data-filter-histogram-range] {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 6px;
|
|
275
|
+
font-size: 11px;
|
|
276
|
+
opacity: 0.8;
|
|
277
|
+
}
|
|
278
|
+
[data-filter-histogram-clear] {
|
|
279
|
+
background: none;
|
|
280
|
+
border: none;
|
|
281
|
+
cursor: pointer;
|
|
282
|
+
padding: 0 2px;
|
|
283
|
+
font-size: 10px;
|
|
284
|
+
opacity: 0.6;
|
|
285
|
+
line-height: 1;
|
|
286
|
+
}
|
|
287
|
+
[data-filter-histogram-clear]:hover {
|
|
288
|
+
opacity: 1;
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
@@ -1,79 +1,83 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
|
|
2
|
+
import { getContext } from 'svelte'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Dual range slider for a continuous crossfilter dimension.
|
|
6
|
+
* NOTE: Interim implementation using HTML range inputs.
|
|
7
|
+
* The spec calls for a Plot+Point+brush architecture, deferred until brush geom is implemented.
|
|
8
|
+
*/
|
|
9
|
+
let { field, min, max, step = 0.1, label = '' } = $props()
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const cf = getContext('crossfilter')
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// Initialize from props; $effect keeps in sync when min/max change
|
|
14
|
+
let low = $state(0)
|
|
15
|
+
let high = $state(100)
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
$effect(() => {
|
|
18
|
+
low = min ?? 0
|
|
19
|
+
high = max ?? 100
|
|
20
|
+
})
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
function handleLow(e) {
|
|
23
|
+
low = Math.min(Number(e.currentTarget.value), high)
|
|
24
|
+
cf?.setRange(field, [low, high])
|
|
25
|
+
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
function handleHigh(e) {
|
|
28
|
+
high = Math.max(Number(e.currentTarget.value), low)
|
|
29
|
+
cf?.setRange(field, [low, high])
|
|
30
|
+
}
|
|
31
31
|
</script>
|
|
32
32
|
|
|
33
33
|
<div data-filter-slider data-filter-field={field}>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
34
|
+
{#if label}
|
|
35
|
+
<span data-filter-slider-label>{label}</span>
|
|
36
|
+
{/if}
|
|
37
|
+
<div data-filter-slider-inputs>
|
|
38
|
+
<input
|
|
39
|
+
type="range"
|
|
40
|
+
{min}
|
|
41
|
+
{max}
|
|
42
|
+
{step}
|
|
43
|
+
value={low}
|
|
44
|
+
oninput={handleLow}
|
|
45
|
+
aria-label="Minimum {label || field}"
|
|
46
|
+
data-filter-slider-low
|
|
47
|
+
/>
|
|
48
|
+
<input
|
|
49
|
+
type="range"
|
|
50
|
+
{min}
|
|
51
|
+
{max}
|
|
52
|
+
{step}
|
|
53
|
+
value={high}
|
|
54
|
+
oninput={handleHigh}
|
|
55
|
+
aria-label="Maximum {label || field}"
|
|
56
|
+
data-filter-slider-high
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div data-filter-slider-display>
|
|
60
|
+
{low} – {high}
|
|
61
|
+
</div>
|
|
58
62
|
</div>
|
|
59
63
|
|
|
60
64
|
<style>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
[data-filter-slider] {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 4px;
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
}
|
|
71
|
+
[data-filter-slider-inputs] {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: 2px;
|
|
75
|
+
}
|
|
76
|
+
[data-filter-slider-label] {
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
}
|
|
79
|
+
[data-filter-slider-display] {
|
|
80
|
+
color: currentColor;
|
|
81
|
+
opacity: 0.7;
|
|
82
|
+
}
|
|
79
83
|
</style>
|
|
@@ -16,105 +16,109 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
function toggleCategoricalInMap(filters, dimension, value) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
const existing = filters.get(dimension)
|
|
20
|
+
const set = existing instanceof Set ? new SvelteSet(existing) : new SvelteSet()
|
|
21
|
+
if (set.has(value)) {
|
|
22
|
+
set.delete(value)
|
|
23
|
+
} else {
|
|
24
|
+
set.add(value)
|
|
25
|
+
}
|
|
26
|
+
if (set.size === 0) {
|
|
27
|
+
filters.delete(dimension)
|
|
28
|
+
} else {
|
|
29
|
+
filters.set(dimension, set)
|
|
30
|
+
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function createCrossFilter() {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Map<dimension, Set<unknown> | [number, number]>
|
|
35
|
+
const filters = new SvelteMap()
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// Simple counter incremented on every mutation. Components read cf.version
|
|
38
|
+
// inside $effect to reactively recompute when any filter changes.
|
|
39
|
+
let version = $state(0)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if any filter is active on this dimension.
|
|
43
|
+
* @param {string} dimension
|
|
44
|
+
*/
|
|
45
|
+
function isFiltered(dimension) {
|
|
46
|
+
const f = filters.get(dimension)
|
|
47
|
+
if (!f) return false
|
|
48
|
+
if (f instanceof Set) return f.size > 0
|
|
49
|
+
return true // range: always active if present
|
|
50
|
+
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if a value on this dimension is NOT in the active filter.
|
|
54
|
+
* Returns false when no filter is active on this dimension.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} dimension
|
|
57
|
+
* @param {unknown} value
|
|
58
|
+
*/
|
|
59
|
+
function isDimmed(dimension, value) {
|
|
60
|
+
const f = filters.get(dimension)
|
|
61
|
+
if (!f) return false
|
|
62
|
+
if (f instanceof Set) {
|
|
63
|
+
return !f.has(value)
|
|
64
|
+
}
|
|
65
|
+
// [min, max] range
|
|
66
|
+
const [lo, hi] = f
|
|
67
|
+
return Number(value) < lo || Number(value) > hi
|
|
68
|
+
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Toggles a categorical value for a dimension.
|
|
72
|
+
* Adds when absent, removes when present.
|
|
73
|
+
* Clears the dimension filter when the last value is removed.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} dimension
|
|
76
|
+
* @param {unknown} value
|
|
77
|
+
*/
|
|
78
|
+
function toggleCategorical(dimension, value) {
|
|
79
|
+
toggleCategoricalInMap(filters, dimension, value)
|
|
80
|
+
version++
|
|
81
|
+
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Sets a continuous range filter for a dimension.
|
|
85
|
+
* @param {string} dimension
|
|
86
|
+
* @param {[number, number]} range
|
|
87
|
+
*/
|
|
88
|
+
function setRange(dimension, range) {
|
|
89
|
+
filters.set(dimension, [range[0], range[1]])
|
|
90
|
+
version++
|
|
91
|
+
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Clears the filter for a single dimension.
|
|
95
|
+
* @param {string} dimension
|
|
96
|
+
*/
|
|
97
|
+
function clearFilter(dimension) {
|
|
98
|
+
filters.delete(dimension)
|
|
99
|
+
version++
|
|
100
|
+
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
/** Clears all active filters. */
|
|
103
|
+
function clearAll() {
|
|
104
|
+
filters.clear()
|
|
105
|
+
version++
|
|
106
|
+
}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
108
|
+
return {
|
|
109
|
+
/** @readonly — reactive Map of current filter state */
|
|
110
|
+
get filters() {
|
|
111
|
+
return filters
|
|
112
|
+
},
|
|
113
|
+
/** @readonly — increments on every mutation; read inside $effect to react to any filter change */
|
|
114
|
+
get version() {
|
|
115
|
+
return version
|
|
116
|
+
},
|
|
117
|
+
isFiltered,
|
|
118
|
+
isDimmed,
|
|
119
|
+
toggleCategorical,
|
|
120
|
+
setRange,
|
|
121
|
+
clearFilter,
|
|
122
|
+
clearAll
|
|
123
|
+
}
|
|
120
124
|
}
|