@rokkit/chart 1.0.0-next.16 → 1.0.0-next.160

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 (173) hide show
  1. package/README.md +150 -46
  2. package/package.json +42 -45
  3. package/src/AnimatedPlot.svelte +383 -0
  4. package/src/Chart.svelte +95 -0
  5. package/src/ChartProvider.svelte +10 -0
  6. package/src/FacetPlot/Panel.svelte +37 -0
  7. package/src/FacetPlot.svelte +114 -0
  8. package/src/Plot/Arc.svelte +29 -0
  9. package/src/Plot/Area.svelte +32 -0
  10. package/src/Plot/Axis.svelte +95 -0
  11. package/src/Plot/Bar.svelte +54 -0
  12. package/src/Plot/Grid.svelte +34 -0
  13. package/src/Plot/Legend.svelte +233 -0
  14. package/src/Plot/Line.svelte +37 -0
  15. package/src/Plot/Point.svelte +40 -0
  16. package/src/Plot/Root.svelte +62 -0
  17. package/src/Plot/Timeline.svelte +95 -0
  18. package/src/Plot/Tooltip.svelte +87 -0
  19. package/src/Plot/index.js +9 -0
  20. package/src/Plot.svelte +297 -0
  21. package/src/PlotState.svelte.js +350 -0
  22. package/src/Sparkline.svelte +108 -0
  23. package/src/Symbol.svelte +21 -0
  24. package/src/Texture.svelte +18 -0
  25. package/src/charts/AreaChart.svelte +27 -0
  26. package/src/charts/BarChart.svelte +28 -0
  27. package/src/charts/BoxPlot.svelte +21 -0
  28. package/src/charts/BubbleChart.svelte +23 -0
  29. package/src/charts/LineChart.svelte +26 -0
  30. package/src/charts/PieChart.svelte +35 -0
  31. package/src/charts/ScatterPlot.svelte +26 -0
  32. package/src/charts/ViolinPlot.svelte +21 -0
  33. package/src/crossfilter/CrossFilter.svelte +42 -0
  34. package/src/crossfilter/FilterBar.svelte +24 -0
  35. package/src/crossfilter/FilterHistogram.svelte +290 -0
  36. package/src/crossfilter/FilterSlider.svelte +83 -0
  37. package/src/crossfilter/createCrossFilter.svelte.js +124 -0
  38. package/src/elements/Bar.svelte +22 -24
  39. package/src/elements/ColorRamp.svelte +20 -22
  40. package/src/elements/ContinuousLegend.svelte +20 -17
  41. package/src/elements/DefinePatterns.svelte +24 -0
  42. package/src/elements/DiscreteLegend.svelte +15 -15
  43. package/src/elements/Label.svelte +4 -8
  44. package/src/elements/SymbolGrid.svelte +22 -0
  45. package/src/elements/index.js +6 -0
  46. package/src/examples/BarChartExample.svelte +81 -0
  47. package/src/geoms/Arc.svelte +126 -0
  48. package/src/geoms/Area.svelte +78 -0
  49. package/src/geoms/Bar.svelte +200 -0
  50. package/src/geoms/Box.svelte +113 -0
  51. package/src/geoms/LabelPill.svelte +17 -0
  52. package/src/geoms/Line.svelte +123 -0
  53. package/src/geoms/Point.svelte +145 -0
  54. package/src/geoms/Violin.svelte +56 -0
  55. package/src/geoms/lib/areas.js +154 -0
  56. package/src/geoms/lib/bars.js +223 -0
  57. package/src/index.js +74 -16
  58. package/src/lib/brewer.js +25 -0
  59. package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
  60. package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
  61. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  62. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  63. package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
  64. package/src/lib/brewing/axes.svelte.js +270 -0
  65. package/src/lib/brewing/bars.svelte.js +201 -0
  66. package/src/lib/brewing/brewer.svelte.js +277 -0
  67. package/src/lib/brewing/colors.js +51 -0
  68. package/src/lib/brewing/dimensions.svelte.js +56 -0
  69. package/src/lib/brewing/index.svelte.js +205 -0
  70. package/src/lib/brewing/legends.svelte.js +137 -0
  71. package/src/lib/brewing/marks/arcs.js +43 -0
  72. package/src/lib/brewing/marks/areas.js +72 -0
  73. package/src/lib/brewing/marks/bars.js +49 -0
  74. package/src/lib/brewing/marks/boxes.js +75 -0
  75. package/src/lib/brewing/marks/lines.js +55 -0
  76. package/src/lib/brewing/marks/points.js +105 -0
  77. package/src/lib/brewing/marks/violins.js +90 -0
  78. package/src/lib/brewing/patterns.js +45 -0
  79. package/src/lib/brewing/scales.js +51 -0
  80. package/src/lib/brewing/scales.svelte.js +82 -0
  81. package/src/lib/brewing/stats.js +74 -0
  82. package/src/lib/brewing/symbols.js +10 -0
  83. package/src/lib/brewing/types.js +73 -0
  84. package/src/lib/chart.js +221 -0
  85. package/src/lib/context.js +131 -0
  86. package/src/lib/grid.js +85 -0
  87. package/src/lib/keyboard-nav.js +37 -0
  88. package/src/lib/plot/chartProps.js +76 -0
  89. package/src/lib/plot/crossfilter.js +16 -0
  90. package/src/lib/plot/facet.js +58 -0
  91. package/src/lib/plot/frames.js +81 -0
  92. package/src/lib/plot/helpers.js +14 -0
  93. package/src/lib/plot/preset.js +67 -0
  94. package/src/lib/plot/scales.js +81 -0
  95. package/src/lib/plot/stat.js +92 -0
  96. package/src/lib/plot/types.js +65 -0
  97. package/src/lib/preset.js +41 -0
  98. package/src/lib/scales.svelte.js +151 -0
  99. package/src/lib/swatch.js +13 -0
  100. package/src/lib/ticks.js +46 -0
  101. package/src/lib/utils.js +111 -118
  102. package/src/lib/xscale.js +31 -0
  103. package/src/patterns/DefinePatterns.svelte +32 -0
  104. package/src/patterns/PatternDef.svelte +27 -0
  105. package/src/patterns/index.js +4 -0
  106. package/src/patterns/patterns.js +360 -0
  107. package/src/patterns/scale.js +116 -0
  108. package/src/spec/chart-spec.js +72 -0
  109. package/src/symbols/RoundedSquare.svelte +33 -0
  110. package/src/symbols/Shape.svelte +37 -0
  111. package/src/symbols/constants/index.js +4 -0
  112. package/src/symbols/index.js +9 -0
  113. package/src/symbols/outline.svelte +60 -0
  114. package/src/symbols/solid.svelte +60 -0
  115. package/LICENSE +0 -21
  116. package/src/chart/FacetGrid.svelte +0 -51
  117. package/src/chart/Grid.svelte +0 -34
  118. package/src/chart/Legend.svelte +0 -16
  119. package/src/chart/PatternDefs.svelte +0 -13
  120. package/src/chart/Swatch.svelte +0 -93
  121. package/src/chart/SwatchButton.svelte +0 -29
  122. package/src/chart/SwatchGrid.svelte +0 -55
  123. package/src/chart/Symbol.svelte +0 -37
  124. package/src/chart/Texture.svelte +0 -16
  125. package/src/chart/TexturedShape.svelte +0 -27
  126. package/src/chart/TimelapseChart.svelte +0 -97
  127. package/src/chart/Timer.svelte +0 -27
  128. package/src/chart.js +0 -9
  129. package/src/components/charts/Axis.svelte +0 -66
  130. package/src/components/charts/Chart.svelte +0 -35
  131. package/src/components/index.js +0 -23
  132. package/src/components/lib/axis.js +0 -0
  133. package/src/components/lib/chart.js +0 -187
  134. package/src/components/lib/color.js +0 -327
  135. package/src/components/lib/funnel.js +0 -204
  136. package/src/components/lib/index.js +0 -19
  137. package/src/components/lib/pattern.js +0 -190
  138. package/src/components/lib/rollup.js +0 -55
  139. package/src/components/lib/shape.js +0 -199
  140. package/src/components/lib/summary.js +0 -145
  141. package/src/components/lib/theme.js +0 -23
  142. package/src/components/lib/timer.js +0 -41
  143. package/src/components/lib/utils.js +0 -165
  144. package/src/components/plots/BarPlot.svelte +0 -36
  145. package/src/components/plots/BoxPlot.svelte +0 -54
  146. package/src/components/plots/ScatterPlot.svelte +0 -30
  147. package/src/components/store.js +0 -70
  148. package/src/constants.js +0 -66
  149. package/src/elements/PatternDefs.svelte +0 -13
  150. package/src/elements/PatternMask.svelte +0 -20
  151. package/src/elements/Symbol.svelte +0 -38
  152. package/src/elements/Tooltip.svelte +0 -23
  153. package/src/funnel.svelte +0 -35
  154. package/src/geom.js +0 -105
  155. package/src/lib/axis.js +0 -75
  156. package/src/lib/colors.js +0 -32
  157. package/src/lib/geom.js +0 -4
  158. package/src/lib/shapes.js +0 -144
  159. package/src/lib/timer.js +0 -44
  160. package/src/lookup.js +0 -29
  161. package/src/plots/BarPlot.svelte +0 -55
  162. package/src/plots/BoxPlot.svelte +0 -0
  163. package/src/plots/FunnelPlot.svelte +0 -33
  164. package/src/plots/HeatMap.svelte +0 -5
  165. package/src/plots/HeatMapCalendar.svelte +0 -129
  166. package/src/plots/LinePlot.svelte +0 -55
  167. package/src/plots/Plot.svelte +0 -25
  168. package/src/plots/RankBarPlot.svelte +0 -38
  169. package/src/plots/ScatterPlot.svelte +0 -20
  170. package/src/plots/ViolinPlot.svelte +0 -11
  171. package/src/plots/heatmap.js +0 -70
  172. package/src/plots/index.js +0 -10
  173. package/src/swatch.js +0 -11
