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