@rokkit/chart 1.0.0-next.16 → 1.0.0-next.161
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/README.md +150 -46
- package/package.json +42 -45
- package/src/AnimatedPlot.svelte +383 -0
- package/src/Chart.svelte +95 -0
- package/src/ChartProvider.svelte +10 -0
- package/src/FacetPlot/Panel.svelte +37 -0
- package/src/FacetPlot.svelte +114 -0
- package/src/Plot/Arc.svelte +29 -0
- package/src/Plot/Area.svelte +32 -0
- package/src/Plot/Axis.svelte +95 -0
- package/src/Plot/Bar.svelte +54 -0
- package/src/Plot/Grid.svelte +34 -0
- package/src/Plot/Legend.svelte +233 -0
- package/src/Plot/Line.svelte +37 -0
- package/src/Plot/Point.svelte +40 -0
- package/src/Plot/Root.svelte +62 -0
- package/src/Plot/Timeline.svelte +95 -0
- package/src/Plot/Tooltip.svelte +87 -0
- package/src/Plot/index.js +9 -0
- package/src/Plot.svelte +297 -0
- package/src/PlotState.svelte.js +350 -0
- package/src/Sparkline.svelte +108 -0
- package/src/Symbol.svelte +21 -0
- package/src/Texture.svelte +18 -0
- package/src/charts/AreaChart.svelte +27 -0
- package/src/charts/BarChart.svelte +28 -0
- package/src/charts/BoxPlot.svelte +21 -0
- package/src/charts/BubbleChart.svelte +23 -0
- package/src/charts/LineChart.svelte +26 -0
- package/src/charts/PieChart.svelte +35 -0
- package/src/charts/ScatterPlot.svelte +26 -0
- package/src/charts/ViolinPlot.svelte +21 -0
- package/src/crossfilter/CrossFilter.svelte +42 -0
- package/src/crossfilter/FilterBar.svelte +24 -0
- package/src/crossfilter/FilterHistogram.svelte +290 -0
- package/src/crossfilter/FilterSlider.svelte +83 -0
- package/src/crossfilter/createCrossFilter.svelte.js +124 -0
- package/src/elements/Bar.svelte +22 -24
- package/src/elements/ColorRamp.svelte +20 -22
- package/src/elements/ContinuousLegend.svelte +20 -17
- package/src/elements/DefinePatterns.svelte +24 -0
- package/src/elements/DiscreteLegend.svelte +15 -15
- package/src/elements/Label.svelte +4 -8
- package/src/elements/SymbolGrid.svelte +22 -0
- package/src/elements/index.js +6 -0
- package/src/examples/BarChartExample.svelte +81 -0
- package/src/geoms/Arc.svelte +126 -0
- package/src/geoms/Area.svelte +78 -0
- package/src/geoms/Bar.svelte +200 -0
- package/src/geoms/Box.svelte +113 -0
- package/src/geoms/LabelPill.svelte +17 -0
- package/src/geoms/Line.svelte +123 -0
- package/src/geoms/Point.svelte +145 -0
- package/src/geoms/Violin.svelte +56 -0
- package/src/geoms/lib/areas.js +154 -0
- package/src/geoms/lib/bars.js +223 -0
- package/src/index.js +74 -16
- package/src/lib/brewer.js +25 -0
- package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
- package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
- package/src/lib/brewing/PieBrewer.svelte.js +14 -0
- package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
- package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
- package/src/lib/brewing/axes.svelte.js +270 -0
- package/src/lib/brewing/bars.svelte.js +201 -0
- package/src/lib/brewing/brewer.svelte.js +277 -0
- package/src/lib/brewing/colors.js +51 -0
- package/src/lib/brewing/dimensions.svelte.js +56 -0
- package/src/lib/brewing/index.svelte.js +205 -0
- package/src/lib/brewing/legends.svelte.js +137 -0
- package/src/lib/brewing/marks/arcs.js +43 -0
- package/src/lib/brewing/marks/areas.js +72 -0
- package/src/lib/brewing/marks/bars.js +49 -0
- package/src/lib/brewing/marks/boxes.js +75 -0
- package/src/lib/brewing/marks/lines.js +55 -0
- package/src/lib/brewing/marks/points.js +105 -0
- package/src/lib/brewing/marks/violins.js +90 -0
- package/src/lib/brewing/patterns.js +45 -0
- package/src/lib/brewing/scales.js +51 -0
- package/src/lib/brewing/scales.svelte.js +82 -0
- package/src/lib/brewing/stats.js +74 -0
- package/src/lib/brewing/symbols.js +10 -0
- package/src/lib/brewing/types.js +73 -0
- package/src/lib/chart.js +221 -0
- package/src/lib/context.js +131 -0
- package/src/lib/grid.js +85 -0
- package/src/lib/keyboard-nav.js +37 -0
- package/src/lib/plot/chartProps.js +76 -0
- package/src/lib/plot/crossfilter.js +16 -0
- package/src/lib/plot/facet.js +58 -0
- package/src/lib/plot/frames.js +81 -0
- package/src/lib/plot/helpers.js +14 -0
- package/src/lib/plot/preset.js +67 -0
- package/src/lib/plot/scales.js +81 -0
- package/src/lib/plot/stat.js +92 -0
- package/src/lib/plot/types.js +65 -0
- package/src/lib/preset.js +41 -0
- package/src/lib/scales.svelte.js +151 -0
- package/src/lib/swatch.js +13 -0
- package/src/lib/ticks.js +46 -0
- package/src/lib/utils.js +111 -118
- package/src/lib/xscale.js +31 -0
- package/src/patterns/DefinePatterns.svelte +32 -0
- package/src/patterns/PatternDef.svelte +27 -0
- package/src/patterns/index.js +4 -0
- package/src/patterns/patterns.js +360 -0
- package/src/patterns/scale.js +116 -0
- package/src/spec/chart-spec.js +72 -0
- package/src/symbols/RoundedSquare.svelte +33 -0
- package/src/symbols/Shape.svelte +37 -0
- package/src/symbols/constants/index.js +4 -0
- package/src/symbols/index.js +9 -0
- package/src/symbols/outline.svelte +60 -0
- package/src/symbols/solid.svelte +60 -0
- package/LICENSE +0 -21
- package/src/chart/FacetGrid.svelte +0 -51
- package/src/chart/Grid.svelte +0 -34
- package/src/chart/Legend.svelte +0 -16
- package/src/chart/PatternDefs.svelte +0 -13
- package/src/chart/Swatch.svelte +0 -93
- package/src/chart/SwatchButton.svelte +0 -29
- package/src/chart/SwatchGrid.svelte +0 -55
- package/src/chart/Symbol.svelte +0 -37
- package/src/chart/Texture.svelte +0 -16
- package/src/chart/TexturedShape.svelte +0 -27
- package/src/chart/TimelapseChart.svelte +0 -97
- package/src/chart/Timer.svelte +0 -27
- package/src/chart.js +0 -9
- package/src/components/charts/Axis.svelte +0 -66
- package/src/components/charts/Chart.svelte +0 -35
- package/src/components/index.js +0 -23
- package/src/components/lib/axis.js +0 -0
- package/src/components/lib/chart.js +0 -187
- package/src/components/lib/color.js +0 -327
- package/src/components/lib/funnel.js +0 -204
- package/src/components/lib/index.js +0 -19
- package/src/components/lib/pattern.js +0 -190
- package/src/components/lib/rollup.js +0 -55
- package/src/components/lib/shape.js +0 -199
- package/src/components/lib/summary.js +0 -145
- package/src/components/lib/theme.js +0 -23
- package/src/components/lib/timer.js +0 -41
- package/src/components/lib/utils.js +0 -165
- package/src/components/plots/BarPlot.svelte +0 -36
- package/src/components/plots/BoxPlot.svelte +0 -54
- package/src/components/plots/ScatterPlot.svelte +0 -30
- package/src/components/store.js +0 -70
- package/src/constants.js +0 -66
- package/src/elements/PatternDefs.svelte +0 -13
- package/src/elements/PatternMask.svelte +0 -20
- package/src/elements/Symbol.svelte +0 -38
- package/src/elements/Tooltip.svelte +0 -23
- package/src/funnel.svelte +0 -35
- package/src/geom.js +0 -105
- package/src/lib/axis.js +0 -75
- package/src/lib/colors.js +0 -32
- package/src/lib/geom.js +0 -4
- package/src/lib/shapes.js +0 -144
- package/src/lib/timer.js +0 -44
- package/src/lookup.js +0 -29
- package/src/plots/BarPlot.svelte +0 -55
- package/src/plots/BoxPlot.svelte +0 -0
- package/src/plots/FunnelPlot.svelte +0 -33
- package/src/plots/HeatMap.svelte +0 -5
- package/src/plots/HeatMapCalendar.svelte +0 -129
- package/src/plots/LinePlot.svelte +0 -55
- package/src/plots/Plot.svelte +0 -25
- package/src/plots/RankBarPlot.svelte +0 -38
- package/src/plots/ScatterPlot.svelte +0 -20
- package/src/plots/ViolinPlot.svelte +0 -11
- package/src/plots/heatmap.js +0 -70
- package/src/plots/index.js +0 -10
- 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
|
+
}
|
package/src/elements/Bar.svelte
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
19
|
+
let y = $derived(rank * (height + spaceBetween))
|
|
20
|
+
let width = $derived(get(scales).x(value))
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
+
let formattedValue = $derived(format(formatString)(value))
|
|
27
|
+
let xOrigin = $derived(get(scales).x(0))
|
|
24
28
|
</script>
|
|
25
29
|
|
|
26
|
-
<rect x={
|
|
27
|
-
<rect x={
|
|
28
|
-
<Label x={width} y={y + textHeight + 8} anchor={textAnchor}
|
|
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 '
|
|
3
|
+
import { id as uniqueId } from '@rokkit/core'
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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}
|