@@ -0,0 +1,21 @@
1
+ <script>
2
+ import Plot from '../Plot.svelte'
3
+ import Violin from '../geoms/Violin.svelte'
4
+
5
+ /** @type {import('../lib/plot/chartProps.js').BoxViolinChartProps} */
6
+ let {
7
+ data = [],
8
+ x = undefined,
9
+ y = undefined,
10
+ fill = undefined,
11
+ width = 600,
12
+ height = 400,
13
+ mode = 'light',
14
+ grid = true,
15
+ legend = false
16
+ } = $props()
17
+ </script>
18
+
19
+ <Plot {data} {width} {height} {mode} {grid} {legend}>
20
+ <Violin {x} {y} {fill} />
21
+ </Plot>
@@ -0,0 +1,42 @@
1
+ <script>
2
+ import { setContext, untrack } from 'svelte'
3
+ import { createCrossFilter } from './createCrossFilter.svelte.js'
4
+
5
+ /**
6
+ * @type {{
7
+ * crossfilter?: ReturnType<typeof createCrossFilter>,
8
+ * mode?: 'dim' | 'hide',
9
+ * filters?: import('./createCrossFilter.svelte.js').FilterState,
10
+ * children?: import('svelte').Snippet
11
+ * }}
12
+ */
13
+ let {
14
+ crossfilter: externalCf = undefined,
15
+ mode = 'dim',
16
+ filters = $bindable(),
17
+ children
18
+ } = $props()
19
+
20
+ // Use an externally provided instance (spec/helpers API) or create one internally.
21
+ // untrack() suppresses "captures initial value" warning — intentional: the cf
22
+ // instance is locked in at construction time and must not recreate on prop changes.
23
+ const cf = untrack(() => externalCf ?? createCrossFilter())
24
+
25
+ // Expose the reactive filters Map to callers via bind:filters
26
+ $effect(() => {
27
+ filters = cf.filters
28
+ })
29
+
30
+ setContext('crossfilter', cf)
31
+ // Use a getter object so children can read .mode reactively
32
+ const modeRef = {
33
+ get mode() {
34
+ return mode
35
+ }
36
+ }
37
+ setContext('crossfilter-mode', modeRef)
38
+ </script>
39
+
40
+ <div data-crossfilter data-crossfilter-mode={mode}>
41
+ {@render children?.()}
42
+ </div>
@@ -0,0 +1,24 @@
1
+ <script>
2
+ import PlotChart from '../Plot.svelte'
3
+ import Bar from '../geoms/Bar.svelte'
4
+
5
+ let {
6
+ data = [],
7
+ field,
8
+ valueField,
9
+ stat = 'sum',
10
+ width = 300,
11
+ height = 120,
12
+ mode = 'light'
13
+ } = $props()
14
+
15
+ const spec = $derived({
16
+ x: field,
17
+ y: valueField
18
+ })
19
+ </script>
20
+
21
+ <!-- FilterBar must be used inside a <CrossFilter> parent. Does not create its own context. -->
22
+ <PlotChart {data} {spec} {width} {height} {mode} grid={false} legend={false}>
23
+ <Bar x={field} y={valueField} {stat} filterable={true} />
24
+ </PlotChart>
@@ -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>
@@ -0,0 +1,83 @@
1
+ <script>
2
+ import { getContext } from 'svelte'
3
+
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
+
11
+ const cf = getContext('crossfilter')
12
+
13
+ // Initialize from props; $effect keeps in sync when min/max change
14
+ let low = $state(0)
15
+ let high = $state(100)
16
+
17
+ $effect(() => {
18
+ low = min ?? 0
19
+ high = max ?? 100
20
+ })
21
+
22
+ function handleLow(e) {
23
+ low = Math.min(Number(e.currentTarget.value), high)
24
+ cf?.setRange(field, [low, high])
25
+ }
26
+
27
+ function handleHigh(e) {
28
+ high = Math.max(Number(e.currentTarget.value), low)
29
+ cf?.setRange(field, [low, high])
30
+ }
31
+ </script>
32
+
33
+ <div data-filter-slider data-filter-field={field}>
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>
62
+ </div>
63
+
64
+ <style>
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
+ }
83
+ </style>
@@ -0,0 +1,124 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
+
3
+ /**
4
+ * Creates a reactive cross-filter state object.
5
+ *
6
+ * Filter values follow the spec type:
7
+ * FilterState = Map<string, Set<unknown> | [number, number]>
8
+ * - categorical: raw Set of selected values
9
+ * - continuous: [min, max] tuple
10
+ *
11
+ * Exposes a `filters` getter so CrossFilter.svelte can bind to current state.
12
+ * Exposes a `version` counter that increments on every mutation, giving
13
+ * components a simple reactive signal to watch for filter changes.
14
+ *
15
+ * @returns {CrossFilter}
16
+ */
17
+
18
+ function toggleCategoricalInMap(filters, dimension, value) {
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
+ }
32
+
33
+ export function createCrossFilter() {
34
+ // Map<dimension, Set<unknown> | [number, number]>
35
+ const filters = new SvelteMap()
36
+
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
+
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
+
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
+
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
+
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
+
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
+
102
+ /** Clears all active filters. */
103
+ function clearAll() {
104
+ filters.clear()
105
+ version++
106
+ }
107
+
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
+ }
124
+ }
@@ -1,35 +1,33 @@
1
1
  <script>
