LCOV - code coverage report
Current view: top level - dashql-app/src/views/visualization - vegalite_view.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 80.9 % 178 144
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1