LCOV - code coverage report
Current view: top level - dashql-app/src/session/chart_builder - chart_spec_paths.ts Coverage Total Hit
Test: DashQL 915828b Lines: 99.6 % 277 276
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1