|
7 | 7 | resolveSignalTimeField, |
8 | 8 | } from "@rilldata/web-common/components/vega/vega-signals"; |
9 | 9 | import VegaLiteRenderer from "@rilldata/web-common/components/vega/VegaLiteRenderer.svelte"; |
10 | | - import VegaRenderer from "@rilldata/web-common/components/vega/VegaRenderer.svelte"; |
11 | 10 | import type { CanvasChartSpec } from "@rilldata/web-common/features/canvas/components/charts"; |
12 | 11 | import ComponentError from "@rilldata/web-common/features/components/ComponentError.svelte"; |
13 | 12 | import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte"; |
|
20 | 19 | import type { TimeRange } from "@rilldata/web-common/lib/time/types"; |
21 | 20 | import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client"; |
22 | 21 | import { onDestroy } from "svelte"; |
23 | | - import type { SignalListeners, VegaSpec, View } from "svelte-vega"; |
| 22 | + import type { SignalListeners, View } from "svelte-vega"; |
24 | 23 | import type { Readable } from "svelte/store"; |
25 | 24 | import { getChroma } from "../../themes/theme-utils"; |
26 | | - import { |
27 | | - compileToBrushedVegaSpec, |
28 | | - createAdaptiveScrubHandler, |
29 | | - } from "./brush-builder"; |
| 25 | + import { discoverTemporalBrushSignal } from "./brush-builder"; |
30 | 26 | import type { ChartDataResult, ChartType } from "./types"; |
31 | 27 | import { generateSpec, getColorMappingForChart } from "./util"; |
32 | 28 |
|
|
42 | 38 | export let theme: Record<string, string> | undefined = undefined; |
43 | 39 | export let isCanvas: boolean; |
44 | 40 |
|
45 | | - export let isScrubbing: boolean = false; |
46 | 41 | export let temporalField: string | undefined = undefined; |
47 | | - export let onBrush: ((interval: TimeRange) => void) | undefined = undefined; |
48 | 42 | export let onBrushEnd: ((interval: TimeRange) => void) | undefined = |
49 | 43 | undefined; |
50 | 44 | export let onBrushClear: (() => void) | undefined = undefined; |
|
54 | 48 |
|
55 | 49 | export let view: View; |
56 | 50 |
|
57 | | - let vegaSpec: VegaSpec | undefined = undefined; |
58 | | - let prevVlSpec: unknown = undefined; |
59 | | - let compileGeneration = 0; |
60 | | -
|
61 | 51 | $: ({ data, domainValues, hasComparison, isFetching, error } = $chartData); |
62 | 52 |
|
63 | 53 | $: hasNoData = !isFetching && data.length === 0; |
|
77 | 67 | } |
78 | 68 | : $chartData; |
79 | 69 |
|
80 | | - $: spec = generateSpec(chartType, chartSpec, chartDataWithTheme); |
| 70 | + $: rawSpec = generateSpec(chartType, chartSpec, chartDataWithTheme); |
81 | 71 |
|
82 | | - // Compile VL spec to Vega spec when brush is enabled. |
83 | | - // Memoize with deep equality to avoid recompilation on store re-emissions |
84 | | - // that produce the same spec, which would reset brush selection state. |
85 | | - $: useBrush = "isInteractive" in chartSpec && !!chartSpec.isInteractive; |
86 | | - $: { |
87 | | - if ( |
88 | | - useBrush && |
89 | | - spec && |
90 | | - JSON.stringify(spec) !== JSON.stringify(prevVlSpec) |
91 | | - ) { |
92 | | - prevVlSpec = spec; |
93 | | - const gen = ++compileGeneration; |
94 | | - void compileToBrushedVegaSpec(spec, isThemeModeDark, theme).then( |
95 | | - (compiled) => { |
96 | | - if (gen === compileGeneration) vegaSpec = compiled; |
97 | | - }, |
98 | | - ); |
99 | | - } |
| 72 | + // Memoize spec with deep equality so VegaLiteRenderer doesn't recreate the |
| 73 | + // view (and kill brush state) on store re-emissions that produce the same spec. |
| 74 | + let spec: ReturnType<typeof generateSpec> = {}; |
| 75 | + $: if (JSON.stringify(rawSpec) !== JSON.stringify(spec)) { |
| 76 | + spec = rawSpec; |
100 | 77 | } |
101 | 78 |
|
| 79 | + $: useBrush = "isInteractive" in chartSpec && !!chartSpec.isInteractive; |
| 80 | +
|
| 81 | + // Read brushTemporalField from the VL spec's usermeta (set by spec generators) |
| 82 | + $: brushTemporalField = |
| 83 | + spec && typeof spec === "object" && "usermeta" in spec |
| 84 | + ? (spec.usermeta as { brushTemporalField?: string })?.brushTemporalField |
| 85 | + : undefined; |
| 86 | +
|
102 | 87 | // TODO: Move this to a central cached store |
103 | 88 | $: measureFormatters = measures.reduce( |
104 | 89 | (acc, measure) => ({ |
|
135 | 120 | isThemeModeDark, |
136 | 121 | ); |
137 | 122 |
|
138 | | - const scrubHandler = createAdaptiveScrubHandler((interval) => |
139 | | - onBrush?.(interval), |
140 | | - ); |
141 | | - onDestroy(() => scrubHandler.destroy()); |
142 | | -
|
143 | | - // Signal listeners for brush and hover events |
144 | | - $: signalListeners = buildSignalListeners( |
145 | | - useBrush && !!vegaSpec, |
146 | | - !!onHover, |
147 | | - temporalField, |
148 | | - ); |
| 123 | + // Hover signal listeners (passed declaratively to VegaLiteRenderer) |
| 124 | + $: signalListeners = buildHoverListeners(!!onHover, temporalField); |
149 | 125 |
|
150 | | - function buildSignalListeners( |
151 | | - brushEnabled: boolean, |
| 126 | + function buildHoverListeners( |
152 | 127 | hoverEnabled: boolean, |
153 | 128 | timeField?: string, |
154 | 129 | ): SignalListeners { |
155 | 130 | const listeners: SignalListeners = {}; |
156 | | -
|
157 | 131 | if (hoverEnabled) { |
158 | 132 | listeners.hover = (_name: string, value: unknown) => { |
159 | 133 | const dimension = resolveSignalField(value, "dimension"); |
160 | 134 | const ts = resolveSignalTimeField(value, timeField); |
161 | 135 | onHover?.(dimension, ts); |
162 | 136 | }; |
163 | 137 | } |
| 138 | + return listeners; |
| 139 | + } |
164 | 140 |
|
165 | | - if (brushEnabled) { |
166 | | - listeners.brush = (_name: string, value: unknown) => { |
167 | | - const interval = resolveSignalIntervalField(value); |
168 | | - // Trigger async rendering to prevent race condition |
169 | | - void view?.runAsync(); |
170 | | - if (interval) scrubHandler.update(interval); |
171 | | - }; |
| 141 | + // Brush-end and brush-clear detection. |
| 142 | + // The temporal brush signal is discovered from the live view because its name |
| 143 | + // includes a timeUnit prefix that varies (e.g. brush_yearmonthdatehours___time). |
| 144 | + let pointerUpHandler: (() => void) | undefined; |
| 145 | + let clearHandler: ((name: string, value: unknown) => void) | undefined; |
| 146 | + let currentBrushSignal: string | undefined; |
| 147 | +
|
| 148 | + function attachBrushListener(v: View) { |
| 149 | + detachBrushListener(); |
| 150 | +
|
| 151 | + const signalName = discoverTemporalBrushSignal(v, brushTemporalField); |
| 152 | + if (!signalName) return; |
| 153 | + currentBrushSignal = signalName; |
172 | 154 |
|
173 | | - listeners.brush_end = (_name: string, value: unknown) => { |
| 155 | + // Detect brush-end via DOM pointerup |
| 156 | + pointerUpHandler = () => { |
| 157 | + try { |
| 158 | + const value = v.signal(signalName); |
174 | 159 | const interval = resolveSignalIntervalField(value); |
175 | 160 | if (interval) { |
176 | 161 | onBrushEnd?.(interval); |
177 | | - } else { |
178 | | - // Brush was cleared by clicking outside the selection |
179 | | - onBrushClear?.(); |
180 | 162 | } |
181 | | - }; |
| 163 | + } catch { |
| 164 | + // view may have been finalized |
| 165 | + } |
| 166 | + }; |
| 167 | + window.addEventListener("pointerup", pointerUpHandler); |
182 | 168 |
|
183 | | - listeners.brush_clear = (_name: string, value: unknown) => { |
184 | | - if (value) onBrushClear?.(); |
185 | | - }; |
| 169 | + // Detect brush-clear (user clicks outside brush or double-clicks) |
| 170 | + clearHandler = (_name: string, value: unknown) => { |
| 171 | + if (value === null || value === undefined) { |
| 172 | + onBrushClear?.(); |
| 173 | + } |
| 174 | + }; |
| 175 | + v.addSignalListener(signalName, clearHandler); |
| 176 | + } |
| 177 | +
|
| 178 | + function detachBrushListener() { |
| 179 | + if (pointerUpHandler) { |
| 180 | + window.removeEventListener("pointerup", pointerUpHandler); |
| 181 | + pointerUpHandler = undefined; |
| 182 | + } |
| 183 | + if (view && currentBrushSignal && clearHandler) { |
| 184 | + try { |
| 185 | + view.removeSignalListener(currentBrushSignal, clearHandler); |
| 186 | + } catch { |
| 187 | + // view may have been finalized |
| 188 | + } |
186 | 189 | } |
| 190 | + clearHandler = undefined; |
| 191 | + currentBrushSignal = undefined; |
| 192 | + } |
187 | 193 |
|
188 | | - return listeners; |
| 194 | + $: if (useBrush && view) { |
| 195 | + attachBrushListener(view); |
189 | 196 | } |
| 197 | +
|
| 198 | + onDestroy(() => { |
| 199 | + detachBrushListener(); |
| 200 | + }); |
190 | 201 | </script> |
191 | 202 |
|
192 | 203 | {#if isFetching || measures.length === 0} |
|
201 | 212 | > |
202 | 213 | No Data to Display |
203 | 214 | </div> |
204 | | -{:else if useBrush && vegaSpec} |
205 | | - <VegaRenderer |
206 | | - bind:view |
207 | | - data={{ "metrics-view": data }} |
208 | | - {isScrubbing} |
209 | | - spec={vegaSpec} |
210 | | - {colorMapping} |
211 | | - theme={themeMode} |
212 | | - {signalListeners} |
213 | | - renderer="svg" |
214 | | - {expressionFunctions} |
215 | | - {hasComparison} |
216 | | - /> |
217 | 215 | {:else} |
218 | 216 | <VegaLiteRenderer |
219 | 217 | bind:viewVL={view} |
|
223 | 221 | {spec} |
224 | 222 | {colorMapping} |
225 | 223 | {signalListeners} |
226 | | - renderer="canvas" |
| 224 | + renderer={useBrush ? "svg" : "canvas"} |
227 | 225 | {expressionFunctions} |
228 | 226 | {hasComparison} |
229 | 227 | config={getRillTheme(isThemeModeDark, theme)} |
|
0 commit comments