2
2
  import { format } from 'd3-format'
3
+ import { get } from 'svelte/store'
3
4
  import Label from './Label.svelte'
4
5
 
5
- export let rank
6
- export let value
7
- export let name
8
- export let formatString = '.1%'
9
- export let scales
10
- export let height = 60
11
- export let fill
12
- export let spaceBetween = 5
6
+ let {
7
+ rank,
8
+ value,
9
+ name,
10
+ formatString = '.1%',
11
+ scales,
12
+ height = 60,
13
+ fill,
14
+ spaceBetween = 5
15
+ } = $props()
13
16
 
14
17
  const textHeight = 16
15
18
  const charWidth = 12
16
- $: y = rank * (height + spaceBetween)
17
- $: width = $scales.x(value)
19
+ let y = $derived(rank * (height + spaceBetween))
20
+ let width = $derived(get(scales).x(value))
18
21
 
19
- $: textWidth = name.length * charWidth
20
- $: textOffset = width <= textWidth ? width + charWidth : width
21
- $: textAnchor = textOffset > width ? 'start' : 'end'
22
+ let textWidth = $derived(name.length * charWidth)
23
+ let textOffset = $derived(width <= textWidth ? width + charWidth : width)
24
+ let textAnchor = $derived(textOffset > width ? 'start' : 'end')
22
25
 
