Line data Source code
1 : import * as React from 'react';
2 : import type { TopLevelSpec } from 'vega-lite';
3 :
4 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
5 : import { useFocusTrap } from '../../ui/foundations/focus.js';
6 : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
7 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
8 : import { AlertIcon, BracesIcon, ScreenFullIcon, ScreenNormalIcon, ToolsIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
9 : import { checkVegaLiteSpec } from '../../ui/vegalite/vegalite_check.js';
10 : import { QueryExecutionStatus, type QueryExecutionState } from '../../query/query_execution_state.js';
11 : import { VegaLiteView } from '../../views/visualization/vegalite_view.js';
12 : import type { ResolvedVisualizeQuery } from '../scripts/script_types.js';
13 : import { chartColumns } from './chart_columns.js';
14 : import { ChartSpecBuilder } from './chart_spec_builder.js';
15 : import { ChartSpecJsonEditor } from './chart_spec_json_editor.js';
16 : import { encodedFields, inlineSharedEncoding, NEW_CHART_SPEC, type ChartSpec } from './chart_spec_paths.js';
17 : import * as styles from './chart_builder.module.css';
18 :
19 1 : const IGNORE_OUTSIDE_CLICK = () => {};
20 1 : const VEGA_LITE_SCHEMA = 'https://vega.github.io/schema/vega-lite/v6.json';
21 : /// Checking the spec compiles it, wait until the user pauses
22 1 : const CHECK_DELAY_MS = 200;
23 :
24 1 : const enum ChartBuilderMode {
25 1 : Builder = 0,
26 1 : Json = 1,
27 1 : }
28 :
29 : /// The spec that the user authored, without the `$schema` and the `data` that the compiler adds
30 1 : export function authoredChartSpec(visualizeQuery: ResolvedVisualizeQuery | null): ChartSpec {
31 1 : if (visualizeQuery?.renderer !== 'vegalite') return { ...NEW_CHART_SPEC };
32 1 : const spec: ChartSpec = { ...(visualizeQuery.vegaLiteSpec as unknown as ChartSpec) };
33 1 : delete spec.$schema;
34 1 : delete spec.data;
35 1 : return spec;
36 1 : }
37 :
38 : export interface ChartBuilderOverlayProps {
39 : /// The display name of the script
40 : scriptName: string;
41 : /// The chart of the script when the overlay opens
42 : visualizeQuery: ResolvedVisualizeQuery | null;
43 : /// The latest query of the script, the preview shows its result
44 : queryState: QueryExecutionState | null;
45 : /// Stage the spec for the `@vis vegalite` tag of the script as a diff to accept or reject
46 : onChange: (spec: string) => void;
47 : onClose: () => void;
48 : dependencies?: ChartBuilderOverlayDependencies;
49 : }
50 :
51 : export interface ChartBuilderOverlayDependencies {
52 : VegaLiteView: typeof VegaLiteView;
53 : }
54 :
55 1 : const DEFAULT_DEPENDENCIES: ChartBuilderOverlayDependencies = { VegaLiteView };
56 :
57 : /// Build the chart of a script: a builder or the JSON of the Vega-Lite spec on the left, the chart on the right.
58 : /// Every edit updates the chart and the diff of the script, the user accepts or rejects the diff in the script.
59 1 : export function ChartBuilderOverlay(props: ChartBuilderOverlayProps): React.ReactElement {
60 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
61 1 : const dialogRef = React.useRef<HTMLElement | null>(null);
62 1 : const [expanded, setExpanded] = React.useState(false);
63 1 : const [mode, setMode] = React.useState<ChartBuilderMode>(ChartBuilderMode.Builder);
64 : // The overlay owns the spec while it is open, the diff of the script follows it
65 1 : const [spec, setSpec] = React.useState<ChartSpec>(() => authoredChartSpec(props.visualizeQuery));
66 1 : const [problems, setProblems] = React.useState<string[]>([]);
67 :
68 1 : const onChangeRef = React.useRef(props.onChange);
69 1 : onChangeRef.current = props.onChange;
70 1 : const updateSpec = React.useCallback((next: ChartSpec) => {
71 1 : setSpec(next);
72 1 : onChangeRef.current(JSON.stringify(next));
73 1 : }, []);
74 :
75 : // The builder edits the encoding of a layer only. An encoding that the layers share moves into the layers when
76 : // the builder shows the spec, the script shows the move as a diff like any other edit.
77 1 : React.useLayoutEffect(() => {
78 1 : if (mode !== ChartBuilderMode.Builder) return;
79 1 : const inlined = inlineSharedEncoding(spec);
80 1 : if (inlined != null) updateSpec(inlined);
81 1 : }, [mode, spec, updateSpec]);
82 :
83 : // Check the spec once the user pauses, the problems show in the header
84 1 : React.useEffect(() => {
85 1 : let cancelled = false;
86 1 : const timer = setTimeout(() => {
87 0 : checkVegaLiteSpec(spec)
88 0 : .then(found => { if (!cancelled) setProblems(found); })
89 0 : .catch(() => {});
90 1 : }, CHECK_DELAY_MS);
91 1 : return () => {
92 1 : cancelled = true;
93 1 : clearTimeout(timer);
94 1 : };
95 1 : }, [spec]);
96 :
97 1 : const query = props.queryState;
98 1 : const resultTable = query?.status === QueryExecutionStatus.SUCCEEDED ? query.resultTable : null;
99 1 : const columns = React.useMemo(() => chartColumns(resultTable?.schema ?? null, encodedFields(spec)), [resultTable, spec]);
100 1 : const previewSpec = React.useMemo(() => ({ $schema: VEGA_LITE_SCHEMA, ...spec }) as unknown as TopLevelSpec, [spec]);
101 :
102 : // Focus the dialog, not its first button
103 1 : useFocusTrap({
104 1 : containerRef: dialogRef as React.RefObject<HTMLElement>,
105 1 : initialFocusRef: dialogRef as React.RefObject<HTMLElement>,
106 1 : restoreFocusOnCleanUp: true,
107 1 : });
108 :
109 1 : return (
110 1 : <Overlay
111 1 : centered
112 1 : width={OverlaySize.XXL}
113 1 : height={OverlaySize.XL}
114 1 : maxHeight={OverlaySize.XL}
115 1 : preventFocusOnOpen
116 1 : onEscape={props.onClose}
117 1 : onClickOutside={IGNORE_OUTSIDE_CLICK}
118 1 : expanded={expanded}
119 : >
120 1 : <section
121 1 : ref={dialogRef}
122 1 : className={styles.dialog}
123 1 : role="dialog"
124 1 : aria-modal="true"
125 1 : tabIndex={-1}
126 1 : aria-label={`Visualize ${props.scriptName} as Chart`}
127 1 : data-expanded={expanded || undefined}
128 1 : data-testid="chart-builder-overlay"
129 : >
130 1 : <section className={styles.editor_pane} aria-label="Chart spec">
131 1 : <header className={styles.pane_header}>
132 1 : <h2 className={styles.title}>Visualize as Chart</h2>
133 1 : <div className={styles.spacer} />
134 1 : <SegmentedControl
135 1 : aria-label="Chart editor"
136 1 : size={SegmentedControlSize.Small}
137 1 : onChange={index => setMode(index as ChartBuilderMode)}
138 : >
139 1 : <SegmentedControl.IconButton
140 1 : icon={ToolsIcon}
141 1 : aria-label="Builder"
142 1 : selected={mode === ChartBuilderMode.Builder}
143 1 : data-testid="chart-mode-builder"
144 1 : />
145 1 : <SegmentedControl.IconButton
146 1 : icon={BracesIcon}
147 1 : aria-label="JSON"
148 1 : selected={mode === ChartBuilderMode.Json}
149 1 : data-testid="chart-mode-json"
150 1 : />
151 1 : </SegmentedControl>
152 1 : </header>
153 1 : <div className={styles.editor_content}>
154 1 : {mode === ChartBuilderMode.Builder ? (
155 1 : <ChartSpecBuilder
156 1 : spec={spec}
157 1 : columns={columns}
158 1 : onChange={updateSpec}
159 1 : onEditJson={() => setMode(ChartBuilderMode.Json)}
160 1 : />
161 : ) : (
162 1 : <ChartSpecJsonEditor initialSpec={spec} onChange={updateSpec} />
163 : )}
164 1 : </div>
165 1 : </section>
166 1 : <section className={styles.preview_pane} aria-label="Chart preview">
167 1 : <header className={styles.pane_header}>
168 1 : {problems.length > 0 && (
169 0 : <div className={styles.diagnostic} role="status" title={problems.join('\n')} data-testid="chart-problems">
170 0 : <AlertIcon size={16} aria-hidden="true" />
171 0 : <span className={styles.diagnostic_text}>{problems[0]}</span>
172 0 : </div>
173 : )}
174 1 : <div className={styles.spacer} />
175 1 : <IconButton
176 1 : variant={ButtonVariant.Invisible}
177 1 : size={ButtonSize.Small}
178 1 : aria-label={expanded ? 'Restore size' : 'Expand'}
179 1 : aria-pressed={expanded}
180 1 : onClick={() => setExpanded(e => !e)}
181 : >
182 1 : {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
183 1 : </IconButton>
184 1 : <IconButton
185 1 : variant={ButtonVariant.Invisible}
186 1 : size={ButtonSize.Small}
187 1 : aria-label="Close chart builder"
188 1 : data-testid="chart-builder-close"
189 1 : onClick={props.onClose}
190 : >
191 1 : <XIcon size={16} />
192 1 : </IconButton>
193 1 : </header>
194 1 : <div className={styles.preview_content} data-testid="chart-preview">
195 1 : {resultTable != null ? (
196 1 : <dependencies.VegaLiteView query={query} vegaLiteSpec={previewSpec} />
197 : ) : (
198 1 : <p className={styles.preview_empty}>Run the query to preview the chart.</p>
199 : )}
200 1 : </div>
201 1 : </section>
202 1 : </section>
203 1 : </Overlay>
204 : );
205 1 : }
|