Line data Source code
1 : import * as React from 'react';
2 : import type { Config, TopLevelSpec } from 'vega-lite';
3 :
4 : import * as styles from './visualization.module.css';
5 : import { loadVega } from '../../ui/vegalite/vegalite_loader.js';
6 : import { arrowTableToRows } from '../../ui/vegalite/vegalite_rows.js';
7 : import { type ChartColors, createEmbedOptions, createVegaLiteConfig, resolveChartColors } from '../../ui/vegalite/vegalite_theme.js';
8 : import { withViewEncodings } from '../../ui/vegalite/vegalite_spec.js';
9 : import { useColorScheme } from '../../config/color_scheme.js';
10 : import { useAppFlavor } from '../../config/flavor_provider.js';
11 : import { QueryExecutionState, QueryExecutionStatus } from '../../query/query_execution_state.js';
12 : import { useComputationRegistry } from '../../compute/computation_registry.js';
13 : import { CardPlaceholder, QueryStatusPlaceholder } from '../card_placeholder.js';
14 : import {
15 : filterTableToVegaCrossFilterRows,
16 : injectVegaStableScaleDomains,
17 : injectVegaLiteCrossFilter,
18 : VegaCrossFilterMask,
19 : VegaCrossFilterUpdater,
20 : VegaStableScaleDomain,
21 : } from './vegalite_crossfilter.js';
22 :
23 : type VegaEmbed = typeof import('vega-embed').default;
24 : type VegaExpressionInterpreter = typeof import('vega-interpreter').expressionInterpreter;
25 :
26 : export interface VegaLiteViewDependencies {
27 : loadVega(): Promise<{
28 : embed: VegaEmbed;
29 : expressionInterpreter: VegaExpressionInterpreter;
30 : }>;
31 : useComputationRegistry(): [ReturnType<typeof useComputationRegistry>[0]];
32 : useNearViewport(ref: React.RefObject<HTMLElement | null>, mounted: boolean): boolean;
33 : }
34 :
35 : /// How far ahead of the viewport a chart is embedded, scrolling then rarely shows an empty chart
36 1 : const VIEWPORT_MARGIN = '50% 0px';
37 :
38 : /// Has the element come near the viewport? Latches once it did.
39 : /// Embedding a chart costs the main thread a lot, the charts out of view wait.
40 : /// The scroll margin also extends nested scroll containers like the feed list.
41 1 : export function useNearViewport(ref: React.RefObject<HTMLElement | null>, mounted: boolean): boolean {
42 1 : const [near, setNear] = React.useState(() => typeof IntersectionObserver === 'undefined');
43 1 : React.useEffect(() => {
44 1 : const element = ref.current;
45 1 : if (near || !mounted || element == null) return;
46 1 : const observer = new IntersectionObserver(entries => {
47 1 : if (entries.some(entry => entry.isIntersecting)) {
48 1 : observer.disconnect();
49 1 : setNear(true);
50 1 : }
51 1 : }, { rootMargin: VIEWPORT_MARGIN, scrollMargin: VIEWPORT_MARGIN });
52 1 : observer.observe(element);
53 1 : return () => observer.disconnect();
54 1 : }, [ref, near, mounted]);
55 1 : return near;
56 1 : }
57 :
58 1 : const DEFAULT_DEPENDENCIES: VegaLiteViewDependencies = {
59 1 : loadVega,
60 1 : useNearViewport,
61 1 : useComputationRegistry: () => {
62 1 : const [state] = useComputationRegistry();
63 1 : return [state];
64 1 : },
65 1 : };
66 :
67 : interface Props {
68 : query: QueryExecutionState | null;
69 : vegaLiteSpec: TopLevelSpec | null;
70 : /// Optional exact width/height in px for the whole chart. The view is sized to `fit`, so the
71 : /// entire plot (marks, axes, legend) fits the available box. An unset dimension falls back to
72 : /// the container.
73 : width?: number;
74 : height?: number;
75 : /// Optional uniform scale factor (<1 shrinks). `fit` only rescales the plot *area*; label fonts,
76 : /// mark sizes and stroke widths keep their absolute px, so on a tiny box they look oversized.
77 : /// This renders the chart into a `1/scale` larger logical box and CSS-scales the result down, so
78 : /// *everything* — text, marks, axes — shrinks uniformly while still landing in the width×height
79 : /// box. Defaults to 1 (no scaling).
80 : scale?: number;
81 : /// Suppress all legends. Legends eat a lot of space in a cramped host (e.g. a grid card
82 : /// thumbnail), so the compact rendering strips them by injecting `legend: null` into every
83 : /// encoding channel. Defaults to false (legends shown as authored).
84 : hideLegend?: boolean;
85 : /// The Vega-Lite config of the chart, the config of all charts of the app by default
86 : config?: (colors: ChartColors) => Config;
87 : /// The class of the root, the root style of the visualizations by default
88 : className?: string;
89 : dependencies?: VegaLiteViewDependencies;
90 : }
91 :
92 : /// The encoding channels that can produce a legend in Vega-Lite. Setting `legend: null` on any of
93 : /// these suppresses its legend.
94 1 : const LEGEND_CHANNELS = ['color', 'fill', 'stroke', 'size', 'shape', 'opacity', 'strokeWidth', 'strokeDash', 'angle'];
95 :
96 : /// Recursively strip legends from a Vega-Lite spec by setting `legend: null` on every legend-bearing
97 : /// encoding channel. Walks nested specs (layer / hconcat / vconcat / concat / facet / spec) so
98 : /// composed views are covered too. Returns a deep-ish copy with the mutations applied; the input is
99 : /// left untouched.
100 1 : function stripLegends<T>(spec: T): T {
101 1 : if (Array.isArray(spec)) {
102 0 : return spec.map(stripLegends) as unknown as T;
103 : }
104 1 : if (spec == null || typeof spec !== 'object') {
105 0 : return spec;
106 : }
107 1 : const out: Record<string, unknown> = { ...(spec as Record<string, unknown>) };
108 1 : if (out.encoding != null && typeof out.encoding === 'object') {
109 1 : const encoding: Record<string, unknown> = { ...(out.encoding as Record<string, unknown>) };
110 1 : for (const channel of LEGEND_CHANNELS) {
111 1 : const def = encoding[channel];
112 1 : if (def != null && typeof def === 'object' && !Array.isArray(def)) {
113 1 : encoding[channel] = { ...(def as Record<string, unknown>), legend: null };
114 : }
115 1 : }
116 1 : out.encoding = encoding;
117 1 : }
118 1 : for (const key of ['layer', 'hconcat', 'vconcat', 'concat', 'spec']) {
119 1 : if (out[key] != null && typeof out[key] === 'object') {
120 0 : out[key] = stripLegends(out[key]);
121 : }
122 : }
123 1 : return out as unknown as T;
124 1 : }
125 :
126 : /// Structural signatures of specs, computed once per spec object
127 1 : const specSignatures = new WeakMap<object, string>();
128 :
129 1 : function getSpecSignature(spec: object): string {
130 1 : let signature = specSignatures.get(spec);
131 1 : if (signature === undefined) {
132 1 : signature = JSON.stringify(spec);
133 1 : specSignatures.set(spec, signature);
134 1 : }
135 1 : return signature;
136 1 : }
137 :
138 1 : export function VegaLiteView(props: Props): React.ReactElement {
139 1 : const containerRef = React.useRef<HTMLDivElement | null>(null);
140 1 : const crossFilterUpdaterRef = React.useRef<VegaCrossFilterUpdater | null>(null);
141 1 : const [error, setError] = React.useState<string | null>(null);
142 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
143 : // The canvas does not follow the color scheme or the flavor, the chart is embedded again with their new colors
144 1 : const colorScheme = useColorScheme();
145 1 : const flavorId = useAppFlavor().id;
146 1 : const [computationState] = dependencies.useComputationRegistry();
147 :
148 1 : const succeeded = props.query?.status === QueryExecutionStatus.SUCCEEDED;
149 : // Analysis emits a fresh spec object for editor transactions, including cursor-only updates.
150 : // Keep the previous identity when the authored spec is structurally unchanged so Vega's
151 : // effect does not finalize and rebuild the canvas while the user types or moves the cursor.
152 1 : const specSignature = props.vegaLiteSpec == null ? null : getSpecSignature(props.vegaLiteSpec);
153 1 : const spec = React.useMemo(() => props.vegaLiteSpec, [specSignature]);
154 1 : const queryId = props.query?.queryId ?? null;
155 :
156 : // Prefer the analyzed data table (it carries the cross-filter row-id indirection); fall back
157 : // to the raw result table when there is no computation registered. The analyzed table is a
158 : // superset of the result columns (system columns are ignored by the spec).
159 1 : const tableComputation = queryId != null ? computationState.tableComputations[queryId] ?? null : null;
160 1 : const dataTable = tableComputation?.dataTable ?? (succeeded ? props.query?.resultTable ?? null : null);
161 1 : const rowNumberColumnName = tableComputation?.rowNumberColumnName ?? null;
162 :
163 1 : const filterTable = tableComputation?.filterTable ?? null;
164 : // The chart container is rendered only for a spec with a result, see below
165 1 : const nearViewport = dependencies.useNearViewport(containerRef, spec != null && succeeded && dataTable != null);
166 : // The rows are converted for the embedded chart only
167 1 : const rows = React.useMemo<Record<string, unknown>[] | null>(() => {
168 1 : if (!succeeded || !dataTable || !nearViewport) return null;
169 1 : return arrowTableToRows(dataTable);
170 1 : }, [succeeded, dataTable, nearViewport]);
171 1 : const crossFilterBinding = React.useMemo(() => {
172 1 : if (spec == null || dataTable == null) {
173 0 : return null;
174 : }
175 1 : const baseSpec = props.hideLegend ? stripLegends(spec) : spec;
176 1 : return injectVegaLiteCrossFilter(
177 1 : baseSpec,
178 1 : rowNumberColumnName,
179 1 : dataTable.schema.fields.map(field => field.name),
180 1 : );
181 1 : }, [spec, rowNumberColumnName, dataTable, props.hideLegend]);
182 1 : const maskSelectedFieldName = crossFilterBinding?.maskSelectedFieldName ?? null;
183 1 : const crossFilterMask = React.useMemo<VegaCrossFilterMask>(() => ({
184 1 : active: filterTable != null,
185 1 : rows: filterTable != null && maskSelectedFieldName != null
186 0 : ? filterTableToVegaCrossFilterRows(filterTable.dataTable, maskSelectedFieldName)
187 1 : : [],
188 1 : }), [filterTable, maskSelectedFieldName]);
189 1 : const crossFilterMaskRef = React.useRef(crossFilterMask);
190 1 : crossFilterMaskRef.current = crossFilterMask;
191 :
192 1 : React.useEffect(() => {
193 1 : crossFilterUpdaterRef.current?.update(crossFilterMask);
194 1 : }, [crossFilterMask]);
195 :
196 : // A uniform scale (<1) is applied via CSS transform on the chart container. To still land in the
197 : // requested width×height box, the chart is laid out into a `1/scale` larger logical box so that
198 : // scaling it back down recovers the exact size — but with fonts/marks/strokes visually smaller.
199 1 : const scale = props.scale != null && props.scale > 0 ? props.scale : 1;
200 1 : const width = props.width != null ? Math.round(props.width / scale) : null;
201 1 : const height = props.height != null ? Math.round(props.height / scale) : null;
202 1 : React.useEffect(() => {
203 1 : const el = containerRef.current;
204 1 : if (!el || !spec || !rows) return;
205 :
206 1 : let disposed = false;
207 1 : let finalizeView: (() => void) | null = null;
208 1 : let stableScaleDomains: VegaStableScaleDomain[] = [];
209 1 : const baseSpec = crossFilterBinding?.spec ?? (props.hideLegend ? stripLegends(spec) : spec);
210 1 : const runtimeSpec = { ...withViewEncodings(baseSpec) } as TopLevelSpec & {
211 : width?: unknown;
212 : height?: unknown;
213 : autosize?: unknown;
214 : };
215 : // Pin whichever exact dimensions were requested; otherwise fill the container.
216 1 : runtimeSpec.width = width != null ? width : 'container';
217 1 : runtimeSpec.height = height != null ? height : 'container';
218 : // Include axes, legends and titles in the available size, and recalculate their bounds when
219 : // data or the container changes instead of relying on fixed outer padding.
220 1 : runtimeSpec.autosize = { type: 'fit', contains: 'padding', resize: true };
221 : // Lazy-load vega-embed and vega-interpreter to keep them out of the import graph for non-vis paths.
222 1 : dependencies.loadVega().then(({ embed, expressionInterpreter }) => {
223 1 : if (disposed) return;
224 : // The charts of the app share a theme and the embed options, e.g. the CSP-safe expression interpreter
225 1 : const colors = resolveChartColors();
226 1 : const options = createEmbedOptions(colors, expressionInterpreter);
227 1 : const config = (props.config ?? createVegaLiteConfig)(colors);
228 1 : return embed(el, runtimeSpec, {
229 1 : ...options,
230 1 : patch: compiledSpec => {
231 0 : const stableScales = injectVegaStableScaleDomains(compiledSpec);
232 0 : stableScaleDomains = stableScales.domains;
233 0 : return stableScales.spec;
234 0 : },
235 : // `mark.tooltip: true` makes Vega-Lite derive a tooltip from each mark's encoded
236 : // fields; vega-embed installs the vega-tooltip handler by default, so hovering a
237 : // mark shows its data. Applied via config so it covers every mark without editing
238 : // the generated spec.
239 1 : config: { ...config, mark: { ...config.mark, tooltip: true } },
240 1 : });
241 1 : }).then(result => {
242 0 : if (!result) return;
243 0 : if (disposed) {
244 0 : result.view.finalize();
245 0 : return;
246 0 : }
247 0 : finalizeView = () => result.view.finalize();
248 0 : if (crossFilterBinding != null) {
249 0 : const updater = new VegaCrossFilterUpdater(
250 0 : result.view,
251 0 : crossFilterBinding.sourceDatasetName,
252 0 : rows,
253 0 : crossFilterBinding.maskDatasetName,
254 0 : crossFilterBinding.maskActiveSignalName,
255 0 : e => setError(e instanceof Error ? e.message : String(e)),
256 0 : stableScaleDomains,
257 0 : );
258 0 : crossFilterUpdaterRef.current = updater;
259 0 : updater.update(crossFilterMaskRef.current);
260 0 : }
261 0 : setError(null);
262 1 : }).catch((e: unknown) => {
263 0 : setError(e instanceof Error ? e.message : String(e));
264 1 : });
265 1 : return () => {
266 1 : disposed = true;
267 1 : crossFilterUpdaterRef.current?.dispose();
268 1 : crossFilterUpdaterRef.current = null;
269 1 : finalizeView?.();
270 1 : if (el) el.replaceChildren();
271 1 : };
272 1 : }, [spec, rows, crossFilterBinding, width, height, scale, props.hideLegend, props.config, dependencies, colorScheme, flavorId]);
273 :
274 1 : if (!spec) {
275 0 : return <CardPlaceholder message="No visualization available" />;
276 : }
277 1 : if (!succeeded) {
278 0 : return <QueryStatusPlaceholder query={props.query} />;
279 : }
280 1 : if (!dataTable) {
281 0 : return <CardPlaceholder message="Result is empty" />;
282 : }
283 :
284 : // When scaling, the chart container is laid out at the enlarged logical size and shrunk with a
285 : // CSS transform anchored top-left, so the whole plot renders smaller within the requested box.
286 : // Reserve explicit dimensions before the async vega-embed import finishes. This is important in
287 : // the virtualized feed: a remounted chart otherwise collapses temporarily, causing its row's
288 : // ResizeObserver to publish a smaller height before the canvas restores the final height.
289 1 : const chartStyle: React.CSSProperties | undefined = width != null || height != null || scale !== 1
290 1 : ? {
291 1 : width: width ?? undefined,
292 1 : height: height ?? undefined,
293 1 : transform: scale !== 1 ? `scale(${scale})` : undefined,
294 1 : transformOrigin: scale !== 1 ? 'top left' : undefined,
295 1 : }
296 0 : : undefined;
297 :
298 1 : return (
299 1 : <div className={props.className ?? styles.root}>
300 1 : {error && <div className={styles.error}>{error}</div>}
301 1 : <div ref={containerRef} className={styles.chart} style={chartStyle} />
302 1 : </div>
303 : );
304 1 : }
|