23
- $: formattedValue = format(formatString)(value)
26
+ let formattedValue = $derived(format(formatString)(value))
27
+ let xOrigin = $derived(get(scales).x(0))
24
28
  </script>
25
29
 
26
- <rect x={$scales.x(0)} {y} {width} {height} {fill} opacity={0.5} />
27
- <rect x={$scales.x(0)} {y} width={5} {height} {fill} />
28
- <Label x={width} y={y + textHeight + 8} anchor={textAnchor} label={name} />
29
- <Label
30
- x={width}
31
- y={y + height - 14}
32
- anchor={textAnchor}
33
- label={formattedValue}
34
- small
35
- />
30
+ <rect x={xOrigin} {y} {width} {height} {fill} opacity={0.5} />
31
+ <rect x={xOrigin} {y} width={5} {height} {fill} />
32
+ <Label x={width} y={y + textHeight + 8} anchor={textAnchor} text={name} />
33
+ <Label x={width} y={y + height - 14} anchor={textAnchor} text={formattedValue} small />
@@ -1,39 +1,37 @@
1
1
  <script>
2
2
  import { scaleLinear } from 'd3-scale'
3
- import { uniqueId } from '../lib/utils'
3
+ import { id as uniqueId } from '@rokkit/core'
4
4
 
5
- export let x = 0
6
- export let y = 0
7
- export let textSize = 5
8
- export let height = 10
9
- export let width = 100
10
- export let tickCount = 5
11
- export let scale
5
+ let { x = 0, y = 0, textSize = 5, height = 10, width = 100, tickCount = 5, scale } = $props()
12
6
 
