|
1 | 1 | <script> |
2 | 2 | import { onMount, tick } from "svelte"; |
3 | 3 | import embed from "vega-embed"; |
| 4 | + import * as vega from "vega"; |
| 5 | + import { buildColorSpecKey } from "../lib/dataProcessing.js"; |
4 | 6 |
|
5 | 7 | let { |
6 | 8 | data = [], |
|
10 | 12 | colorMap = {}, |
11 | 13 | title = "", |
12 | 14 | xLim = null, |
| 15 | + yExtent = undefined, |
13 | 16 | onSelect = null, |
14 | 17 | onResetZoom = null, |
15 | 18 | draggable = false, |
|
24 | 27 | let view = $state(null); |
25 | 28 | let fullscreen = $state(false); |
26 | 29 |
|
| 30 | + let lastStructuralKey = null; |
| 31 | + let lastHasSmoothed = false; |
| 32 | +
|
27 | 33 | let legendEntries = $derived.by(() => { |
28 | 34 | if (!colorField || !data || data.length === 0) return []; |
29 | 35 | const seen = new Set(); |
|
38 | 44 | return entries; |
39 | 45 | }); |
40 | 46 |
|
| 47 | + let colorSpecKey = $derived(buildColorSpecKey(data, colorField, colorMap)); |
| 48 | +
|
41 | 49 | function cssVar(name, fallback) { |
42 | 50 | return ( |
43 | 51 | getComputedStyle(document.documentElement) |
|
46 | 54 | ); |
47 | 55 | } |
48 | 56 |
|
| 57 | + function splitData() { |
| 58 | + const originalData = data.filter( |
| 59 | + (d) => d.data_type === "original" || !d.data_type, |
| 60 | + ); |
| 61 | + const smoothedData = data.filter((d) => d.data_type === "smoothed"); |
| 62 | + return { originalData, smoothedData, hasSmoothed: smoothedData.length > 0 }; |
| 63 | + } |
| 64 | +
|
| 65 | + function computeXDomain(originalData) { |
| 66 | + const xVals = originalData.map((d) => d[x]).filter((v) => v != null); |
| 67 | + if (xLim) return [xLim[0], xLim[1]]; |
| 68 | + if (xVals.length > 0) return [Math.min(...xVals), Math.max(...xVals)]; |
| 69 | + return undefined; |
| 70 | + } |
| 71 | +
|
49 | 72 | function buildSpec() { |
50 | 73 | const hasColor = |
51 | 74 | colorField && data.length > 0 && Object.hasOwn(data[0], colorField); |
|
58 | 81 | (r) => colorMap[r] || "#999", |
59 | 82 | ); |
60 | 83 |
|
61 | | - const originalData = data.filter( |
62 | | - (d) => d.data_type === "original" || !d.data_type, |
63 | | - ); |
64 | | - const smoothedData = data.filter((d) => d.data_type === "smoothed"); |
65 | | - const hasSmoothed = smoothedData.length > 0; |
| 84 | + const { originalData, smoothedData, hasSmoothed } = splitData(); |
| 85 | + lastHasSmoothed = hasSmoothed; |
| 86 | + const xDomain = computeXDomain(originalData); |
66 | 87 |
|
67 | 88 | const xEnc = { |
68 | 89 | field: x, |
69 | 90 | type: "quantitative", |
70 | | - scale: { zero: false, ...(xLim ? { domain: [xLim[0], xLim[1]] } : {}) }, |
| 91 | + scale: { zero: false, ...(xDomain ? { domain: xDomain } : {}) }, |
| 92 | + }; |
| 93 | + const yEnc = { |
| 94 | + field: y, |
| 95 | + type: "quantitative", |
| 96 | + ...(yExtent ? { scale: { domain: yExtent } } : {}), |
71 | 97 | }; |
72 | | - const yEnc = { field: y, type: "quantitative" }; |
73 | 98 | const colorEnc = hasColor |
74 | 99 | ? { |
75 | 100 | color: { |
|
83 | 108 |
|
84 | 109 | const layers = []; |
85 | 110 |
|
| 111 | + const lineMark = (extra = {}) => ({ |
| 112 | + type: "line", |
| 113 | + clip: true, |
| 114 | + strokeWidth: 2, |
| 115 | + ...extra, |
| 116 | + }); |
| 117 | +
|
86 | 118 | if (hasSmoothed) { |
87 | 119 | layers.push({ |
88 | | - data: { values: originalData }, |
89 | | - mark: { type: "line", clip: true, strokeWidth: 1, opacity: 0.3, point: { size: 20, opacity: 0.3 } }, |
| 120 | + data: { name: "data_original", values: originalData }, |
| 121 | + mark: lineMark({ strokeWidth: 1, opacity: 0.3 }), |
90 | 122 | encoding: { x: xEnc, y: yEnc, ...colorEnc }, |
91 | 123 | name: "original", |
92 | 124 | }); |
93 | 125 | layers.push({ |
94 | | - data: { values: smoothedData }, |
95 | | - mark: { type: "line", clip: true, strokeWidth: 2, point: { size: 20 } }, |
| 126 | + data: { name: "data_smoothed", values: smoothedData }, |
| 127 | + mark: lineMark(), |
96 | 128 | encoding: { x: xEnc, y: yEnc, ...colorEnc }, |
97 | 129 | name: "plot", |
98 | 130 | }); |
99 | 131 | } else { |
100 | 132 | layers.push({ |
101 | | - data: { values: data }, |
102 | | - mark: { type: "line", clip: true, strokeWidth: 2, point: { size: 20 } }, |
| 133 | + data: { name: "data_plot", values: data }, |
| 134 | + mark: lineMark(), |
103 | 135 | encoding: { x: xEnc, y: yEnc, ...colorEnc }, |
104 | 136 | name: "plot", |
105 | 137 | }); |
|
153 | 185 | }; |
154 | 186 | } |
155 | 187 |
|
156 | | - async function render() { |
| 188 | + function getStructuralKey() { |
| 189 | + const { originalData } = splitData(); |
| 190 | + const xDomain = computeXDomain(originalData); |
| 191 | + const xKey = xDomain ? `${xDomain[0]},${xDomain[1]}` : "auto"; |
| 192 | + const yKey = yExtent ? `${yExtent[0]},${yExtent[1]}` : "auto"; |
| 193 | + return `${y}\0${x}\0${colorSpecKey}\0${title}\0${fullscreen}\0${!!onSelect}\0${xKey}\0${yKey}`; |
| 194 | + } |
| 195 | +
|
| 196 | + function replaceDataset(v, name, newData) { |
| 197 | + const cs = vega.changeset().remove(vega.truthy).insert(newData); |
| 198 | + v.change(name, cs); |
| 199 | + } |
| 200 | +
|
| 201 | + function tryIncrementalUpdate() { |
| 202 | + if (!view) return false; |
| 203 | +
|
| 204 | + const { originalData, smoothedData, hasSmoothed } = splitData(); |
| 205 | +
|
| 206 | + if (hasSmoothed !== lastHasSmoothed) return false; |
| 207 | +
|
| 208 | + try { |
| 209 | + if (hasSmoothed) { |
| 210 | + replaceDataset(view, "data_original", originalData); |
| 211 | + replaceDataset(view, "data_smoothed", smoothedData); |
| 212 | + } else { |
| 213 | + replaceDataset(view, "data_plot", data); |
| 214 | + } |
| 215 | +
|
| 216 | + view.run(); |
| 217 | + lastHasSmoothed = hasSmoothed; |
| 218 | + return true; |
| 219 | + } catch { |
| 220 | + return false; |
| 221 | + } |
| 222 | + } |
| 223 | +
|
| 224 | + async function fullRender() { |
157 | 225 | await tick(); |
158 | 226 | if (!container || !data || data.length === 0 || !y) return; |
159 | 227 |
|
|
166 | 234 | } |
167 | 235 | const result = await embed(container, spec, { |
168 | 236 | actions: false, |
169 | | - renderer: "svg", |
| 237 | + renderer: "canvas", |
170 | 238 | }); |
171 | 239 | view = result.view; |
| 240 | + lastStructuralKey = getStructuralKey(); |
172 | 241 | requestAnimationFrame(() => { |
173 | 242 | result.view.resize(); |
174 | 243 | }); |
|
193 | 262 | } |
194 | 263 | } |
195 | 264 |
|
| 265 | + async function render() { |
| 266 | + if (!container || !data || data.length === 0 || !y) return; |
| 267 | +
|
| 268 | + const structuralKey = getStructuralKey(); |
| 269 | + if (view && structuralKey === lastStructuralKey) { |
| 270 | + if (tryIncrementalUpdate()) return; |
| 271 | + } |
| 272 | +
|
| 273 | + await fullRender(); |
| 274 | + } |
| 275 | +
|
196 | 276 | function downloadCSV() { |
197 | 277 | if (!data || data.length === 0) return; |
198 | 278 | const originals = data.filter((d) => d.data_type === "original" || !d.data_type); |
|
224 | 304 | async function downloadImage() { |
225 | 305 | if (!view) return; |
226 | 306 | try { |
227 | | - const url = await view.toImageURL("png", 2); |
| 307 | + const url = await view.toImageURL("png", 4); |
228 | 308 | const a = document.createElement("a"); |
229 | 309 | a.href = url; |
230 | 310 | a.download = `${(y || "chart").replace(/\//g, "_")}.png`; |
|
314 | 394 | data; |
315 | 395 | y; |
316 | 396 | x; |
317 | | - colorMap; |
| 397 | + colorSpecKey; |
318 | 398 | xLim; |
| 399 | + yExtent; |
319 | 400 | title; |
320 | 401 | fullscreen; |
321 | 402 | container; |
|
0 commit comments