Skip to content

Commit 17bb918

Browse files
authored
fix: scrub for vega tdd charts (#9223)
* fix scrub for vega tdd charts * use usermeta for better signal matching * remove injected signals * directly use vega lite and DOM for scrub * update comment
1 parent fddb1a6 commit 17bb918

10 files changed

Lines changed: 150 additions & 282 deletions

File tree

web-common/src/components/vega/VegaLiteRenderer.svelte

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@
4848
void viewVL.runAsync();
4949
}
5050
51+
// Memoize colorMapping/hasComparison so they don't cause options to change
52+
// (which triggers svelte-vega to recreate the entire view, losing brush state).
53+
let stableColorMapping: ColorMapping = [];
54+
let stableHasComparison = false;
55+
$: if (JSON.stringify(colorMapping) !== JSON.stringify(stableColorMapping)) {
56+
stableColorMapping = colorMapping;
57+
}
58+
$: if (hasComparison !== stableHasComparison) {
59+
stableHasComparison = hasComparison;
60+
}
61+
5162
$: options = createEmbedOptions({
5263
client: runtimeClient,
5364
width,
@@ -56,14 +67,14 @@
5667
renderer,
5768
themeMode,
5869
expressionFunctions,
59-
colorMapping,
60-
hasComparison,
70+
colorMapping: stableColorMapping,
71+
hasComparison: stableHasComparison,
6172
});
6273
6374
// Create a more efficient key for component remounting
6475
$: configKey = config ? Object.keys(config).sort().join(",") : "default";
6576
$: colorMappingKey =
66-
colorMapping?.map((m) => `${m.value}:${m.color}`)?.join("|") ?? "";
77+
stableColorMapping?.map((m) => `${m.value}:${m.color}`)?.join("|") ?? "";
6778
$: componentKey = `${themeMode}-${configKey}-${colorMappingKey}`;
6879
6980
const onError = (e: Error) => {

web-common/src/components/vega/vega-signals.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,26 @@ export function resolveSignalTimeField(value: unknown, temporalField?: string) {
3232
export function resolveSignalIntervalField(
3333
value: unknown,
3434
): { start: Date; end: Date } | undefined {
35+
const checkAndCreateTimeRange = (
36+
arr: unknown,
37+
): { start: Date; end: Date } | undefined => {
38+
if (Array.isArray(arr) && arr.length === 2) {
39+
const [start, end] = arr;
40+
return { start: new Date(start), end: new Date(end) };
41+
}
42+
return undefined;
43+
};
44+
45+
// Handle raw [date1, date2] array (e.g. from view.signal() on a brush temporal signal)
46+
if (Array.isArray(value)) {
47+
return checkAndCreateTimeRange(value);
48+
}
49+
3550
/**
3651
* Time range fields can be either 'ts' or end with '_ts'
3752
* We check for both cases and return a TimeRange if a valid array of two timestamps is found.
3853
*/
3954
if (typeof value === "object" && value !== null) {
40-
const checkAndCreateTimeRange = (
41-
arr: unknown,
42-
): { start: Date; end: Date } | undefined => {
43-
if (Array.isArray(arr) && arr.length === 2) {
44-
const [start, end] = arr;
45-
return {
46-
start: new Date(start),
47-
end: new Date(end),
48-
};
49-
}
50-
return undefined;
51-
};
52-
5355
// Check for 'ts' key first
5456
if ("ts" in value) {
5557
return checkAndCreateTimeRange(value["ts"]);

web-common/src/features/components/charts/Chart.svelte

Lines changed: 73 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
resolveSignalTimeField,
88
} from "@rilldata/web-common/components/vega/vega-signals";
99
import VegaLiteRenderer from "@rilldata/web-common/components/vega/VegaLiteRenderer.svelte";
10-
import VegaRenderer from "@rilldata/web-common/components/vega/VegaRenderer.svelte";
1110
import type { CanvasChartSpec } from "@rilldata/web-common/features/canvas/components/charts";
1211
import ComponentError from "@rilldata/web-common/features/components/ComponentError.svelte";
1312
import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte";
@@ -20,13 +19,10 @@
2019
import type { TimeRange } from "@rilldata/web-common/lib/time/types";
2120
import type { MetricsViewSpecMeasure } from "@rilldata/web-common/runtime-client";
2221
import { onDestroy } from "svelte";
23-
import type { SignalListeners, VegaSpec, View } from "svelte-vega";
22+
import type { SignalListeners, View } from "svelte-vega";
2423
import type { Readable } from "svelte/store";
2524
import { getChroma } from "../../themes/theme-utils";
26-
import {
27-
compileToBrushedVegaSpec,
28-
createAdaptiveScrubHandler,
29-
} from "./brush-builder";
25+
import { discoverTemporalBrushSignal } from "./brush-builder";
3026
import type { ChartDataResult, ChartType } from "./types";
3127
import { generateSpec, getColorMappingForChart } from "./util";
3228
@@ -42,9 +38,7 @@
4238
export let theme: Record<string, string> | undefined = undefined;
4339
export let isCanvas: boolean;
4440
45-
export let isScrubbing: boolean = false;
4641
export let temporalField: string | undefined = undefined;
47-
export let onBrush: ((interval: TimeRange) => void) | undefined = undefined;
4842
export let onBrushEnd: ((interval: TimeRange) => void) | undefined =
4943
undefined;
5044
export let onBrushClear: (() => void) | undefined = undefined;
@@ -54,10 +48,6 @@
5448
5549
export let view: View;
5650
57-
let vegaSpec: VegaSpec | undefined = undefined;
58-
let prevVlSpec: unknown = undefined;
59-
let compileGeneration = 0;
60-
6151
$: ({ data, domainValues, hasComparison, isFetching, error } = $chartData);
6252
6353
$: hasNoData = !isFetching && data.length === 0;
@@ -77,28 +67,23 @@
7767
}
7868
: $chartData;
7969
80-
$: spec = generateSpec(chartType, chartSpec, chartDataWithTheme);
70+
$: rawSpec = generateSpec(chartType, chartSpec, chartDataWithTheme);
8171
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;
10077
}
10178
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+
10287
// TODO: Move this to a central cached store
10388
$: measureFormatters = measures.reduce(
10489
(acc, measure) => ({
@@ -135,58 +120,84 @@
135120
isThemeModeDark,
136121
);
137122
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);
149125
150-
function buildSignalListeners(
151-
brushEnabled: boolean,
126+
function buildHoverListeners(
152127
hoverEnabled: boolean,
153128
timeField?: string,
154129
): SignalListeners {
155130
const listeners: SignalListeners = {};
156-
157131
if (hoverEnabled) {
158132
listeners.hover = (_name: string, value: unknown) => {
159133
const dimension = resolveSignalField(value, "dimension");
160134
const ts = resolveSignalTimeField(value, timeField);
161135
onHover?.(dimension, ts);
162136
};
163137
}
138+
return listeners;
139+
}
164140
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;
172154
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);
174159
const interval = resolveSignalIntervalField(value);
175160
if (interval) {
176161
onBrushEnd?.(interval);
177-
} else {
178-
// Brush was cleared by clicking outside the selection
179-
onBrushClear?.();
180162
}
181-
};
163+
} catch {
164+
// view may have been finalized
165+
}
166+
};
167+
window.addEventListener("pointerup", pointerUpHandler);
182168
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+
}
186189
}
190+
clearHandler = undefined;
191+
currentBrushSignal = undefined;
192+
}
187193
188-
return listeners;
194+
$: if (useBrush && view) {
195+
attachBrushListener(view);
189196
}
197+
198+
onDestroy(() => {
199+
detachBrushListener();
200+
});
190201
</script>
191202

192203
{#if isFetching || measures.length === 0}
@@ -201,19 +212,6 @@
201212
>
202213
No Data to Display
203214
</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-
/>
217215
{:else}
218216
<VegaLiteRenderer
219217
bind:viewVL={view}
@@ -223,7 +221,7 @@
223221
{spec}
224222
{colorMapping}
225223
{signalListeners}
226-
renderer="canvas"
224+
renderer={useBrush ? "svg" : "canvas"}
227225
{expressionFunctions}
228226
{hasComparison}
229227
config={getRillTheme(isThemeModeDark, theme)}

0 commit comments

Comments
 (0)