Line data Source code
1 : import * as React from 'react';
2 : import {
3 : DndContext,
4 : DragOverlay,
5 : KeyboardSensor,
6 : PointerSensor,
7 : useDraggable,
8 : useDroppable,
9 : useSensor,
10 : useSensors,
11 : type DragEndEvent,
12 : type DragStartEvent,
13 : } from '@dnd-kit/core';
14 :
15 : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
16 : import { ChevronDownIcon, ChevronUpIcon, PlusIcon, SymbolIcon, XIcon, type Icon } from '../../ui/foundations/symbol_icon.js';
17 : import { TextInput } from '../../ui/foundations/text_input.js';
18 : import { classNames } from '../../utils/classnames.js';
19 : import type { ChartColumn } from './chart_columns.js';
20 : import { ChartSelect, type ChartSelectOption } from './chart_select.js';
21 : import {
22 : AGGREGATES,
23 : CHANNELS,
24 : DEFAULT_CHANNELS,
25 : FIELD_TYPES,
26 : FIELD_TYPE_SHORT,
27 : PRIMARY_MARKS,
28 : SECONDARY_MARKS,
29 : SORTS,
30 : TIME_UNITS,
31 : addColumnLayer,
32 : addLayer,
33 : channelSummary,
34 : clearChannelField,
35 : encodedChannels,
36 : getChannel,
37 : getLayer,
38 : getMarkType,
39 : getTimeUnit,
40 : isBuilderEditable,
41 : isCountChannel,
42 : isCustomChannel,
43 : layerCount,
44 : newLayerView,
45 : patchChannel,
46 : removeChannel,
47 : removeLayer,
48 : setChannelCount,
49 : setChannelField,
50 : setChannelType,
51 : setLayer,
52 : setMarkType,
53 : setTopLevel,
54 : withTimeUnit,
55 : type ChartSpec,
56 : type FieldType,
57 : } from './chart_spec_paths.js';
58 : import * as styles from './chart_builder.module.css';
59 :
60 1 : const MoreIcon: Icon = SymbolIcon('kebab_horizontal');
61 1 : const TrashIcon: Icon = SymbolIcon('trash_16');
62 :
63 : /// The value of the count option of a field dropdown, no column can have this name
64 1 : const COUNT_OPTION = '\u001fcount';
65 1 : const NONE_OPTION = '\u001fnone';
66 :
67 : /// The marks with a monochrome example icon each
68 1 : const MARKS: { mark: string; icon: Icon }[] = [...PRIMARY_MARKS, ...SECONDARY_MARKS].map(mark => ({ mark, icon: SymbolIcon(`mark_${mark}_16`) }));
69 1 : const MARK_ICONS = new Map(MARKS.map(({ mark, icon }) => [mark, icon]));
70 1 : const TYPE_OPTIONS: ChartSelectOption[] = FIELD_TYPES.map(type => ({ value: type, label: FIELD_TYPE_SHORT[type], description: type }));
71 1 : const AGGREGATE_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...AGGREGATES.map(a => ({ value: a, label: a }))];
72 1 : const TIME_UNIT_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...TIME_UNITS.map(({ unit, description }) => ({ value: unit, label: unit, description }))];
73 1 : const SORT_OPTIONS: ChartSelectOption[] = [{ value: NONE_OPTION, label: 'none' }, ...SORTS.map(s => ({ value: s, label: s }))];
74 1 : const STACK_OPTIONS: ChartSelectOption[] = [
75 1 : { value: NONE_OPTION, label: 'default' },
76 1 : { value: 'zero', label: 'zero' },
77 1 : { value: 'normalize', label: 'normalize' },
78 1 : { value: 'center', label: 'center' },
79 1 : { value: 'null', label: 'off' },
80 1 : ];
81 :
82 : /// The prefixes of the drag and drop ids
83 1 : const COLUMN_DRAG_PREFIX = 'column:';
84 1 : const CHANNEL_DROP_PREFIX = 'channel:';
85 1 : const LAYER_DROP_ID = 'layer:new';
86 :
87 : interface Props {
88 : spec: ChartSpec;
89 : columns: readonly ChartColumn[];
90 : onChange: (spec: ChartSpec) => void;
91 : /// Switch to the JSON, for specs that the builder cannot edit
92 : onEditJson: () => void;
93 : }
94 :
95 : /// The visual editor of a Vega-Lite spec: the view, the columns of the result, and the layers of the chart with a mark
96 : /// and a row per encoding channel each. The columns can be dragged onto a channel, or picked in the dropdown of the
97 : /// channel. Dropping a column onto the layers adds a layer that shows the column.
98 1 : export function ChartSpecBuilder(props: Props): React.ReactElement {
99 1 : const { spec, onChange } = props;
100 : // The layer that the mark and the encoding edit, a single view is one layer
101 1 : const [layerIndex, setLayerIndex] = React.useState(0);
102 1 : const layers = layerCount(spec);
103 1 : const selectedLayer = Math.min(layerIndex, layers - 1);
104 1 : const view = getLayer(spec, selectedLayer);
105 1 : const onViewChange = React.useCallback(
106 1 : (next: ChartSpec) => onChange(setLayer(spec, selectedLayer, next)),
107 1 : [spec, selectedLayer, onChange],
108 1 : );
109 : // Channels that the user added to the selected layer and that have no field yet
110 1 : const [addedChannels, setAddedChannels] = React.useState<string[]>([]);
111 1 : const selectLayer = React.useCallback((index: number) => {
112 1 : setLayerIndex(index);
113 1 : setAddedChannels([]);
114 1 : }, []);
115 1 : const appendLayer = React.useCallback((next: ChartSpec) => {
116 1 : onChange(next);
117 1 : selectLayer(layerCount(next) - 1);
118 1 : }, [onChange, selectLayer]);
119 1 : const [dragColumn, setDragColumn] = React.useState<ChartColumn | null>(null);
120 1 : const sensors = useSensors(
121 1 : useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
122 1 : useSensor(KeyboardSensor),
123 1 : );
124 :
125 1 : const visibleChannels = React.useMemo(() => {
126 1 : const visible = new Set<string>([...DEFAULT_CHANNELS, ...encodedChannels(view), ...addedChannels]);
127 1 : const known: string[] = CHANNELS.filter(channel => visible.has(channel));
128 : // Channels that the builder does not list, e.g. `fill`, are shown after the known ones
129 1 : const unknown = [...visible].filter(channel => !(CHANNELS as readonly string[]).includes(channel));
130 1 : return [...known, ...unknown];
131 1 : }, [view, addedChannels]);
132 1 : const addableChannels = React.useMemo<ChartSelectOption[]>(
133 1 : () => CHANNELS.filter(channel => !visibleChannels.includes(channel)).map(channel => ({ value: channel, label: channel })),
134 1 : [visibleChannels],
135 1 : );
136 :
137 1 : const onDragStart = React.useCallback((event: DragStartEvent) => {
138 0 : setDragColumn((event.active.data.current?.column as ChartColumn | undefined) ?? null);
139 1 : }, []);
140 1 : const onDragEnd = React.useCallback((event: DragEndEvent) => {
141 0 : setDragColumn(null);
142 0 : const column = event.active.data.current?.column as ChartColumn | undefined;
143 0 : const target = event.over?.id;
144 0 : if (column == null || typeof target !== 'string') return;
145 0 : if (target === LAYER_DROP_ID) {
146 0 : appendLayer(addColumnLayer(spec, selectedLayer, { field: column.name, type: column.type }));
147 0 : } else if (target.startsWith(CHANNEL_DROP_PREFIX)) {
148 0 : onViewChange(setChannelField(view, target.slice(CHANNEL_DROP_PREFIX.length), column.name, column.type));
149 : }
150 1 : }, [spec, view, selectedLayer, appendLayer, onViewChange]);
151 :
152 1 : if (!isBuilderEditable(spec)) {
153 1 : return (
154 1 : <div className={styles.builder_unsupported} data-testid="chart-builder-unsupported">
155 1 : <p>This spec composes several views. The builder edits single views and their layers only.</p>
156 1 : <Button size={ButtonSize.Small} onClick={props.onEditJson}>Edit the JSON</Button>
157 1 : </div>
158 : );
159 : }
160 :
161 1 : const markType = getMarkType(view);
162 1 : return (
163 1 : <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragCancel={() => setDragColumn(null)}>
164 1 : <div className={styles.builder} data-testid="chart-builder">
165 : {/* The view is shared by the layers */}
166 1 : <ViewSection spec={spec} onChange={onChange} />
167 1 : <section className={styles.builder_section} aria-labelledby="chart-builder-columns">
168 1 : <h3 id="chart-builder-columns" className={styles.builder_heading}>Columns</h3>
169 1 : {props.columns.length === 0 ? (
170 1 : <p className={styles.builder_hint}>Run the query to list its columns.</p>
171 : ) : (
172 1 : <ul className={styles.column_list} aria-label="Result columns">
173 1 : {props.columns.map(column => <DraggableColumn key={column.name} column={column} />)}
174 1 : </ul>
175 : )}
176 1 : </section>
177 1 : <LayerBar
178 1 : spec={spec}
179 1 : selected={selectedLayer}
180 1 : onSelect={selectLayer}
181 1 : onAdd={() => appendLayer(addLayer(spec, newLayerView(view)))}
182 1 : onRemove={() => {
183 1 : onChange(removeLayer(spec, selectedLayer));
184 1 : selectLayer(Math.max(0, selectedLayer - 1));
185 1 : }}
186 1 : />
187 1 : <div className={styles.layer_panel} data-testid="chart-layer-panel">
188 1 : <section className={styles.builder_section} aria-labelledby="chart-builder-mark">
189 1 : <h3 id="chart-builder-mark" className={styles.builder_heading}>Mark</h3>
190 1 : <div className={styles.mark_list} role="group" aria-labelledby="chart-builder-mark" data-testid="chart-mark">
191 1 : {MARKS.map(({ mark, icon: MarkIcon }) => (
192 1 : <IconButton
193 1 : key={mark}
194 1 : className={styles.mark_button}
195 1 : variant={mark === markType ? ButtonVariant.Default : ButtonVariant.Invisible}
196 1 : size={ButtonSize.Small}
197 1 : aria-label={mark}
198 1 : aria-pressed={mark === markType}
199 1 : data-testid={`chart-mark-${mark}`}
200 1 : onClick={() => { if (mark !== markType) onViewChange(setMarkType(view, mark)); }}
201 : >
202 1 : <MarkIcon size={16} />
203 1 : </IconButton>
204 1 : ))}
205 1 : </div>
206 1 : </section>
207 1 : <section className={styles.builder_section} aria-labelledby="chart-builder-encoding">
208 1 : <div className={styles.builder_heading_row}>
209 1 : <h3 id="chart-builder-encoding" className={styles.builder_heading}>Encoding</h3>
210 1 : {addableChannels.length > 0 && (
211 1 : <ChartSelect
212 1 : aria-label="Add channel"
213 1 : data-testid="chart-add-channel"
214 1 : variant={ButtonVariant.Invisible}
215 1 : value={null}
216 1 : placeholder="Add channel"
217 1 : options={addableChannels}
218 1 : onChange={channel => setAddedChannels(current => [...current, channel])}
219 1 : />
220 : )}
221 1 : </div>
222 1 : <div className={styles.channel_list}>
223 1 : {visibleChannels.map(channel => (
224 1 : <ChannelRow
225 1 : key={`${selectedLayer}:${channel}`}
226 1 : channel={channel}
227 1 : spec={view}
228 1 : columns={props.columns}
229 1 : onChange={onViewChange}
230 1 : onRemove={() => {
231 0 : setAddedChannels(current => current.filter(c => c !== channel));
232 0 : onViewChange(removeChannel(view, channel));
233 0 : }}
234 1 : />
235 1 : ))}
236 1 : </div>
237 1 : </section>
238 1 : </div>
239 1 : </div>
240 1 : <DragOverlay dropAnimation={null}>
241 1 : {dragColumn != null ? <ColumnChip column={dragColumn} dragging /> : null}
242 1 : </DragOverlay>
243 1 : </DndContext>
244 : );
245 1 : }
246 :
247 : interface LayerBarProps {
248 : spec: ChartSpec;
249 : selected: number;
250 : onSelect: (index: number) => void;
251 : onAdd: () => void;
252 : onRemove: () => void;
253 : }
254 :
255 : /// The layers of the chart, one button per layer and a target that adds a layer for a dropped column
256 1 : function LayerBar(props: LayerBarProps) {
257 1 : const count = layerCount(props.spec);
258 1 : return (
259 1 : <section className={styles.builder_section} aria-labelledby="chart-builder-layers">
260 1 : <h3 id="chart-builder-layers" className={styles.builder_heading}>Layers</h3>
261 1 : <div className={styles.layer_bar}>
262 1 : <div className={styles.layer_list} role="group" aria-labelledby="chart-builder-layers" data-testid="chart-layers">
263 1 : {Array.from({ length: count }, (_, index) => {
264 1 : const mark = getMarkType(getLayer(props.spec, index));
265 1 : const MarkIcon = mark != null ? MARK_ICONS.get(mark) : undefined;
266 1 : return (
267 1 : <button
268 1 : key={index}
269 1 : type="button"
270 1 : className={styles.layer_button}
271 1 : aria-pressed={index === props.selected}
272 1 : aria-label={`Layer ${index + 1}: ${mark ?? 'no mark'}`}
273 1 : data-testid={`chart-layer-${index}`}
274 1 : onClick={() => props.onSelect(index)}
275 : >
276 1 : {MarkIcon != null && <MarkIcon size={16} aria-hidden="true" />}
277 1 : <span className={styles.layer_name}>{mark ?? 'layer'}</span>
278 1 : </button>
279 : );
280 1 : })}
281 1 : <AddLayerTarget onAdd={props.onAdd} />
282 1 : </div>
283 1 : {count > 1 && (
284 1 : <IconButton
285 1 : variant={ButtonVariant.Invisible}
286 1 : size={ButtonSize.Small}
287 1 : aria-label={`Remove layer ${props.selected + 1}`}
288 1 : data-testid="chart-layer-remove"
289 1 : onClick={props.onRemove}
290 : >
291 1 : <TrashIcon size={16} />
292 1 : </IconButton>
293 : )}
294 1 : </div>
295 1 : </section>
296 : );
297 1 : }
298 :
299 : /// Add a layer on click, or a layer that shows a column dropped onto it
300 1 : function AddLayerTarget(props: { onAdd: () => void }) {
301 1 : const { setNodeRef, isOver, active } = useDroppable({ id: LAYER_DROP_ID });
302 1 : return (
303 1 : <button
304 1 : ref={setNodeRef}
305 1 : type="button"
306 1 : className={styles.layer_add}
307 1 : data-dragging={active != null || undefined}
308 1 : data-over={isOver || undefined}
309 1 : aria-label="Add a layer"
310 1 : title="Add a layer, or drop a column to show it in a new layer"
311 1 : data-testid="chart-layer-add"
312 1 : onClick={props.onAdd}
313 : >
314 1 : <PlusIcon size={16} aria-hidden="true" />
315 1 : {active != null && <span>Drop to add a layer</span>}
316 1 : </button>
317 : );
318 1 : }
319 :
320 1 : function ColumnChip(props: { column: ChartColumn; dragging?: boolean }) {
321 1 : return (
322 1 : <span
323 1 : className={classNames(styles.column_chip, { [styles.column_chip_dragging]: props.dragging, [styles.column_chip_missing]: props.column.missing })}
324 1 : title={props.column.missing ? `${props.column.name} is no column of the result` : props.column.name}
325 : >
326 1 : <span className={styles.type_badge} data-type={props.column.type}>{FIELD_TYPE_SHORT[props.column.type]}</span>
327 1 : <span className={styles.column_name}>{props.column.name}</span>
328 1 : </span>
329 : );
330 1 : }
331 :
332 1 : function DraggableColumn(props: { column: ChartColumn }) {
333 1 : const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
334 1 : id: `${COLUMN_DRAG_PREFIX}${props.column.name}`,
335 1 : data: { column: props.column },
336 1 : });
337 1 : return (
338 1 : <li
339 1 : ref={setNodeRef}
340 1 : className={classNames(styles.column_item, { [styles.column_item_source]: isDragging })}
341 1 : data-testid="chart-column"
342 1 : {...attributes}
343 1 : {...listeners}
344 1 : aria-label={`Drag ${props.column.name} onto a channel`}
345 : >
346 1 : <ColumnChip column={props.column} />
347 1 : </li>
348 : );
349 1 : }
350 :
351 : interface ChannelRowProps {
352 : channel: string;
353 : spec: ChartSpec;
354 : columns: readonly ChartColumn[];
355 : onChange: (spec: ChartSpec) => void;
356 : onRemove: () => void;
357 : }
358 :
359 1 : function ChannelRow(props: ChannelRowProps) {
360 1 : const { channel, spec, onChange } = props;
361 1 : const [expanded, setExpanded] = React.useState(false);
362 1 : const { setNodeRef, isOver, active } = useDroppable({ id: `${CHANNEL_DROP_PREFIX}${channel}` });
363 1 : const def = getChannel(spec, channel);
364 1 : const custom = isCustomChannel(def);
365 1 : const count = isCountChannel(def);
366 1 : const fieldDef = def != null && !custom ? def as Record<string, unknown> : null;
367 1 : const field = typeof fieldDef?.field === 'string' ? fieldDef.field : null;
368 1 : const type = typeof fieldDef?.type === 'string' && (FIELD_TYPES as readonly string[]).includes(fieldDef.type) ? fieldDef.type as FieldType : null;
369 1 : const encoded = field != null || count;
370 :
371 1 : const fieldOptions = React.useMemo<ChartSelectOption[]>(() => {
372 1 : const options: ChartSelectOption[] = props.columns.map(column => ({
373 1 : value: column.name,
374 1 : label: column.name,
375 1 : description: column.missing ? 'not in the result' : column.type,
376 1 : }));
377 1 : if (field != null && !props.columns.some(column => column.name === field)) {
378 0 : options.push({ value: field, label: field });
379 : }
380 1 : options.push({ value: COUNT_OPTION, label: 'count()', description: 'the number of rows' });
381 1 : return options;
382 1 : }, [props.columns, field]);
383 :
384 1 : const pickField = (value: string) => {
385 1 : if (value === COUNT_OPTION) {
386 1 : onChange(setChannelCount(spec, channel));
387 1 : return;
388 1 : }
389 1 : const column = props.columns.find(c => c.name === value);
390 1 : onChange(setChannelField(spec, channel, value, type ?? column?.type ?? 'nominal'));
391 1 : };
392 :
393 1 : return (
394 1 : <div
395 1 : ref={setNodeRef}
396 1 : className={classNames(styles.channel_row, { [styles.channel_row_drop]: active != null, [styles.channel_row_over]: isOver })}
397 1 : data-testid={`chart-channel-${channel}`}
398 : >
399 1 : <div className={styles.channel_main}>
400 1 : <span className={styles.channel_label}>{channel}</span>
401 1 : {custom ? (
402 1 : <span className={styles.custom_pill} title="Edit this channel in the JSON">custom</span>
403 : ) : (
404 1 : <>
405 1 : <ChartSelect
406 1 : aria-label={`Field of ${channel}`}
407 1 : data-testid={`chart-channel-${channel}-field`}
408 1 : className={styles.field_select}
409 1 : value={count ? COUNT_OPTION : field}
410 1 : triggerLabel={channelSummary(def) ?? undefined}
411 1 : placeholder="Drop or pick a field"
412 1 : options={fieldOptions}
413 1 : onChange={pickField}
414 1 : />
415 1 : <ChartSelect
416 1 : aria-label={`Type of ${channel}`}
417 1 : data-testid={`chart-channel-${channel}-type`}
418 1 : className={styles.type_select}
419 1 : value={type}
420 1 : placeholder="–"
421 1 : options={TYPE_OPTIONS}
422 1 : disabled={!encoded || count}
423 1 : onChange={value => onChange(setChannelType(spec, channel, value as FieldType))}
424 1 : />
425 1 : </>
426 : )}
427 1 : <IconButton
428 1 : variant={ButtonVariant.Invisible}
429 1 : size={ButtonSize.Small}
430 1 : aria-label={`${expanded ? 'Hide' : 'Show'} the options of ${channel}`}
431 1 : aria-expanded={expanded}
432 1 : disabled={!encoded || custom}
433 1 : onClick={() => setExpanded(e => !e)}
434 : >
435 1 : <MoreIcon size={16} />
436 1 : </IconButton>
437 1 : <IconButton
438 1 : variant={ButtonVariant.Invisible}
439 1 : size={ButtonSize.Small}
440 1 : aria-label={`Remove ${channel}`}
441 1 : data-testid={`chart-channel-${channel}-remove`}
442 1 : disabled={def == null && (DEFAULT_CHANNELS as readonly string[]).includes(channel)}
443 1 : onClick={() => {
444 : // A default channel keeps its row, the other channels disappear with their field
445 1 : if (encoded && !custom) {
446 1 : onChange(clearChannelField(spec, channel));
447 : } else {
448 0 : props.onRemove();
449 : }
450 1 : }}
451 : >
452 1 : <XIcon size={16} />
453 1 : </IconButton>
454 1 : </div>
455 1 : {expanded && encoded && !custom && fieldDef != null && (
456 1 : <ChannelOptions channel={channel} def={fieldDef} type={type} count={count} spec={spec} onChange={onChange} />
457 : )}
458 1 : </div>
459 : );
460 1 : }
461 :
462 : interface ChannelOptionsProps {
463 : channel: string;
464 : def: Record<string, unknown>;
465 : type: FieldType | null;
466 : count: boolean;
467 : spec: ChartSpec;
468 : onChange: (spec: ChartSpec) => void;
469 : }
470 :
471 1 : function ChannelOptions(props: ChannelOptionsProps) {
472 1 : const { channel, def, spec, onChange } = props;
473 1 : const patch = (values: Record<string, unknown>) => onChange(patchChannel(spec, channel, values));
474 1 : const optionValue = (value: unknown) => typeof value === 'string' ? value : NONE_OPTION;
475 1 : const sort = typeof def.sort === 'string' ? def.sort : null;
476 1 : const stack = def.stack === null ? 'null' : typeof def.stack === 'string' ? def.stack : NONE_OPTION;
477 1 : const timeUnit = getTimeUnit(def.timeUnit);
478 1 : return (
479 1 : <div className={styles.channel_options} data-testid={`chart-channel-${channel}-options`}>
480 1 : {!props.count && (
481 1 : <div className={styles.option_row}>
482 1 : <span className={styles.option_label}>Aggregate</span>
483 1 : <ChartSelect
484 1 : aria-label={`Aggregate of ${channel}`}
485 1 : className={styles.select_block}
486 1 : value={optionValue(def.aggregate)}
487 1 : placeholder="none"
488 1 : options={AGGREGATE_OPTIONS}
489 1 : onChange={value => patch({ aggregate: value === NONE_OPTION ? undefined : value })}
490 1 : />
491 1 : </div>
492 : )}
493 1 : {props.type === 'quantitative' && !props.count && (
494 0 : <label className={styles.option_row}>
495 0 : <span className={styles.option_label}>Bin</span>
496 0 : <input
497 0 : type="checkbox"
498 0 : aria-label={`Bin ${channel}`}
499 0 : checked={def.bin === true || (def.bin != null && typeof def.bin === 'object')}
500 0 : onChange={event => patch({ bin: event.target.checked ? true : undefined })}
501 0 : />
502 0 : </label>
503 : )}
504 1 : {props.type === 'temporal' && (
505 1 : <div className={styles.option_row}>
506 1 : <span className={styles.option_label}>Time unit</span>
507 1 : <ChartSelect
508 1 : aria-label={`Time unit of ${channel}`}
509 1 : data-testid={`chart-channel-${channel}-time-unit`}
510 1 : className={styles.select_block}
511 1 : value={timeUnit.unit ?? NONE_OPTION}
512 1 : placeholder="none"
513 1 : options={TIME_UNIT_OPTIONS}
514 1 : onChange={value => patch({ timeUnit: withTimeUnit(def.timeUnit, value === NONE_OPTION ? null : value, timeUnit.step) })}
515 1 : />
516 1 : </div>
517 : )}
518 1 : {props.type === 'temporal' && timeUnit.unit != null && (
519 1 : <div className={styles.option_row}>
520 1 : <span className={styles.option_label}>Step</span>
521 1 : <SpecTextInput
522 1 : aria-label={`Time unit step of ${channel}`}
523 1 : data-testid={`chart-channel-${channel}-time-step`}
524 1 : inputMode="numeric"
525 1 : placeholder="1"
526 1 : value={timeUnit.step != null && timeUnit.step > 1 ? String(timeUnit.step) : ''}
527 1 : onCommit={text => {
528 : // An empty text is no step, other text that is no whole number is ignored
529 1 : const trimmed = text.trim();
530 1 : if (trimmed.length > 0 && !/^\d+$/.test(trimmed)) return;
531 1 : const step = trimmed.length === 0 ? null : Number.parseInt(trimmed, 10);
532 1 : patch({ timeUnit: withTimeUnit(def.timeUnit, timeUnit.unit, step) });
533 1 : }}
534 1 : />
535 1 : </div>
536 : )}
537 1 : {(sort != null || def.sort == null) && (
538 1 : <div className={styles.option_row}>
539 1 : <span className={styles.option_label}>Sort</span>
540 1 : <ChartSelect
541 1 : aria-label={`Sort of ${channel}`}
542 1 : className={styles.select_block}
543 1 : value={sort ?? NONE_OPTION}
544 1 : placeholder="none"
545 1 : options={SORT_OPTIONS}
546 1 : onChange={value => patch({ sort: value === NONE_OPTION ? undefined : value })}
547 1 : />
548 1 : </div>
549 : )}
550 1 : {(channel === 'x' || channel === 'y' || channel === 'theta') && (
551 1 : <div className={styles.option_row}>
552 1 : <span className={styles.option_label}>Stack</span>
553 1 : <ChartSelect
554 1 : aria-label={`Stack of ${channel}`}
555 1 : className={styles.select_block}
556 1 : value={stack}
557 1 : placeholder="default"
558 1 : options={STACK_OPTIONS}
559 1 : onChange={value => patch({ stack: value === NONE_OPTION ? undefined : value === 'null' ? null : value })}
560 1 : />
561 1 : </div>
562 : )}
563 1 : <div className={styles.option_row}>
564 1 : <span className={styles.option_label}>Title</span>
565 1 : <SpecTextInput
566 1 : aria-label={`Title of ${channel}`}
567 1 : value={typeof def.title === 'string' ? def.title : ''}
568 1 : onCommit={text => patch({ title: text.length === 0 ? undefined : text })}
569 1 : />
570 1 : </div>
571 1 : </div>
572 : );
573 1 : }
574 :
575 : /// The title of the chart, a string or the text of a title object
576 1 : function chartTitle(spec: ChartSpec): string {
577 1 : const title = spec.title;
578 1 : if (typeof title === 'string') return title;
579 1 : if (title != null && typeof title === 'object' && typeof (title as Record<string, unknown>).text === 'string') {
580 0 : return (title as Record<string, unknown>).text as string;
581 : }
582 1 : return '';
583 1 : }
584 :
585 : /// The settings of the whole chart, collapsed until the user opens them. Collapsed, the heading shows the title.
586 1 : function ViewSection(props: { spec: ChartSpec; onChange: (spec: ChartSpec) => void }) {
587 1 : const [open, setOpen] = React.useState(false);
588 1 : const title = chartTitle(props.spec);
589 1 : return (
590 1 : <section
591 1 : className={classNames(styles.builder_section, styles.builder_section_toggled, { [styles.builder_section_collapsed]: !open })}
592 1 : aria-labelledby="chart-builder-view"
593 : >
594 1 : <h3 className={styles.builder_heading_toggle}>
595 1 : <button
596 1 : type="button"
597 1 : id="chart-builder-view"
598 1 : className={styles.section_toggle}
599 1 : aria-expanded={open}
600 1 : aria-controls="chart-builder-view-settings"
601 1 : data-testid="chart-view-toggle"
602 1 : onClick={() => setOpen(o => !o)}
603 : >
604 1 : <span className={styles.builder_heading}>View</span>
605 1 : {!open && title.length > 0 && <span className={styles.section_summary}>{title}</span>}
606 1 : {open ? <ChevronUpIcon size={16} /> : <ChevronDownIcon size={16} />}
607 1 : </button>
608 1 : </h3>
609 1 : {open && (
610 1 : <div id="chart-builder-view-settings">
611 1 : <ViewSettings spec={props.spec} onChange={props.onChange} />
612 1 : </div>
613 : )}
614 1 : </section>
615 : );
616 1 : }
617 :
618 : /// A size of the view: a number of pixels or `container`
619 0 : function parseViewSize(text: string): number | 'container' | undefined | null {
620 0 : const trimmed = text.trim();
621 0 : if (trimmed.length === 0) return undefined;
622 0 : if (trimmed === 'container') return 'container';
623 0 : const value = Number(trimmed);
624 0 : return Number.isFinite(value) && value > 0 ? value : null;
625 0 : }
626 :
627 1 : function ViewSettings(props: { spec: ChartSpec; onChange: (spec: ChartSpec) => void }) {
628 1 : const { spec, onChange } = props;
629 1 : const title = spec.title;
630 1 : const titleText = chartTitle(spec);
631 1 : const setTitle = (text: string) => {
632 0 : if (title != null && typeof title === 'object' && !Array.isArray(title)) {
633 0 : onChange(setTopLevel(spec, 'title', { ...(title as Record<string, unknown>), text }));
634 : } else {
635 0 : onChange(setTopLevel(spec, 'title', text.length === 0 ? undefined : text));
636 : }
637 0 : };
638 1 : const sizeInput = (key: 'width' | 'height') => (
639 1 : <div className={styles.option_row}>
640 1 : <span className={styles.option_label}>{key === 'width' ? 'Width' : 'Height'}</span>
641 1 : <SpecTextInput
642 1 : aria-label={`${key === 'width' ? 'Width' : 'Height'} of the chart`}
643 1 : placeholder="auto"
644 1 : value={spec[key] == null ? '' : String(spec[key])}
645 1 : onCommit={text => {
646 0 : const size = parseViewSize(text);
647 0 : if (size !== null) onChange(setTopLevel(spec, key, size));
648 0 : }}
649 1 : />
650 1 : </div>
651 : );
652 1 : return (
653 1 : <div className={styles.view_settings}>
654 1 : <div className={styles.option_row}>
655 1 : <span className={styles.option_label}>Title</span>
656 1 : <SpecTextInput aria-label="Title of the chart" value={titleText} onCommit={setTitle} data-testid="chart-title" />
657 1 : </div>
658 1 : {sizeInput('width')}
659 1 : {sizeInput('height')}
660 1 : </div>
661 : );
662 1 : }
663 :
664 : interface SpecTextInputProps {
665 : 'aria-label': string;
666 : 'data-testid'?: string;
667 : value: string;
668 : placeholder?: string;
669 : inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
670 : /// Every edit of the text, the caller ignores text that is no valid value
671 : onCommit: (text: string) => void;
672 : }
673 :
674 : /// A text input that keeps the typed text while the spec holds another representation, e.g. a partly typed size
675 1 : function SpecTextInput(props: SpecTextInputProps) {
676 1 : const [text, setText] = React.useState(props.value);
677 1 : const lastValue = React.useRef(props.value);
678 1 : if (lastValue.current !== props.value) {
679 1 : lastValue.current = props.value;
680 1 : if (text !== props.value) setText(props.value);
681 1 : }
682 1 : return (
683 1 : <TextInput
684 1 : className={styles.text_input}
685 1 : aria-label={props['aria-label']}
686 1 : data-testid={props['data-testid']}
687 1 : placeholder={props.placeholder}
688 1 : inputMode={props.inputMode}
689 1 : value={text}
690 1 : onChange={event => {
691 1 : setText(event.target.value);
692 1 : props.onCommit(event.target.value);
693 1 : }}
694 1 : />
695 : );
696 1 : }
|