LCOV - code coverage report
Current view: top level - dashql-app/src/ui/vegalite - vegalite_chart.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 96.6 % 118 114
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import type { TopLevelSpec } from 'vega-lite';
       3              : import type { View } from 'vega';
       4              : 
       5              : import { observeSize } from '../foundations/size_observer.js';
       6              : import { loadVega, type VegaModules } from './vegalite_loader.js';
       7              : import { createEmbedOptions, resolveChartColors, type ChartColors } from './vegalite_theme.js';
       8              : import { useColorScheme } from '../../config/color_scheme.js';
       9              : import { useAppFlavor } from '../../config/flavor_provider.js';
      10              : import { withViewEncodings } from './vegalite_spec.js';
      11              : import * as styles from './vegalite_chart.module.css';
      12              : 
      13              : /// The items of the named datasets of a spec, by dataset name
      14              : export type VegaDatasets = Readonly<Record<string, readonly object[]>>;
      15              : /// The values of the top-level params of a spec, by param name
      16              : export type VegaSignals = Readonly<Record<string, unknown>>;
      17              : 
      18              : interface Props {
      19              :     /// The spec, or a function of the chart colors of the app that returns it.
      20              :     /// Its data are named datasets, e.g. `data: { name: 'rows' }`, filled from `datasets`.
      21              :     /// A new spec object embeds the chart again, keep its identity while it is unchanged.
      22              :     spec: TopLevelSpec | ((colors: ChartColors) => TopLevelSpec);
      23              :     /// The items of the named datasets. Items keep their identity across updates: an update only inserts the new
      24              :     /// items and removes the dropped ones, so that a live chart costs little per update.
      25              :     datasets: VegaDatasets;
      26              :     /// The values of top-level params of the spec, e.g. the bounds of a sliding time window
      27              :     signals?: VegaSignals;
      28              :     /// The class of the chart element, it sizes the chart
      29              :     className?: string;
      30              :     /// Receives the view once it is embedded, and null when it is finalized
      31              :     onView?: (view: View | null) => void;
      32              :     /// Load the Vega modules, for tests
      33              :     loader?: () => Promise<VegaModules>;
      34              :     /// The test id of the chart element
      35              :     'data-testid'?: string;
      36              : }
      37              : 
      38              : /// The state of a dataset in the view
      39              : interface AppliedDataset {
      40              :     /// The items in the view
      41              :     items: readonly object[];
      42              :     /// The rows of the items, Vega tags the rows it ingests, so every view gets its own copies
      43              :     rows: WeakMap<object, object>;
      44              : }
      45              : 
      46              : /// Apply the datasets and signals that changed since the last update to a view
      47            1 : function applyUpdates(
      48            1 :     vega: VegaModules['vega'],
      49            1 :     view: View,
      50            1 :     applied: Map<string, AppliedDataset>,
      51            1 :     appliedSignals: Map<string, unknown>,
      52            1 :     datasets: VegaDatasets,
      53            1 :     signals: VegaSignals,
      54            1 : ): boolean {
      55            1 :     let changed = false;
      56            1 :     for (const [name, items] of Object.entries(datasets)) {
      57            1 :         let state = applied.get(name);
      58            1 :         if (state?.items === items) continue;
      59            1 :         state ??= { items: [], rows: new WeakMap() };
      60            1 :         const previous = new Set(state.items);
      61            1 :         const next = new Set(items);
      62            1 :         const rowOf = (item: object) => {
      63            1 :             let row = state!.rows.get(item);
      64            1 :             if (row == null) {
      65            1 :                 row = { ...item };
      66            1 :                 state!.rows.set(item, row);
      67            1 :             }
      68            1 :             return row;
      69            1 :         };
      70            1 :         const removed = state.items.filter(item => !next.has(item)).map(rowOf);
      71            1 :         const inserted = items.filter(item => !previous.has(item)).map(rowOf);
      72            1 :         view.change(name, vega.changeset().remove(removed).insert(inserted));
      73            1 :         applied.set(name, { items, rows: state.rows });
      74            1 :         changed = true;
      75            1 :     }
      76            1 :     for (const [name, value] of Object.entries(signals)) {
      77            1 :         if (appliedSignals.has(name) && Object.is(appliedSignals.get(name), value)) continue;
      78            1 :         view.signal(name, value);
      79            1 :         appliedSignals.set(name, value);
      80            1 :         changed = true;
      81            1 :     }
      82            1 :     return changed;
      83            1 : }
      84              : 
      85            1 : const NO_SIGNALS: VegaSignals = {};
      86              : 
      87              : /// A Vega-Lite chart with the theme of the app that is embedded once and then updated in place.
      88              : /// It fills its element, which the class sizes.
      89            1 : export function VegaLiteChart(props: Props): React.ReactElement {
      90            1 :     const container = React.useRef<HTMLDivElement | null>(null);
      91            1 :     const size = observeSize(container);
      92            1 :     const [error, setError] = React.useState<string | null>(null);
      93            1 :     const [ready, setReady] = React.useState(false);
      94            1 :     const viewRef = React.useRef<{ view: View; vega: VegaModules['vega'] } | null>(null);
      95            1 :     const applied = React.useRef(new Map<string, AppliedDataset>());
      96            1 :     const appliedSignals = React.useRef(new Map<string, unknown>());
      97            1 :     const signals = props.signals ?? NO_SIGNALS;
      98            1 :     const latest = React.useRef({ datasets: props.datasets, signals });
      99            1 :     latest.current = { datasets: props.datasets, signals };
     100            1 :     const onView = React.useRef(props.onView);
     101            1 :     onView.current = props.onView;
     102            1 :     const loader = props.loader ?? loadVega;
     103              :     // The canvas does not follow the color scheme or the flavor, the chart is embedded again with their new colors
     104            1 :     const colorScheme = useColorScheme();
     105            1 :     const flavorId = useAppFlavor().id;
     106              :     // The sizes of the datasets as data-dataset-<name> attributes
     107            1 :     const datasetSizes = Object.fromEntries(
     108            1 :         Object.entries(props.datasets).map(([name, items]) => [`data-dataset-${name}`, items.length]),
     109            1 :     );
     110              : 
     111              :     // Embed the spec, the datasets and signals follow as updates
     112            1 :     React.useEffect(() => {
     113            1 :         const element = container.current;
     114            1 :         if (element == null) return;
     115            1 :         let disposed = false;
     116            1 :         let view: View | null = null;
     117            1 :         setReady(false);
     118            1 :         loader().then(async ({ embed, vega, expressionInterpreter }) => {
     119            1 :             if (disposed) return;
     120            1 :             const colors = resolveChartColors();
     121            1 :             const runtimeSpec = {
     122            1 :                 ...withViewEncodings(typeof props.spec === 'function' ? props.spec(colors) : props.spec),
     123            1 :                 width: 'container',
     124            1 :                 height: 'container',
     125            1 :                 autosize: { type: 'fit', contains: 'padding', resize: true },
     126            1 :             } as TopLevelSpec;
     127            1 :             const result = await embed(element, runtimeSpec, createEmbedOptions(colors, expressionInterpreter));
     128            1 :             if (disposed) {
     129            0 :                 result.view.finalize();
     130            0 :                 return;
     131            0 :             }
     132            1 :             view = result.view;
     133            1 :             applied.current = new Map();
     134            1 :             appliedSignals.current = new Map();
     135            1 :             applyUpdates(vega, view, applied.current, appliedSignals.current, latest.current.datasets, latest.current.signals);
     136            1 :             await view.runAsync();
     137            1 :             viewRef.current = { view, vega };
     138            1 :             onView.current?.(view);
     139            1 :             setError(null);
     140            1 :             setReady(true);
     141            1 :         }).catch((e: unknown) => {
     142            0 :             if (!disposed) setError(e instanceof Error ? e.message : String(e));
     143            1 :         });
     144            1 :         return () => {
     145            1 :             disposed = true;
     146            1 :             viewRef.current = null;
     147            1 :             if (view != null) {
     148            1 :                 onView.current?.(null);
     149            1 :                 view.finalize();
     150            1 :             }
     151            1 :             element.replaceChildren();
     152            1 :         };
     153            1 :     }, [props.spec, loader, colorScheme, flavorId]);
     154              : 
     155              :     // Update the datasets and signals in place
     156            1 :     React.useEffect(() => {
     157            1 :         const current = viewRef.current;
     158            1 :         if (current == null || !ready) return;
     159            1 :         if (applyUpdates(current.vega, current.view, applied.current, appliedSignals.current, props.datasets, signals)) {
     160            1 :             current.view.runAsync().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
     161              :         }
     162            1 :     }, [ready, props.datasets, signals]);
     163              : 
     164              :     // Follow the size of the element, also when the window keeps its size
     165            1 :     React.useEffect(() => {
     166            1 :         viewRef.current?.view.resize().runAsync().catch(() => {});
     167            1 :     }, [size?.width, size?.height]);
     168              : 
     169            1 :     return (
     170            1 :         <div
     171            1 :             className={props.className}
     172            1 :             data-testid={props['data-testid']}
     173            1 :             data-state={error != null ? 'error' : ready ? 'ready' : 'loading'}
     174            1 :             {...datasetSizes}
     175              :         >
     176            1 :             {error != null && <div className={styles.error}>{error}</div>}
     177            1 :             <div ref={container} className={styles.chart} />
     178            1 :         </div>
     179              :     );
     180            1 : }
        

Generated by: LCOV version 2.0-1