import type {AggregateOp} from 'vega'; import {array, isArray} from 'vega-util'; import {isArgmaxDef, isArgminDef} from './aggregate.js'; import {isBinned, isBinning} from './bin.js'; import { ANGLE, Channel, CHANNELS, COLOR, DESCRIPTION, DETAIL, FILL, FILLOPACITY, getMainChannelFromOffsetChannel, getOffsetScaleChannel, HREF, isChannel, isNonPositionScaleChannel, isSecondaryRangeChannel, isXorY, isXorYOffset, KEY, LATITUDE, LATITUDE2, LONGITUDE, LONGITUDE2, OPACITY, ORDER, RADIUS, RADIUS2, SHAPE, SIZE, STROKE, STROKEDASH, STROKEOPACITY, STROKEWIDTH, supportMark, TEXT, THETA, THETA2, TIME, TOOLTIP, UNIT_CHANNELS, URL, X, X2, XOFFSET, Y, Y2, YOFFSET, } from './channel.js'; import { binRequiresRange, ChannelDef, ColorDef, Field, FieldDef, FieldDefWithoutScale, getFieldDef, getGuide, hasConditionalFieldDef, hasConditionalFieldOrDatumDef, initChannelDef, initFieldDef, isConditionalDef, isDatumDef, isFieldDef, isOrderOnlyDef, isTypedFieldDef, isValueDef, LatLongDef, NumericArrayMarkPropDef, NumericMarkPropDef, OffsetDef, OrderFieldDef, OrderOnlyDef, OrderValueDef, PolarDef, Position2Def, PositionDef, SecondaryFieldDef, ShapeDef, StringFieldDef, StringFieldDefWithCondition, StringValueDefWithCondition, TextDef, TimeDef, title, TypedFieldDef, vgField, } from './channeldef.js'; import {Config} from './config.js'; import * as log from './log/index.js'; import {Mark} from './mark.js'; import {EncodingFacetMapping} from './spec/facet.js'; import {AggregatedFieldDef, BinTransform, TimeUnitTransform} from './transform.js'; import {isContinuous, isDiscrete, QUANTITATIVE, TEMPORAL} from './type.js'; import {keys, some} from './util.js'; import {isSignalRef} from './vega.schema.js'; import {isBinnedTimeUnit} from './timeunit.js'; export interface Encoding { /** * X coordinates of the marks, or width of horizontal `"bar"` and `"area"` without specified `x2` or `width`. * * The `value` of this channel can be a number or a string `"width"` for the width of the plot. */ x?: PositionDef; /** * Y coordinates of the marks, or height of vertical `"bar"` and `"area"` without specified `y2` or `height`. * * The `value` of this channel can be a number or a string `"height"` for the height of the plot. */ y?: PositionDef; /** * Offset of x-position of the marks */ xOffset?: OffsetDef; /** * Offset of y-position of the marks */ yOffset?: OffsetDef; /** * X2 coordinates for ranged `"area"`, `"bar"`, `"rect"`, and `"rule"`. * * The `value` of this channel can be a number or a string `"width"` for the width of the plot. */ // TODO: Ham need to add default behavior // `x2` cannot have type as it should have the same type as `x` x2?: Position2Def; /** * Y2 coordinates for ranged `"area"`, `"bar"`, `"rect"`, and `"rule"`. * * The `value` of this channel can be a number or a string `"height"` for the height of the plot. */ // TODO: Ham need to add default behavior // `y2` cannot have type as it should have the same type as `y` y2?: Position2Def; /** * Longitude position of geographically projected marks. */ longitude?: LatLongDef; /** * Latitude position of geographically projected marks. */ latitude?: LatLongDef; /** * Longitude-2 position for geographically projected ranged `"area"`, `"bar"`, `"rect"`, and `"rule"`. */ // `longitude2` cannot have type as it should have the same type as `longitude` longitude2?: Position2Def; /** * Latitude-2 position for geographically projected ranged `"area"`, `"bar"`, `"rect"`, and `"rule"`. */ // `latitude2` cannot have type as it should have the same type as `latitude` latitude2?: Position2Def; /** * - For arc marks, the arc length in radians if theta2 is not specified, otherwise the start arc angle. (A value of 0 indicates up or “north”, increasing values proceed clockwise.) * * - For text marks, polar coordinate angle in radians. */ theta?: PolarDef; /** * The end angle of arc marks in radians. A value of 0 indicates up or “north”, increasing values proceed clockwise. */ theta2?: Position2Def; /** * The outer radius in pixels of arc marks. */ radius?: PolarDef; /** * The inner radius in pixels of arc marks. */ radius2?: Position2Def; time?: TimeDef; /** * Color of the marks – either fill or stroke color based on the `filled` property of mark definition. * By default, `color` represents fill color for `"area"`, `"bar"`, `"tick"`, * `"text"`, `"trail"`, `"circle"`, and `"square"` / stroke color for `"line"` and `"point"`. * * __Default value:__ If undefined, the default color depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `color` property. * * _Note:_ * 1) For fine-grained control over both fill and stroke colors of the marks, please use the `fill` and `stroke` channels. The `fill` or `stroke` encodings have higher precedence than `color`, thus may override the `color` encoding if conflicting encodings are specified. * 2) See the scale documentation for more information about customizing [color scheme](https://vega.github.io/vega-lite/docs/scale.html#scheme). */ color?: ColorDef; /** * Fill color of the marks. * __Default value:__ If undefined, the default color depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `color` property. * * _Note:_ The `fill` encoding has higher precedence than `color`, thus may override the `color` encoding if conflicting encodings are specified. */ fill?: ColorDef; /** * Stroke color of the marks. * __Default value:__ If undefined, the default color depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `color` property. * * _Note:_ The `stroke` encoding has higher precedence than `color`, thus may override the `color` encoding if conflicting encodings are specified. */ stroke?: ColorDef; /** * Opacity of the marks. * * __Default value:__ If undefined, the default opacity depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `opacity` property. */ opacity?: NumericMarkPropDef; /** * Fill opacity of the marks. * * __Default value:__ If undefined, the default opacity depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `fillOpacity` property. */ fillOpacity?: NumericMarkPropDef; /** * Stroke opacity of the marks. * * __Default value:__ If undefined, the default opacity depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `strokeOpacity` property. */ strokeOpacity?: NumericMarkPropDef; /** * Stroke width of the marks. * * __Default value:__ If undefined, the default stroke width depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#mark-config)'s `strokeWidth` property. */ strokeWidth?: NumericMarkPropDef; /** * Stroke dash of the marks. * * __Default value:__ `[1,0]` (No dash). */ strokeDash?: NumericArrayMarkPropDef; /** * Size of the mark. * - For `"point"`, `"square"` and `"circle"`, – the symbol size, or pixel area of the mark. * - For `"bar"` and `"tick"` – the bar and tick's size. * - For `"text"` – the text's font size. * - Size is unsupported for `"line"`, `"area"`, and `"rect"`. (Use `"trail"` instead of line with varying size) */ size?: NumericMarkPropDef; /** * Rotation angle of point and text marks. */ angle?: NumericMarkPropDef; /** * Shape of the mark. * * 1. For `point` marks the supported values include: * - plotting shapes: `"circle"`, `"square"`, `"cross"`, `"diamond"`, `"triangle-up"`, `"triangle-down"`, `"triangle-right"`, or `"triangle-left"`. * - the line symbol `"stroke"` * - centered directional shapes `"arrow"`, `"wedge"`, or `"triangle"` * - a custom [SVG path string](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths) (For correct sizing, custom shape paths should be defined within a square bounding box with coordinates ranging from -1 to 1 along both the x and y dimensions.) * * 2. For `geoshape` marks it should be a field definition of the geojson data * * __Default value:__ If undefined, the default shape depends on [mark config](https://vega.github.io/vega-lite/docs/config.html#point-config)'s `shape` property. (`"circle"` if unset.) */ shape?: ShapeDef; /** * Additional levels of detail for grouping data in aggregate views and * in line, trail, and area marks without mapping data to a specific visual channel. */ detail?: FieldDefWithoutScale | FieldDefWithoutScale[]; /** * A data field to use as a unique key for data binding. When a visualization’s data is updated, the key value will be used to match data elements to existing mark instances. Use a key channel to enable object constancy for transitions over dynamic data. */ key?: FieldDefWithoutScale; /** * Text of the `text` mark. */ text?: TextDef; /** * The tooltip text to show upon mouse hover. Specifying `tooltip` encoding overrides [the `tooltip` property in the mark definition](https://vega.github.io/vega-lite/docs/mark.html#mark-def). * * See the [`tooltip`](https://vega.github.io/vega-lite/docs/tooltip.html) documentation for a detailed discussion about tooltip in Vega-Lite. */ tooltip?: StringFieldDefWithCondition | StringValueDefWithCondition | StringFieldDef[] | null; /** * A URL to load upon mouse click. */ href?: StringFieldDefWithCondition | StringValueDefWithCondition; /** * The URL of an image mark. */ url?: StringFieldDefWithCondition | StringValueDefWithCondition; /** * A text description of this mark for ARIA accessibility (SVG output only). For SVG output the `"aria-label"` attribute will be set to this description. */ description?: StringFieldDefWithCondition | StringValueDefWithCondition; /** * Order of the marks. * - For stacked marks, this `order` channel encodes [stack order](https://vega.github.io/vega-lite/docs/stack.html#order). * - For line and trail marks, this `order` channel encodes order of data points in the lines. This can be useful for creating [a connected scatterplot](https://vega.github.io/vega-lite/examples/connected_scatterplot.html). Setting `order` to `{"value": null}` makes the line marks use the original order in the data sources. * - Otherwise, this `order` channel encodes layer order of the marks. * * __Note__: In aggregate plots, `order` field should be `aggregate`d to avoid creating additional aggregation grouping. */ order?: OrderFieldDef | OrderFieldDef[] | OrderValueDef | OrderOnlyDef; } export interface EncodingWithFacet extends Encoding, EncodingFacetMapping {} export function channelHasField( encoding: EncodingWithFacet, channel: keyof EncodingWithFacet, ): boolean { const channelDef = encoding?.[channel]; if (channelDef) { if (isArray(channelDef)) { return some(channelDef, (fieldDef) => !!fieldDef.field); } else { return isFieldDef(channelDef) || hasConditionalFieldDef(channelDef); } } return false; } export function channelHasFieldOrDatum( encoding: EncodingWithFacet, channel: keyof EncodingWithFacet, ): boolean { const channelDef = encoding?.[channel]; if (channelDef) { if (isArray(channelDef)) { return some(channelDef, (fieldDef) => !!fieldDef.field); } else { return isFieldDef(channelDef) || isDatumDef(channelDef) || hasConditionalFieldOrDatumDef(channelDef); } } return false; } export function channelHasNestedOffsetScale( encoding: EncodingWithFacet, channel: keyof EncodingWithFacet, ): boolean { if (isXorY(channel)) { const fieldDef = encoding[channel]; if ( (isFieldDef(fieldDef) || isDatumDef(fieldDef)) && (isDiscrete(fieldDef.type) || (isFieldDef(fieldDef) && fieldDef.timeUnit)) ) { const offsetChannel = getOffsetScaleChannel(channel); return channelHasFieldOrDatum(encoding, offsetChannel); } } return false; } export function isAggregate(encoding: EncodingWithFacet) { return some(CHANNELS, (channel) => { if (channelHasField(encoding, channel)) { const channelDef = encoding[channel]; if (isArray(channelDef)) { return some(channelDef, (fieldDef) => !!fieldDef.aggregate); } else { const fieldDef = getFieldDef(channelDef); return fieldDef && !!fieldDef.aggregate; } } return false; }); } export function extractTransformsFromEncoding(oldEncoding: Encoding, config: Config) { const groupby: string[] = []; const bins: BinTransform[] = []; const timeUnits: TimeUnitTransform[] = []; const aggregate: AggregatedFieldDef[] = []; const encoding: Encoding = {}; forEach(oldEncoding, (channelDef, channel) => { // Extract potential embedded transformations along with remaining properties if (isFieldDef(channelDef)) { const {field, aggregate: aggOp, bin, timeUnit, ...remaining} = channelDef; if (aggOp || timeUnit || bin) { const guide = getGuide(channelDef); const isTitleDefined = guide?.title; let newField = vgField(channelDef, {forAs: true}); const newFieldDef: FieldDef = { // Only add title if it doesn't exist ...(isTitleDefined ? [] : {title: title(channelDef, config, {allowDisabling: true})}), ...remaining, // Always overwrite field field: newField, }; if (aggOp) { let op: AggregateOp; if (isArgmaxDef(aggOp)) { op = 'argmax'; newField = vgField({op: 'argmax', field: aggOp.argmax}, {forAs: true}); newFieldDef.field = `${newField}.${field}`; } else if (isArgminDef(aggOp)) { op = 'argmin'; newField = vgField({op: 'argmin', field: aggOp.argmin}, {forAs: true}); newFieldDef.field = `${newField}.${field}`; } else if (aggOp !== 'boxplot' && aggOp !== 'errorbar' && aggOp !== 'errorband') { op = aggOp; } if (op) { const aggregateEntry: AggregatedFieldDef = { op, as: newField, }; if (field) { aggregateEntry.field = field; } aggregate.push(aggregateEntry); } } else { groupby.push(newField); if (isTypedFieldDef(channelDef) && isBinning(bin)) { bins.push({bin, field, as: newField}); // Add additional groupbys for range and end of bins groupby.push(vgField(channelDef, {binSuffix: 'end'})); if (binRequiresRange(channelDef, channel)) { groupby.push(vgField(channelDef, {binSuffix: 'range'})); } // Create accompanying 'x2' or 'y2' field if channel is 'x' or 'y' respectively if (isXorY(channel)) { const secondaryChannel: SecondaryFieldDef = { field: `${newField}_end`, }; encoding[`${channel}2`] = secondaryChannel; } newFieldDef.bin = 'binned'; if (!isSecondaryRangeChannel(channel)) { (newFieldDef as any)['type'] = QUANTITATIVE; } } else if (timeUnit && !isBinnedTimeUnit(timeUnit)) { timeUnits.push({ timeUnit, field, as: newField, }); // define the format type for later compilation const formatType = isTypedFieldDef(channelDef) && channelDef.type !== TEMPORAL && 'time'; if (formatType) { if (channel === TEXT || channel === TOOLTIP) { (newFieldDef as any)['formatType'] = formatType; } else if (isNonPositionScaleChannel(channel)) { (newFieldDef as any)['legend'] = { formatType, ...(newFieldDef as any)['legend'], }; } else if (isXorY(channel)) { (newFieldDef as any)['axis'] = { formatType, ...(newFieldDef as any)['axis'], }; } } } } // now the field should refer to post-transformed field instead (encoding as any)[channel as any] = newFieldDef; } else { groupby.push(field); (encoding as any)[channel as any] = oldEncoding[channel]; } } else { // For value def / signal ref / datum def, just copy (encoding as any)[channel as any] = oldEncoding[channel]; } }); return { bins, timeUnits, aggregate, groupby, encoding, }; } export function markChannelCompatible(encoding: Encoding, channel: Channel, mark: Mark) { const markSupported = supportMark(channel, mark); if (!markSupported) { return false; } else if (markSupported === 'binned') { const primaryFieldDef = encoding[channel === X2 ? X : Y]; // circle, point, square and tick only support x2/y2 when their corresponding x/y fieldDef // has "binned" data and thus need x2/y2 to specify the bin-end field. if (isFieldDef(primaryFieldDef) && isFieldDef(encoding[channel]) && isBinned(primaryFieldDef.bin)) { return true; } else { return false; } } return true; } export function initEncoding( encoding: Encoding, mark: Mark, filled: boolean, config: Config, ): Encoding { const normalizedEncoding: Encoding = {}; for (const key of keys(encoding)) { if (!isChannel(key)) { // Drop invalid channel log.warn(log.message.invalidEncodingChannel(key)); } } for (let channel of UNIT_CHANNELS) { if (!encoding[channel]) { continue; } const channelDef = encoding[channel]; if (isXorYOffset(channel)) { const mainChannel = getMainChannelFromOffsetChannel(channel); const positionDef = normalizedEncoding[mainChannel]; if (isFieldDef(positionDef)) { if (isContinuous(positionDef.type)) { if (isFieldDef(channelDef) && !positionDef.timeUnit) { // TODO: nesting continuous field instead continuous field should // behave like offsetting the data in data domain log.warn(log.message.offsetNestedInsideContinuousPositionScaleDropped(mainChannel)); continue; } } } } if (channel === 'angle' && mark === 'arc' && !encoding.theta) { log.warn(log.message.REPLACE_ANGLE_WITH_THETA); channel = THETA; } if (!markChannelCompatible(encoding, channel, mark)) { // Drop unsupported channel log.warn(log.message.incompatibleChannel(channel, mark)); continue; } // Drop line's size if the field is aggregated. if (channel === SIZE && mark === 'line') { const fieldDef = getFieldDef(encoding[channel]); if (fieldDef?.aggregate) { log.warn(log.message.LINE_WITH_VARYING_SIZE); continue; } } // Drop color if either fill or stroke is specified if (channel === COLOR && (filled ? 'fill' in encoding : 'stroke' in encoding)) { log.warn(log.message.droppingColor('encoding', {fill: 'fill' in encoding, stroke: 'stroke' in encoding})); continue; } if ( channel === DETAIL || (channel === ORDER && !isArray(channelDef) && !isValueDef(channelDef)) || (channel === TOOLTIP && isArray(channelDef)) ) { if (channelDef) { if (channel === ORDER) { const def = encoding[channel]; if (isOrderOnlyDef(def)) { normalizedEncoding[channel] = def; continue; } } // Array of fieldDefs for detail channel (or production rule) (normalizedEncoding[channel] as any) = array(channelDef).reduce( (defs: FieldDef[], fieldDef: FieldDef) => { if (!isFieldDef(fieldDef)) { log.warn(log.message.emptyFieldDef(fieldDef, channel)); } else { defs.push(initFieldDef(fieldDef, channel)); } return defs; }, [], ); } } else { if (channel === TOOLTIP && channelDef === null) { // Preserve null so we can use it to disable tooltip normalizedEncoding[channel] = null; } else if ( !isFieldDef(channelDef) && !isDatumDef(channelDef) && !isValueDef(channelDef) && !isConditionalDef(channelDef) && !isSignalRef(channelDef) ) { log.warn(log.message.emptyFieldDef(channelDef, channel)); continue; } (normalizedEncoding as any)[channel as any] = initChannelDef(channelDef as ChannelDef, channel, config); } } return normalizedEncoding; } /** * For composite marks, we have to call initChannelDef during init so we can infer types earlier. */ export function normalizeEncoding(encoding: Encoding, config: Config): Encoding { const normalizedEncoding: Encoding = {}; for (const channel of keys(encoding)) { const newChannelDef = initChannelDef(encoding[channel], channel, config, {compositeMark: true}); (normalizedEncoding as any)[channel as any] = newChannelDef; } return normalizedEncoding; } export function fieldDefs(encoding: EncodingWithFacet): FieldDef[] { const arr: FieldDef[] = []; for (const channel of keys(encoding)) { if (channelHasField(encoding, channel)) { const channelDef = encoding[channel]; const channelDefArray = array(channelDef); for (const def of channelDefArray) { if (isFieldDef(def)) { arr.push(def); } else if (hasConditionalFieldDef(def)) { arr.push(def.condition); } } } } return arr; } export function forEach>( mapping: U, f: (cd: ChannelDef, c: keyof U) => void, thisArg?: any, ) { if (!mapping) { return; } for (const channel of keys(mapping)) { const el = mapping[channel]; if (isArray(el)) { for (const channelDef of el as unknown[]) { f.call(thisArg, channelDef, channel); } } else { f.call(thisArg, el, channel); } } } export function reduce>( mapping: U, f: (acc: any, fd: TypedFieldDef, c: keyof U) => U, init: T, thisArg?: any, ) { if (!mapping) { return init; } return keys(mapping).reduce((r, channel) => { const map = mapping[channel]; if (isArray(map)) { return map.reduce((r1: T, channelDef: ChannelDef) => { return f.call(thisArg, r1, channelDef, channel); }, r); } else { return f.call(thisArg, r, map, channel); } }, init); } /** * Returns list of path grouping fields for the given encoding */ export function pathGroupingFields(mark: Mark, encoding: Encoding): string[] { return keys(encoding).reduce((details, channel) => { switch (channel) { // x, y, x2, y2, lat, long, lat1, long2, order, tooltip, href, aria label, cursor should not cause lines to group case X: case Y: case HREF: case DESCRIPTION: case URL: case X2: case Y2: case XOFFSET: case YOFFSET: case THETA: case THETA2: case RADIUS: case RADIUS2: case TIME: // falls through case LATITUDE: case LONGITUDE: case LATITUDE2: case LONGITUDE2: // TODO: case 'cursor': // text, shape, shouldn't be a part of line/trail/area [falls through] case TEXT: case SHAPE: case ANGLE: // falls through // tooltip fields should not be added to group by [falls through] case TOOLTIP: return details; case ORDER: // order should not group line / trail if (mark === 'line' || mark === 'trail') { return details; } // but order should group area for stacking (falls through) case DETAIL: case KEY: { const channelDef = encoding[channel]; if (isArray(channelDef) || isFieldDef(channelDef)) { for (const fieldDef of array(channelDef)) { if (!fieldDef.aggregate) { details.push(vgField(fieldDef, {})); } } } return details; } case SIZE: if (mark === 'trail') { // For trail, size should not group trail lines. return details; } // For line, size should group lines. // falls through case COLOR: case FILL: case STROKE: case OPACITY: case FILLOPACITY: case STROKEOPACITY: case STROKEDASH: case STROKEWIDTH: { // TODO strokeDashOffset: // falls through const fieldDef = getFieldDef(encoding[channel]); if (fieldDef && !fieldDef.aggregate) { details.push(vgField(fieldDef, {})); } return details; } } }, []); }