@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.
- 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,383 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, onDestroy } from 'svelte'
|
|
3
|
+
import { tweened } from 'svelte/motion'
|
|
4
|
+
import { sineInOut } from 'svelte/easing'
|
|
5
|
+
import { extractFrames, completeFrames, computeStaticDomains } from './lib/plot/frames.js'
|
|
6
|
+
import { applyGeomStat } from './lib/plot/stat.js'
|
|
7
|
+
import Timeline from './Plot/Timeline.svelte'
|
|
8
|
+
import PlotChart from './Plot.svelte'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @type {{
|
|
12
|
+
* data: Object[],
|
|
13
|
+
* animate: { by: string, duration?: number, loop?: boolean },
|
|
14
|
+
* x?: string,
|
|
15
|
+
* y?: string,
|
|
16
|
+
* color?: string,
|
|
17
|
+
* fill?: string,
|
|
18
|
+
* pattern?: string,
|
|
19
|
+
* symbol?: string,
|
|
20
|
+
* geom?: string,
|
|
21
|
+
* stat?: string,
|
|
22
|
+
* geoms?: import('./lib/plot/types.js').GeomSpec[],
|
|
23
|
+
* helpers?: import('./lib/plot/types.js').PlotHelpers,
|
|
24
|
+
* width?: number,
|
|
25
|
+
* height?: number,
|
|
26
|
+
* mode?: 'light' | 'dark',
|
|
27
|
+
* grid?: boolean,
|
|
28
|
+
* legend?: boolean,
|
|
29
|
+
* tween?: boolean,
|
|
30
|
+
* sorted?: boolean,
|
|
31
|
+
* dynamicDomain?: boolean,
|
|
32
|
+
* label?: boolean | string | ((data: Record<string, unknown>) => string),
|
|
33
|
+
* children?: import('svelte').Snippet
|
|
34
|
+
* }}
|
|
35
|
+
*/
|
|
36
|
+
let {
|
|
37
|
+
data = [],
|
|
38
|
+
animate,
|
|
39
|
+
x,
|
|
40
|
+
y,
|
|
41
|
+
color,
|
|
42
|
+
fill = undefined,
|
|
43
|
+
pattern = undefined,
|
|
44
|
+
symbol = undefined,
|
|
45
|
+
geom = 'bar',
|
|
46
|
+
stat = 'identity',
|
|
47
|
+
geoms = [],
|
|
48
|
+
helpers = {},
|
|
49
|
+
width = 600,
|
|
50
|
+
height = 400,
|
|
51
|
+
mode = 'light',
|
|
52
|
+
grid = true,
|
|
53
|
+
legend = false,
|
|
54
|
+
tween = true,
|
|
55
|
+
sorted = false,
|
|
56
|
+
dynamicDomain = false,
|
|
57
|
+
label = false,
|
|
58
|
+
children
|
|
59
|
+
} = $props()
|
|
60
|
+
|
|
61
|
+
// Effective geom list: explicit array takes precedence; otherwise build from shorthand props
|
|
62
|
+
const effectiveGeoms = $derived(
|
|
63
|
+
geoms.length > 0
|
|
64
|
+
? geoms
|
|
65
|
+
: [
|
|
66
|
+
{
|
|
67
|
+
type: geom,
|
|
68
|
+
stat,
|
|
69
|
+
...(fill !== undefined && { fill }),
|
|
70
|
+
...(pattern !== undefined && { pattern }),
|
|
71
|
+
...(symbol !== undefined && { symbol })
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Pre-aggregate and complete frames when any geom has a non-identity stat
|
|
77
|
+
const prepared = $derived.by(() => {
|
|
78
|
+
const firstNonIdentity = effectiveGeoms.find((g) => g.stat && g.stat !== 'identity')
|
|
79
|
+
if (!firstNonIdentity) return { data, geoms: effectiveGeoms }
|
|
80
|
+
|
|
81
|
+
const aggChannels = { y }
|
|
82
|
+
if (x) aggChannels.x = x
|
|
83
|
+
if (color) aggChannels.color = color
|
|
84
|
+
aggChannels.frame = animate.by
|
|
85
|
+
|
|
86
|
+
const aggregated = applyGeomStat(
|
|
87
|
+
data,
|
|
88
|
+
{ stat: firstNonIdentity.stat, channels: aggChannels },
|
|
89
|
+
helpers
|
|
90
|
+
)
|
|
91
|
+
const completeData = completeFrames(aggregated, { x, y, color }, animate.by)
|
|
92
|
+
return { data: completeData, geoms: effectiveGeoms.map((g) => ({ ...g, stat: 'identity' })) }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Extract frames and compute stable domains from the full prepared dataset
|
|
96
|
+
const rawFrames = $derived(extractFrames(prepared.data, animate.by))
|
|
97
|
+
const frameKeys = $derived([...rawFrames.keys()])
|
|
98
|
+
|
|
99
|
+
const channels = $derived({ x, y, color })
|
|
100
|
+
const staticDomains = $derived(
|
|
101
|
+
x && y
|
|
102
|
+
? computeStaticDomains(prepared.data, channels)
|
|
103
|
+
: { xDomain: undefined, yDomain: undefined }
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Tweened x-domain for dynamic axis animation (opt-in via dynamicDomain prop)
|
|
107
|
+
const xDomainTween = tweened([0, 1], { duration: 0 })
|
|
108
|
+
let xDomainInitialized = false
|
|
109
|
+
|
|
110
|
+
// Playback state
|
|
111
|
+
let currentIndex = $state(0)
|
|
112
|
+
let playing = $state(false)
|
|
113
|
+
let speed = $state(1)
|
|
114
|
+
|
|
115
|
+
// Current frame data — already complete (all x/color combos present)
|
|
116
|
+
const currentFrameData = $derived.by(() => {
|
|
117
|
+
const key = frameKeys[currentIndex]
|
|
118
|
+
return rawFrames.get(key) ?? []
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Reduced motion preference
|
|
122
|
+
let prefersReducedMotion = $state(false)
|
|
123
|
+
onMount(() => {
|
|
124
|
+
if (typeof window.matchMedia !== 'function') return
|
|
125
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
126
|
+
prefersReducedMotion = mq.matches
|
|
127
|
+
const handler = (e) => { prefersReducedMotion = e.matches }
|
|
128
|
+
mq.addEventListener('change', handler)
|
|
129
|
+
return () => mq.removeEventListener('change', handler)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Animation timing
|
|
133
|
+
const baseDuration = $derived(animate.duration ?? 800)
|
|
134
|
+
const msPerFrame = $derived(Math.round(baseDuration / speed))
|
|
135
|
+
|
|
136
|
+
// rAF-based frame timer — advances currentIndex every msPerFrame ms.
|
|
137
|
+
// Separate from the tween: the tween handles visual interpolation only.
|
|
138
|
+
let lastTime = 0
|
|
139
|
+
let rafId = 0
|
|
140
|
+
|
|
141
|
+
function advanceFrame() {
|
|
142
|
+
currentIndex = currentIndex + 1
|
|
143
|
+
if (currentIndex >= frameKeys.length) {
|
|
144
|
+
if (animate.loop ?? false) currentIndex = 0
|
|
145
|
+
else playing = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tick(time) {
|
|
150
|
+
if (!playing) return
|
|
151
|
+
if (time - lastTime >= msPerFrame) {
|
|
152
|
+
lastTime = time
|
|
153
|
+
advanceFrame()
|
|
154
|
+
if (!playing) return
|
|
155
|
+
}
|
|
156
|
+
rafId = requestAnimationFrame(tick)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
$effect(() => {
|
|
160
|
+
// msPerFrame is a tracked dependency: re-runs on speed change, resetting lastTime=0
|
|
161
|
+
const _ms = msPerFrame
|
|
162
|
+
if (playing && !prefersReducedMotion) {
|
|
163
|
+
lastTime = 0
|
|
164
|
+
rafId = requestAnimationFrame(tick)
|
|
165
|
+
} else {
|
|
166
|
+
cancelAnimationFrame(rafId)
|
|
167
|
+
}
|
|
168
|
+
return () => cancelAnimationFrame(rafId)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Reduced motion: step frames on interval instead of rAF
|
|
172
|
+
let reducedInterval = 0
|
|
173
|
+
$effect(() => {
|
|
174
|
+
if (!playing || !prefersReducedMotion) {
|
|
175
|
+
clearInterval(reducedInterval)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
reducedInterval = setInterval(() => {
|
|
179
|
+
currentIndex = currentIndex + 1
|
|
180
|
+
if (currentIndex >= frameKeys.length) {
|
|
181
|
+
if (animate.loop ?? false) currentIndex = 0
|
|
182
|
+
else { playing = false; clearInterval(reducedInterval) }
|
|
183
|
+
}
|
|
184
|
+
}, msPerFrame)
|
|
185
|
+
return () => clearInterval(reducedInterval)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
onDestroy(() => {
|
|
189
|
+
cancelAnimationFrame(rafId)
|
|
190
|
+
clearInterval(reducedInterval)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
$effect(() => {
|
|
194
|
+
const len = frameKeys.length
|
|
195
|
+
if (currentIndex >= len && len > 0) {
|
|
196
|
+
currentIndex = len - 1
|
|
197
|
+
playing = false
|
|
198
|
+
} else if (len === 0) {
|
|
199
|
+
currentIndex = 0
|
|
200
|
+
playing = false
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
function handlePlay() { playing = true }
|
|
205
|
+
function handlePause() { playing = false }
|
|
206
|
+
function handleScrub(index) { playing = false; currentIndex = index }
|
|
207
|
+
function handleSpeed(s) { speed = s }
|
|
208
|
+
|
|
209
|
+
// Detect horizontal bar chart race: sorted=true AND y is a categorical string field
|
|
210
|
+
const isHorizontalRace = $derived.by(() => {
|
|
211
|
+
if (!sorted || !prepared.data.length) return false
|
|
212
|
+
const sample = prepared.data[0]
|
|
213
|
+
return y && typeof sample[y] === 'string'
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Number of unique entities (y categories) for horizontal race yDomain
|
|
217
|
+
const entityCount = $derived.by(() => {
|
|
218
|
+
if (!isHorizontalRace) return 0
|
|
219
|
+
return new Set(prepared.data.map((d) => d[y])).size
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Tweened display data — smoothly interpolates values between frames.
|
|
223
|
+
// Tween duration is 1.1× the frame interval so tweens always overlap:
|
|
224
|
+
// when the next frame fires, the previous tween is ~91% complete.
|
|
225
|
+
// displayTween.set() starts from the current in-flight value → seamless continuous motion.
|
|
226
|
+
const displayTween = tweened([], { duration: 0 })
|
|
227
|
+
|
|
228
|
+
$effect(() => {
|
|
229
|
+
const raw = currentFrameData
|
|
230
|
+
const xField = x
|
|
231
|
+
const yField = y
|
|
232
|
+
|
|
233
|
+
// Build display target for this frame
|
|
234
|
+
let target
|
|
235
|
+
if (isHorizontalRace) {
|
|
236
|
+
const ranked = raw.slice().sort((a, b) => Number(b[xField]) - Number(a[xField]))
|
|
237
|
+
const n = ranked.length
|
|
238
|
+
target = ranked.map((row, i) => ({
|
|
239
|
+
...row,
|
|
240
|
+
_entity: row[yField],
|
|
241
|
+
_rank: n - 1 - i
|
|
242
|
+
}))
|
|
243
|
+
} else if (sorted) {
|
|
244
|
+
target = raw.slice().sort((a, b) => Number(b[yField]) - Number(a[yField]))
|
|
245
|
+
} else {
|
|
246
|
+
target = raw
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Tween duration slightly longer than frame interval → guaranteed overlap, no pause
|
|
250
|
+
const tweenDuration = Math.round(msPerFrame * 1.1)
|
|
251
|
+
|
|
252
|
+
if (!tween || prefersReducedMotion) {
|
|
253
|
+
displayTween.set(target, { duration: 0 })
|
|
254
|
+
} else if (isHorizontalRace) {
|
|
255
|
+
displayTween.set(target, {
|
|
256
|
+
duration: tweenDuration,
|
|
257
|
+
easing: sineInOut,
|
|
258
|
+
interpolate: (a, b) => {
|
|
259
|
+
const aMap = new Map(a.map((r) => [r._entity, r]))
|
|
260
|
+
return (t) =>
|
|
261
|
+
b.map((r) => {
|
|
262
|
+
const p = aMap.get(r._entity) ?? r
|
|
263
|
+
return {
|
|
264
|
+
...r,
|
|
265
|
+
[xField]: Number(p[xField] ?? 0) * (1 - t) + Number(r[xField]) * t,
|
|
266
|
+
_rank: Number(p._rank ?? r._rank) * (1 - t) + Number(r._rank) * t
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
} else if (sorted) {
|
|
272
|
+
displayTween.set(target, {
|
|
273
|
+
duration: tweenDuration,
|
|
274
|
+
easing: sineInOut,
|
|
275
|
+
interpolate: (a, b) => {
|
|
276
|
+
const aMap = new Map(a.map((r) => [r[xField], r]))
|
|
277
|
+
return (t) =>
|
|
278
|
+
b.map((r) => {
|
|
279
|
+
const p = aMap.get(r[xField]) ?? r
|
|
280
|
+
return { ...r, [yField]: Number(p[yField] ?? 0) * (1 - t) + Number(r[yField]) * t }
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
} else {
|
|
285
|
+
// Unsorted: if y is categorical (string), key by y-value and lerp x (bar width).
|
|
286
|
+
// This keeps bars at stable band positions while widths animate smoothly.
|
|
287
|
+
const yCategorical = yField && raw.length > 0 && typeof raw[0][yField] === 'string'
|
|
288
|
+
if (yCategorical) {
|
|
289
|
+
displayTween.set(target, {
|
|
290
|
+
duration: tweenDuration,
|
|
291
|
+
easing: sineInOut,
|
|
292
|
+
interpolate: (a, b) => {
|
|
293
|
+
const aMap = new Map(a.map((r) => [r[yField], r]))
|
|
294
|
+
return (t) =>
|
|
295
|
+
b.map((r) => {
|
|
296
|
+
const p = aMap.get(r[yField]) ?? r
|
|
297
|
+
return { ...r, [xField]: Number(p[xField] ?? 0) * (1 - t) + Number(r[xField]) * t }
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
} else {
|
|
302
|
+
displayTween.set(target, {
|
|
303
|
+
duration: tweenDuration,
|
|
304
|
+
easing: sineInOut,
|
|
305
|
+
interpolate: (a, b) => (t) =>
|
|
306
|
+
b.map((r, i) => ({
|
|
307
|
+
...r,
|
|
308
|
+
[yField]: Number(a[i]?.[yField] ?? 0) * (1 - t) + Number(r[yField]) * t
|
|
309
|
+
}))
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Tween the x-domain per frame for dynamic axis animation (opt-in).
|
|
316
|
+
// First update is instant to avoid a jarring jump when toggled.
|
|
317
|
+
$effect(() => {
|
|
318
|
+
if (!dynamicDomain || !x || !currentFrameData.length) {
|
|
319
|
+
if (!dynamicDomain) xDomainInitialized = false
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const vals = currentFrameData.map((d) => Number(d[x])).filter((v) => !isNaN(v))
|
|
323
|
+
if (vals.length === 0) return
|
|
324
|
+
const max = Math.max(...vals)
|
|
325
|
+
xDomainTween.set([0, max], {
|
|
326
|
+
duration: xDomainInitialized ? Math.round(msPerFrame * 1.1) : 0,
|
|
327
|
+
easing: sineInOut
|
|
328
|
+
})
|
|
329
|
+
xDomainInitialized = true
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// For horizontal race: inject orientation, label, and labelInside into each geom
|
|
333
|
+
const raceGeoms = $derived(
|
|
334
|
+
prepared.geoms.map((g) => ({
|
|
335
|
+
...g,
|
|
336
|
+
...(label && { label: '_entity' }),
|
|
337
|
+
options: {
|
|
338
|
+
...(g.options ?? {}),
|
|
339
|
+
orientation: 'horizontal',
|
|
340
|
+
...(label && { labelInside: true })
|
|
341
|
+
}
|
|
342
|
+
}))
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const xDomainForFrame = $derived(
|
|
346
|
+
isHorizontalRace && dynamicDomain ? $xDomainTween : staticDomains.xDomain
|
|
347
|
+
)
|
|
348
|
+
const frameSpec = $derived({
|
|
349
|
+
data: $displayTween,
|
|
350
|
+
x,
|
|
351
|
+
y: isHorizontalRace ? '_rank' : y,
|
|
352
|
+
color,
|
|
353
|
+
geoms: isHorizontalRace ? raceGeoms : prepared.geoms,
|
|
354
|
+
xDomain: xDomainForFrame,
|
|
355
|
+
yDomain: isHorizontalRace ? [0, entityCount - 1] : staticDomains.yDomain,
|
|
356
|
+
orientation: isHorizontalRace ? 'horizontal' : undefined
|
|
357
|
+
})
|
|
358
|
+
</script>
|
|
359
|
+
|
|
360
|
+
<div data-plot-animated>
|
|
361
|
+
<PlotChart spec={frameSpec} {helpers} {width} {height} {mode} {grid} {legend}>
|
|
362
|
+
{@render children?.()}
|
|
363
|
+
</PlotChart>
|
|
364
|
+
|
|
365
|
+
<Timeline
|
|
366
|
+
{frameKeys}
|
|
367
|
+
{currentIndex}
|
|
368
|
+
{playing}
|
|
369
|
+
{speed}
|
|
370
|
+
onplay={handlePlay}
|
|
371
|
+
onpause={handlePause}
|
|
372
|
+
onscrub={handleScrub}
|
|
373
|
+
onspeed={handleSpeed}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<style>
|
|
378
|
+
[data-plot-animated] {
|
|
379
|
+
display: flex;
|
|
380
|
+
flex-direction: column;
|
|
381
|
+
width: 100%;
|
|
382
|
+
}
|
|
383
|
+
</style>
|
package/src/Chart.svelte
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { setContext } from 'svelte'
|
|
3
|
+
import { ChartBrewer } from './lib/brewing/brewer.svelte.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @type {{
|
|
7
|
+
* spec?: import('./spec/chart-spec.js').ChartSpec,
|
|
8
|
+
* data?: Object[],
|
|
9
|
+
* x?: string,
|
|
10
|
+
* y?: string,
|
|
11
|
+
* color?: string,
|
|
12
|
+
* pattern?: string,
|
|
13
|
+
* fill?: string,
|
|
14
|
+
* size?: string,
|
|
15
|
+
* label?: string,
|
|
16
|
+
* symbol?: string,
|
|
17
|
+
* width?: number,
|
|
18
|
+
* height?: number,
|
|
19
|
+
* mode?: 'light' | 'dark',
|
|
20
|
+
* children?: import('svelte').Snippet
|
|
21
|
+
* }}
|
|
22
|
+
*/
|
|
23
|
+
let {
|
|
24
|
+
spec = undefined,
|
|
25
|
+
data = [],
|
|
26
|
+
x = undefined,
|
|
27
|
+
y = undefined,
|
|
28
|
+
color = undefined,
|
|
29
|
+
pattern = undefined,
|
|
30
|
+
fill = undefined,
|
|
31
|
+
size = undefined,
|
|
32
|
+
label = undefined,
|
|
33
|
+
symbol = undefined,
|
|
34
|
+
width = 600,
|
|
35
|
+
height = 400,
|
|
36
|
+
mode = 'light',
|
|
37
|
+
children
|
|
38
|
+
} = $props()
|
|
39
|
+
|
|
40
|
+
const brewer = new ChartBrewer()
|
|
41
|
+
setContext('chart-brewer', brewer)
|
|
42
|
+
|
|
43
|
+
function buildChannels() {
|
|
44
|
+
const channels = {}
|
|
45
|
+
if (x) channels.x = x
|
|
46
|
+
if (y) channels.y = y
|
|
47
|
+
if (color) channels.color = color
|
|
48
|
+
if (pattern) channels.pattern = pattern
|
|
49
|
+
if (fill) channels.fill = fill
|
|
50
|
+
if (size) channels.size = size
|
|
51
|
+
if (label) channels.label = label
|
|
52
|
+
if (symbol) channels.symbol = symbol
|
|
53
|
+
return channels
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$effect(() => {
|
|
57
|
+
if (spec) {
|
|
58
|
+
brewer.update({
|
|
59
|
+
data: spec.data,
|
|
60
|
+
channels: spec.channels,
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
mode,
|
|
64
|
+
layers: spec.layers
|
|
65
|
+
})
|
|
66
|
+
} else {
|
|
67
|
+
brewer.update({ data, channels: buildChannels(), width, height, mode })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div class="chart-container" data-chart-root>
|
|
73
|
+
<svg {width} {height} viewBox="0 0 {width} {height}" role="img" aria-label="Chart visualization">
|
|
74
|
+
<g class="chart-area" data-chart-canvas>
|
|
75
|
+
{@render children?.()}
|
|
76
|
+
</g>
|
|
77
|
+
</svg>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<style>
|
|
81
|
+
.chart-container {
|
|
82
|
+
position: relative;
|
|
83
|
+
width: 100%;
|
|
84
|
+
height: auto;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
svg {
|
|
88
|
+
display: block;
|
|
89
|
+
overflow: visible;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.chart-area {
|
|
93
|
+
pointer-events: all;
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { setContext } from 'svelte'
|
|
3
|
+
import { createChartPreset } from './lib/preset.js'
|
|
4
|
+
|
|
5
|
+
let { preset = createChartPreset(), children } = $props()
|
|
6
|
+
|
|
7
|
+
setContext('chart-preset', { get current() { return preset } })
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
{@render children?.()}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import PlotChart from '../Plot.svelte'
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
data,
|
|
6
|
+
x,
|
|
7
|
+
y,
|
|
8
|
+
color,
|
|
9
|
+
geoms = [],
|
|
10
|
+
helpers = {},
|
|
11
|
+
width,
|
|
12
|
+
height,
|
|
13
|
+
mode,
|
|
14
|
+
grid,
|
|
15
|
+
legend,
|
|
16
|
+
xDomain,
|
|
17
|
+
yDomain,
|
|
18
|
+
colorDomain,
|
|
19
|
+
children
|
|
20
|
+
} = $props()
|
|
21
|
+
|
|
22
|
+
// Build spec with domain overrides so PlotState uses them
|
|
23
|
+
const spec = $derived({
|
|
24
|
+
data,
|
|
25
|
+
x,
|
|
26
|
+
y,
|
|
27
|
+
color,
|
|
28
|
+
geoms,
|
|
29
|
+
xDomain,
|
|
30
|
+
yDomain,
|
|
31
|
+
colorDomain
|
|
32
|
+
})
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<PlotChart {spec} {helpers} {width} {height} {mode} {grid} {legend}>
|
|
36
|
+
{@render children?.()}
|
|
37
|
+
</PlotChart>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { splitByField, getFacetDomains } from './lib/plot/facet.js'
|
|
3
|
+
import { distinct } from './lib/brewing/colors.js'
|
|
4
|
+
import PlotPanel from './FacetPlot/Panel.svelte'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @type {{
|
|
8
|
+
* data: Object[],
|
|
9
|
+
* facet: { by: string, cols?: number, scales?: 'fixed'|'free'|'free_x'|'free_y' },
|
|
10
|
+
* x?: string,
|
|
11
|
+
* y?: string,
|
|
12
|
+
* color?: string,
|
|
13
|
+
* fill?: string,
|
|
14
|
+
* pattern?: string,
|
|
15
|
+
* symbol?: string,
|
|
16
|
+
* geom?: string,
|
|
17
|
+
* stat?: string,
|
|
18
|
+
* geoms?: import('./lib/plot/types.js').GeomSpec[],
|
|
19
|
+
* helpers?: import('./lib/plot/types.js').PlotHelpers,
|
|
20
|
+
* panelWidth?: number,
|
|
21
|
+
* panelHeight?: number,
|
|
22
|
+
* width?: number,
|
|
23
|
+
* height?: number,
|
|
24
|
+
* mode?: 'light' | 'dark',
|
|
25
|
+
* grid?: boolean,
|
|
26
|
+
* legend?: boolean,
|
|
27
|
+
* children?: import('svelte').Snippet
|
|
28
|
+
* }}
|
|
29
|
+
*/
|
|
30
|
+
let {
|
|
31
|
+
data = [],
|
|
32
|
+
facet,
|
|
33
|
+
x,
|
|
34
|
+
y,
|
|
35
|
+
fill = undefined,
|
|
36
|
+
color = undefined,
|
|
37
|
+
pattern = undefined,
|
|
38
|
+
symbol = undefined,
|
|
39
|
+
geom = 'bar',
|
|
40
|
+
stat = 'identity',
|
|
41
|
+
geoms = [],
|
|
42
|
+
helpers = {},
|
|
43
|
+
panelWidth,
|
|
44
|
+
panelHeight,
|
|
45
|
+
width = 900,
|
|
46
|
+
height = 300,
|
|
47
|
+
mode = 'light',
|
|
48
|
+
grid = true,
|
|
49
|
+
legend = false,
|
|
50
|
+
children
|
|
51
|
+
} = $props()
|
|
52
|
+
|
|
53
|
+
// `fill` is accepted as an alias for `color` (bar/area semantics vs line/point)
|
|
54
|
+
const colorChannel = $derived(fill ?? color)
|
|
55
|
+
|
|
56
|
+
// Effective geom list: explicit array takes precedence; otherwise build from shorthand props
|
|
57
|
+
const effectiveGeoms = $derived(
|
|
58
|
+
geoms.length > 0
|
|
59
|
+
? geoms
|
|
60
|
+
: [
|
|
61
|
+
{
|
|
62
|
+
type: geom,
|
|
63
|
+
stat,
|
|
64
|
+
...(pattern !== undefined && { pattern }),
|
|
65
|
+
...(symbol !== undefined && { symbol })
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const panels = $derived(splitByField(data, facet.by))
|
|
71
|
+
const scales = $derived(facet.scales ?? 'fixed')
|
|
72
|
+
const domains = $derived(x && y ? getFacetDomains(panels, { x, y }, scales) : new Map())
|
|
73
|
+
|
|
74
|
+
// Global color domain ensures the same value maps to the same color in every panel.
|
|
75
|
+
const colorDomain = $derived(colorChannel ? distinct(data, colorChannel) : undefined)
|
|
76
|
+
|
|
77
|
+
const cols = $derived(facet.cols ?? Math.min(panels.size, 3))
|
|
78
|
+
const pw = $derived(panelWidth ?? Math.floor(width / cols))
|
|
79
|
+
const ph = $derived(panelHeight ?? height)
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<div data-facet-grid style:--facet-cols={cols}>
|
|
83
|
+
{#each [...panels.entries()] as [facetValue, panelData] (`${facetValue}`)}
|
|
84
|
+
<div data-facet-panel data-facet-value={facetValue}>
|
|
85
|
+
<div data-facet-title>{facetValue}</div>
|
|
86
|
+
<PlotPanel
|
|
87
|
+
data={panelData}
|
|
88
|
+
{x}
|
|
89
|
+
{y}
|
|
90
|
+
color={colorChannel}
|
|
91
|
+
geoms={effectiveGeoms}
|
|
92
|
+
{helpers}
|
|
93
|
+
width={pw}
|
|
94
|
+
height={ph}
|
|
95
|
+
{mode}
|
|
96
|
+
{grid}
|
|
97
|
+
legend={false}
|
|
98
|
+
xDomain={domains.get(facetValue)?.xDomain}
|
|
99
|
+
yDomain={domains.get(facetValue)?.yDomain}
|
|
100
|
+
{colorDomain}
|
|
101
|
+
>
|
|
102
|
+
<!-- Render caller-supplied geoms inside every panel (each gets its own PlotState context) -->
|
|
103
|
+
{@render children?.()}
|
|
104
|
+
</PlotPanel>
|
|
105
|
+
</div>
|
|
106
|
+
{/each}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- Single shared legend outside the grid -->
|
|
110
|
+
{#if legend}
|
|
111
|
+
<div data-facet-legend>
|
|
112
|
+
<!-- Legend content rendered by first panel; simplified for now -->
|
|
113
|
+
</div>
|
|
114
|
+
{/if}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getContext } from 'svelte'
|
|
3
|
+
|
|
4
|
+
const brewer = getContext('chart-brewer')
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
{#if brewer && brewer.arcs && brewer.arcs.length > 0}
|
|
8
|
+
<g
|
|
9
|
+
class="chart-arcs"
|
|
10
|
+
data-plot-type="arc"
|
|
11
|
+
transform="translate({brewer.width / 2}, {brewer.height / 2})"
|
|
12
|
+
>
|
|
13
|
+
{#each brewer.arcs as arc (arc.key)}
|
|
14
|
+
<path
|
|
15
|
+
d={arc.d}
|
|
16
|
+
fill={arc.fill}
|
|
17
|
+
stroke={arc.stroke}
|
|
18
|
+
stroke-width="1"
|
|
19
|
+
data-plot-element="arc"
|
|
20
|
+
/>
|
|
21
|
+
{/each}
|
|
22
|
+
</g>
|
|
23
|
+
{/if}
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
.chart-arcs {
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getContext } from 'svelte'
|
|
3
|
+
import { area as d3Area } from 'd3-shape'
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
data = [],
|
|
7
|
+
x = undefined,
|
|
8
|
+
y = undefined,
|
|
9
|
+
y0 = undefined,
|
|
10
|
+
fill = 'steelblue',
|
|
11
|
+
opacity = 0.7,
|
|
12
|
+
curve = undefined
|
|
13
|
+
} = $props()
|
|
14
|
+
|
|
15
|
+
const state = getContext('plot-state')
|
|
16
|
+
|
|
17
|
+
const path = $derived.by(() => {
|
|
18
|
+
if (!state?.xScale || !state?.yScale || !data?.length) return null
|
|
19
|
+
const innerHeight = state.innerHeight
|
|
20
|
+
const areaGen = d3Area()
|
|
21
|
+
.x((d) => state.xScale(x ? d[x] : d) ?? 0)
|
|
22
|
+
.y1((d) => state.yScale(y ? d[y] : d) ?? 0)
|
|
23
|
+
.y0((d) => (y0 !== undefined ? state.yScale(d[y0] ?? y0) : innerHeight) ?? innerHeight)
|
|
24
|
+
.defined((d) => d != null)
|
|
25
|
+
if (curve) areaGen.curve(curve)
|
|
26
|
+
return areaGen(data)
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
{#if path}
|
|
31
|
+
<path d={path} {fill} {opacity} stroke="none" data-plot-element="area" />
|
|
32
|
+
{/if}
|