Line data Source code
1 : /// Edits of a Vega-Lite spec for the chart builder.
2 : ///
3 : /// The spec itself is the model of the builder. Every edit replaces the objects on the path to one key and keeps
4 : /// all other keys, so properties that the builder does not show (axis, scale, transform, params, ...) survive.
5 :
6 : export type ChartSpec = Record<string, unknown>;
7 : export type ChannelDef = Record<string, unknown>;
8 :
9 : /// The spec of a new chart
10 1 : export const NEW_CHART_SPEC: Readonly<ChartSpec> = { mark: 'bar' };
11 :
12 : /// The marks of the builder, the first ones are shown as buttons
13 1 : export const PRIMARY_MARKS = ['bar', 'line', 'area', 'point'] as const;
14 1 : export const SECONDARY_MARKS = ['circle', 'square', 'tick', 'rect', 'rule', 'text', 'arc', 'boxplot'] as const;
15 :
16 : /// The encoding channels of the builder, in the order of the rows
17 1 : export const CHANNELS = ['x', 'y', 'color', 'size', 'shape', 'opacity', 'text', 'tooltip', 'detail', 'theta', 'x2', 'y2', 'row', 'column'] as const;
18 : export type Channel = typeof CHANNELS[number];
19 : /// The channels that a new chart shows, also while they are empty
20 1 : export const DEFAULT_CHANNELS: readonly Channel[] = ['x', 'y', 'color'];
21 :
22 1 : export const FIELD_TYPES = ['quantitative', 'nominal', 'ordinal', 'temporal'] as const;
23 : export type FieldType = typeof FIELD_TYPES[number];
24 1 : export const FIELD_TYPE_SHORT: Record<FieldType, string> = { quantitative: 'Q', nominal: 'N', ordinal: 'O', temporal: 'T' };
25 :
26 1 : export const AGGREGATES = ['count', 'sum', 'mean', 'median', 'min', 'max', 'distinct'] as const;
27 : /// The time units of the builder. The units that start with the year cut the time into consecutive periods, one mark
28 : /// per period. The others pick a part of the time, the periods of all years fall into the same mark.
29 1 : export const TIME_UNITS: readonly { unit: string; description: string }[] = [
30 1 : { unit: 'year', description: 'one per year' },
31 1 : { unit: 'yearquarter', description: 'one per quarter' },
32 1 : { unit: 'yearmonth', description: 'one per month' },
33 1 : { unit: 'yearweek', description: 'one per week' },
34 1 : { unit: 'yearmonthdate', description: 'one per day' },
35 1 : { unit: 'yearmonthdatehours', description: 'one per hour' },
36 1 : { unit: 'yearmonthdatehoursminutes', description: 'one per minute' },
37 1 : { unit: 'yearmonthdatehoursminutesseconds', description: 'one per second' },
38 1 : { unit: 'quarter', description: 'quarter of the year' },
39 1 : { unit: 'month', description: 'month of the year' },
40 1 : { unit: 'week', description: 'week of the year' },
41 1 : { unit: 'dayofyear', description: 'day of the year' },
42 1 : { unit: 'monthdate', description: 'month and day of the year' },
43 1 : { unit: 'date', description: 'day of the month' },
44 1 : { unit: 'day', description: 'day of the week' },
45 1 : { unit: 'hours', description: 'hour of the day' },
46 1 : { unit: 'hoursminutes', description: 'time of the day, by minute' },
47 1 : { unit: 'hoursminutesseconds', description: 'time of the day, by second' },
48 1 : { unit: 'minutes', description: 'minute of the hour' },
49 1 : { unit: 'minutesseconds', description: 'minute and second of the hour' },
50 1 : { unit: 'seconds', description: 'second of the minute' },
51 1 : ];
52 1 : export const SORTS = ['ascending', 'descending'] as const;
53 :
54 : /// The keys of a channel that the builder edits. Clearing a field removes these and keeps the others.
55 1 : const OWNED_CHANNEL_KEYS = ['field', 'type', 'aggregate', 'bin', 'timeUnit', 'sort', 'title', 'stack'];
56 :
57 : /// The keys that compose views. The builder edits single views and layers of single views.
58 1 : const COMPOSITION_KEYS = ['layer', 'concat', 'hconcat', 'vconcat', 'repeat', 'spec', 'facet'];
59 : /// The keys of a single view that move into the first layer when a second layer is added
60 1 : const LAYER_KEYS = ['mark', 'encoding'];
61 :
62 1 : function isObject(value: unknown): value is Record<string, unknown> {
63 1 : return value != null && typeof value === 'object' && !Array.isArray(value);
64 1 : }
65 :
66 1 : function isSingleView(spec: ChartSpec): boolean {
67 1 : return COMPOSITION_KEYS.every(key => !(key in spec));
68 1 : }
69 :
70 : /// The layers of a layered spec, null for a single view
71 1 : export function getLayers(spec: ChartSpec): ChartSpec[] | null {
72 1 : return Array.isArray(spec.layer) ? spec.layer as ChartSpec[] : null;
73 1 : }
74 :
75 : /// Can the builder edit the spec? A layer of single views is edited one layer at a time. Other compositions, nested
76 : /// layers and an encoding that the layers share are edited as JSON.
77 1 : export function isBuilderEditable(spec: ChartSpec): boolean {
78 1 : if (isSingleView(spec)) return true;
79 1 : const layers = getLayers(spec);
80 1 : if (layers == null || layers.length === 0 || 'encoding' in spec || 'mark' in spec) return false;
81 1 : if (COMPOSITION_KEYS.some(key => key !== 'layer' && key in spec)) return false;
82 1 : return layers.every(layer => isObject(layer) && isSingleView(layer));
83 1 : }
84 :
85 1 : function isFieldOrDatumDef(def: unknown): def is ChannelDef {
86 1 : return isObject(def) && ('field' in def || 'datum' in def);
87 1 : }
88 :
89 : /// The channel of a layer that inherits the channel of the shared encoding, the rules of the normalizer of Vega-Lite:
90 : /// a field or datum def overrides the keys of the shared def, a condition with a field or datum def does the same in
91 : /// the condition, null removes the channel, any other def replaces it, and a missing def inherits the shared one.
92 1 : function inheritChannel(shared: unknown, own: unknown): unknown {
93 1 : if (own === undefined) return shared;
94 1 : if (own === null || !isObject(shared)) return own;
95 1 : if (isFieldOrDatumDef(own)) return { ...shared, ...own };
96 1 : if (isObject(own) && isFieldOrDatumDef(own.condition)) return { ...own, condition: { ...shared, ...own.condition } };
97 0 : return own;
98 1 : }
99 :
100 : /// Move an encoding that the layers share into the layers, the builder edits the encoding of a layer only. The layers
101 : /// render as before, null if the spec has no shared encoding or if the builder cannot edit the layers anyway.
102 1 : export function inlineSharedEncoding(spec: ChartSpec): ChartSpec | null {
103 1 : const layers = getLayers(spec);
104 1 : const shared = spec.encoding;
105 1 : if (layers == null || layers.length === 0 || !isObject(shared) || 'mark' in spec) return null;
106 1 : if (COMPOSITION_KEYS.some(key => key !== 'layer' && key in spec)) return null;
107 1 : if (!layers.every(layer => isObject(layer) && isSingleView(layer))) return null;
108 1 : const top = { ...spec };
109 1 : delete top.encoding;
110 1 : return {
111 1 : ...top,
112 1 : layer: layers.map(layer => {
113 1 : const own = getEncoding(layer);
114 1 : const encoding: Record<string, unknown> = {};
115 1 : for (const channel of new Set([...Object.keys(shared), ...Object.keys(own)])) {
116 1 : const def = inheritChannel(shared[channel], own[channel]);
117 1 : if (def != null) encoding[channel] = def;
118 1 : }
119 1 : return withEncoding(layer, encoding);
120 1 : }),
121 1 : };
122 1 : }
123 :
124 : /// The number of views that the builder edits, a single view is one layer
125 1 : export function layerCount(spec: ChartSpec): number {
126 1 : return getLayers(spec)?.length ?? 1;
127 1 : }
128 :
129 : /// The view of a layer, the spec itself for a single view
130 1 : export function getLayer(spec: ChartSpec, index: number): ChartSpec {
131 1 : return getLayers(spec)?.[index] ?? spec;
132 1 : }
133 :
134 : /// Replace the view of a layer, the spec itself for a single view
135 1 : export function setLayer(spec: ChartSpec, index: number, view: ChartSpec): ChartSpec {
136 1 : const layers = getLayers(spec);
137 1 : if (layers == null) return view;
138 1 : return { ...spec, layer: layers.map((layer, i) => i === index ? view : layer) };
139 1 : }
140 :
141 : /// Add a layer. A single view moves its mark and encoding into the first layer, the other keys stay on top, e.g. the
142 : /// title, the size, the data and the transforms that the layers share.
143 1 : export function addLayer(spec: ChartSpec, view: ChartSpec): ChartSpec {
144 1 : const layers = getLayers(spec);
145 1 : if (layers != null) return { ...spec, layer: [...layers, view] };
146 1 : const top: ChartSpec = {};
147 1 : const first: ChartSpec = {};
148 1 : for (const [key, value] of Object.entries(spec)) {
149 1 : if (LAYER_KEYS.includes(key)) first[key] = value;
150 1 : else top[key] = value;
151 : }
152 1 : return { ...top, layer: [first, view] };
153 1 : }
154 :
155 : /// The view of a new line layer that shares x with the given layer. A dropped column goes on y and aggregates a measure
156 : /// like the y of the given layer, e.g. a line of the mean revenue over the bars of the summed revenue per region.
157 1 : export function newLayerView(from: ChartSpec, column?: { field: string; type: FieldType }): ChartSpec {
158 1 : const encoding: Record<string, unknown> = {};
159 1 : const x = getChannel(from, 'x');
160 1 : if (x != null) encoding.x = x;
161 1 : if (column != null) {
162 1 : const y = getChannel(from, 'y');
163 1 : const aggregate = isObject(y) && typeof y.aggregate === 'string' && y.aggregate !== 'count' ? y.aggregate : undefined;
164 1 : encoding.y = column.type === 'quantitative' && aggregate != null
165 1 : ? { field: column.field, type: column.type, aggregate }
166 1 : : { field: column.field, type: column.type };
167 1 : }
168 1 : return Object.keys(encoding).length === 0 ? { mark: 'line' } : { mark: 'line', encoding };
169 1 : }
170 :
171 : /// Add a layer that shows a dropped column, see `newLayerView`. The layers that have no color yet get the label of
172 : /// their y as a color datum: each gets a color of the palette, and the legend tells them apart.
173 1 : export function addColumnLayer(spec: ChartSpec, from: number, column: { field: string; type: FieldType }): ChartSpec {
174 1 : let next = addLayer(spec, newLayerView(getLayer(spec, from), column));
175 1 : const fromIndex = getLayers(spec) == null ? 0 : from;
176 1 : for (const index of [fromIndex, layerCount(next) - 1]) {
177 1 : const view = getLayer(next, index);
178 1 : const label = channelSummary(getChannel(view, 'y'));
179 1 : if (label != null && getChannel(view, 'color') == null) {
180 1 : next = setLayer(next, index, patchChannel(view, 'color', { datum: label }));
181 : }
182 1 : }
183 1 : return next;
184 1 : }
185 :
186 : /// Remove a layer. The last layer becomes a single view again unless its keys clash with the keys on top.
187 1 : export function removeLayer(spec: ChartSpec, index: number): ChartSpec {
188 1 : const layers = getLayers(spec);
189 1 : if (layers == null || layers.length <= 1) return spec;
190 1 : const rest = layers.filter((_, i) => i !== index);
191 1 : const top = { ...spec };
192 1 : delete top.layer;
193 1 : if (rest.length === 1 && Object.keys(rest[0]).every(key => !(key in top))) {
194 1 : return { ...top, ...rest[0] };
195 : }
196 1 : return { ...top, layer: rest };
197 1 : }
198 :
199 : /// The type of the mark, a string or the type of a mark object
200 1 : export function getMarkType(spec: ChartSpec): string | null {
201 1 : const mark = spec.mark;
202 1 : if (typeof mark === 'string') return mark;
203 1 : if (isObject(mark) && typeof mark.type === 'string') return mark.type;
204 1 : return null;
205 1 : }
206 :
207 : /// Set the type of the mark. A mark object keeps its other properties.
208 1 : export function setMarkType(spec: ChartSpec, type: string): ChartSpec {
209 1 : const mark = spec.mark;
210 1 : if (isObject(mark)) return { ...spec, mark: { ...mark, type } };
211 1 : return { ...spec, mark: type };
212 1 : }
213 :
214 1 : function getEncoding(spec: ChartSpec): Record<string, unknown> {
215 1 : return isObject(spec.encoding) ? spec.encoding : {};
216 1 : }
217 :
218 : /// The definition of a channel, null if the channel is not encoded
219 1 : export function getChannel(spec: ChartSpec, channel: string): unknown {
220 1 : return getEncoding(spec)[channel] ?? null;
221 1 : }
222 :
223 : /// The channels that the spec encodes
224 1 : export function encodedChannels(spec: ChartSpec): string[] {
225 1 : return Object.keys(getEncoding(spec));
226 1 : }
227 :
228 : /// Is a channel definition something that the builder cannot edit, e.g. a value, a datum, a condition or a list?
229 1 : export function isCustomChannel(def: unknown): boolean {
230 1 : if (def == null) return false;
231 1 : if (!isObject(def)) return true;
232 1 : return 'value' in def || 'datum' in def || 'condition' in def || 'repeat' in def;
233 1 : }
234 :
235 : /// Is a channel a count of the rows, it has no field then
236 1 : export function isCountChannel(def: unknown): boolean {
237 1 : return isObject(def) && def.aggregate === 'count' && def.field == null;
238 1 : }
239 :
240 : /// Set the encoding of the spec, an empty encoding is removed
241 1 : function withEncoding(spec: ChartSpec, encoding: Record<string, unknown>): ChartSpec {
242 1 : const next = { ...spec };
243 1 : if (Object.keys(encoding).length === 0) {
244 1 : delete next.encoding;
245 : } else {
246 1 : next.encoding = encoding;
247 : }
248 1 : return next;
249 1 : }
250 :
251 : /// Set or remove properties of a channel. An undefined value removes the key, a channel without keys is removed.
252 1 : export function patchChannel(spec: ChartSpec, channel: string, patch: Record<string, unknown>): ChartSpec {
253 1 : const encoding = { ...getEncoding(spec) };
254 1 : const current = encoding[channel];
255 1 : const def: ChannelDef = isObject(current) ? { ...current } : {};
256 1 : for (const [key, value] of Object.entries(patch)) {
257 1 : if (value === undefined) {
258 1 : delete def[key];
259 : } else {
260 1 : def[key] = value;
261 : }
262 : }
263 1 : if (Object.keys(def).length === 0) {
264 1 : delete encoding[channel];
265 : } else {
266 1 : encoding[channel] = def;
267 : }
268 1 : return withEncoding(spec, encoding);
269 1 : }
270 :
271 : /// Encode a channel with a field. Properties that do not fit the new type are removed, the others are kept.
272 1 : export function setChannelField(spec: ChartSpec, channel: string, field: string, type: FieldType): ChartSpec {
273 1 : const current = getChannel(spec, channel);
274 1 : const def = isObject(current) && !isCustomChannel(current) ? current : {};
275 1 : const aggregate = def.aggregate === 'count' ? undefined : def.aggregate;
276 1 : return patchChannel(replaceChannel(spec, channel, def), channel, {
277 1 : field,
278 1 : type,
279 1 : aggregate,
280 1 : bin: type === 'quantitative' ? def.bin : undefined,
281 1 : timeUnit: type === 'temporal' ? def.timeUnit : undefined,
282 1 : });
283 1 : }
284 :
285 : /// Encode a channel with the count of the rows
286 1 : export function setChannelCount(spec: ChartSpec, channel: string): ChartSpec {
287 1 : const current = getChannel(spec, channel);
288 1 : const def = isObject(current) && !isCustomChannel(current) ? current : {};
289 1 : return patchChannel(replaceChannel(spec, channel, def), channel, {
290 1 : field: undefined,
291 1 : aggregate: 'count',
292 1 : type: 'quantitative',
293 1 : bin: undefined,
294 1 : timeUnit: undefined,
295 1 : });
296 1 : }
297 :
298 : /// Set the type of the field of a channel, removing the properties that do not fit the type
299 1 : export function setChannelType(spec: ChartSpec, channel: string, type: FieldType): ChartSpec {
300 1 : const def = getChannel(spec, channel);
301 1 : return patchChannel(spec, channel, {
302 1 : type,
303 1 : bin: type === 'quantitative' && isObject(def) ? def.bin : undefined,
304 1 : timeUnit: type === 'temporal' && isObject(def) ? def.timeUnit : undefined,
305 1 : });
306 1 : }
307 :
308 : /// Remove the field of a channel. The keys that the builder does not own stay, e.g. an axis or a scale.
309 1 : export function clearChannelField(spec: ChartSpec, channel: string): ChartSpec {
310 1 : const def = getChannel(spec, channel);
311 1 : if (!isObject(def)) return removeChannel(spec, channel);
312 1 : const patch: Record<string, undefined> = {};
313 1 : for (const key of OWNED_CHANNEL_KEYS) patch[key] = undefined;
314 1 : return patchChannel(spec, channel, patch);
315 1 : }
316 :
317 : /// Remove a channel with all its properties
318 1 : export function removeChannel(spec: ChartSpec, channel: string): ChartSpec {
319 1 : const encoding = { ...getEncoding(spec) };
320 1 : delete encoding[channel];
321 1 : return withEncoding(spec, encoding);
322 1 : }
323 :
324 1 : function replaceChannel(spec: ChartSpec, channel: string, def: ChannelDef): ChartSpec {
325 1 : return withEncoding(spec, { ...getEncoding(spec), [channel]: def });
326 1 : }
327 :
328 : /// Set or remove a top-level property, e.g. the title or the width
329 1 : export function setTopLevel(spec: ChartSpec, key: string, value: unknown): ChartSpec {
330 1 : const next = { ...spec };
331 1 : if (value === undefined) {
332 1 : delete next[key];
333 : } else {
334 1 : next[key] = value;
335 : }
336 1 : return next;
337 1 : }
338 :
339 : /// The fields that the spec encodes, also in nested channels of a list
340 1 : export function encodedFields(spec: ChartSpec): string[] {
341 1 : const fields = new Set<string>();
342 1 : const visit = (def: unknown) => {
343 1 : if (Array.isArray(def)) {
344 1 : def.forEach(visit);
345 1 : } else if (isObject(def) && typeof def.field === 'string') {
346 1 : fields.add(def.field);
347 : }
348 1 : };
349 1 : for (const view of [spec, ...(getLayers(spec) ?? [])]) {
350 1 : for (const def of Object.values(getEncoding(view))) visit(def);
351 : }
352 1 : return [...fields];
353 1 : }
354 :
355 : /// The unit and the step of a time unit, a string or an object like `{ unit: 'minutes', step: 15 }`
356 1 : export function getTimeUnit(timeUnit: unknown): { unit: string | null; step: number | null } {
357 1 : if (typeof timeUnit === 'string') return { unit: timeUnit, step: null };
358 1 : if (!isObject(timeUnit)) return { unit: null, step: null };
359 1 : return {
360 1 : unit: typeof timeUnit.unit === 'string' ? timeUnit.unit : null,
361 1 : step: typeof timeUnit.step === 'number' ? timeUnit.step : null,
362 1 : };
363 1 : }
364 :
365 : /// The time unit with a new unit and step. Without a unit there is no time unit. A step of 1 or less is no step,
366 : /// and a unit without a step or other parameters (e.g. `utc`) is a plain string.
367 1 : export function withTimeUnit(timeUnit: unknown, unit: string | null, step: number | null): unknown {
368 1 : if (unit == null) return undefined;
369 1 : const params: Record<string, unknown> = isObject(timeUnit) ? { ...timeUnit } : {};
370 1 : delete params.unit;
371 1 : delete params.step;
372 1 : if (step != null && step > 1) params.step = step;
373 1 : return Object.keys(params).length === 0 ? unit : { unit, ...params };
374 1 : }
375 :
376 : /// A short label of a channel, e.g. `sum(revenue)`, `month(created)` or `15 minutes(created)`
377 1 : export function channelSummary(def: unknown): string | null {
378 1 : if (!isObject(def)) return null;
379 1 : if (isCountChannel(def)) return 'count()';
380 1 : if (typeof def.field !== 'string') return null;
381 1 : const timeUnit = getTimeUnit(def.timeUnit);
382 1 : const wrap = typeof def.aggregate === 'string' ? def.aggregate
383 1 : : timeUnit.unit != null ? (timeUnit.step != null && timeUnit.step > 1 ? `${timeUnit.step} ${timeUnit.unit}` : timeUnit.unit)
384 1 : : def.bin === true || isObject(def.bin) ? 'bin'
385 1 : : null;
386 1 : return wrap == null ? def.field : `${wrap}(${def.field})`;
387 1 : }
|