13
- $: scaleTicks = scaleLinear()
14
- .range([x, x + width])
15
- .domain(scale.domain())
16
- $: scalePercent = scaleLinear().range([0, 100]).domain(scale.domain())
17
- $: ticks = scale.ticks
18
- .apply(scale, [tickCount])
19
- .map((d) => ({ x: scaleTicks(d), value: d }))
7
+ let scaleTicks = $derived(
8
+ scaleLinear()
9
+ .range([x, x + width])
10
+ .domain(scale.domain())
11
+ )
12
+ let scalePercent = $derived(scaleLinear().range([0, 100]).domain(scale.domain()))
13
+ let ticks = $derived(
14
+ scale.ticks.apply(scale, [tickCount]).map((d) => ({ x: scaleTicks(d), value: d }))
15
+ )
20
16
 
21
- $: colors = ticks.map(({ value }) => ({
22
- color: scale(value),
23
- offset: `${scalePercent(value)}%`,
24
- }))
25
- $: id = uniqueId('legend-')
17
+ let colors = $derived(
18
+ ticks.map(({ value }) => ({
19
+ color: scale(value),
20
+ offset: `${scalePercent(value)}%`
21
+ }))
22
+ )
23
+ let id = $state(uniqueId('legend-'))
26
24
  </script>
27
25
 
28
26
  <defs>
29
27
  <linearGradient {id}>
30
- {#each colors as { color, offset }}
28
+ {#each colors as { color, offset }, index (index)}
31
29
  <stop stop-color={color} {offset} />
32
30
  {/each}
33
31
  </linearGradient>
34
32
  </defs>
35
33
  <rect {x} y={y + height} {width} {height} fill="url(#{id})" />
36
- {#each ticks as { x, value }}
34
+ {#each ticks as { x, value }, index (index)}
37
35
  <line x1={x} y1={y + (2 * height) / 3} x2={x} y2={y + height * 2} />
38
36
  <text {x} y={y + height / 2} font-size={textSize}>{value}</text>
39
37
  {/each}