Skip to main content
Module

x/vega_lite/src/stack.ts

A concise grammar of interactive graphics, built on Vega.
Latest
File
import {array, isBoolean} from 'vega-util';import {SUM_OPS} from './aggregate';import {getSecondaryRangeChannel, NonPositionChannel, NONPOSITION_CHANNELS} from './channel';import { channelDefType, FieldName, getFieldDef, isFieldDef, isFieldOrDatumDef, PositionDatumDef, PositionFieldDef, TypedFieldDef, vgField} from './channeldef';import {channelHasField, Encoding, isAggregate} from './encoding';import * as log from './log';import { ARC, AREA, BAR, CIRCLE, isMarkDef, isPathMark, LINE, Mark, MarkDef, POINT, RULE, SQUARE, TEXT, TICK} from './mark';import {ScaleType} from './scale';import {contains} from './util';
const STACK_OFFSET_INDEX = { zero: 1, center: 1, normalize: 1} as const;
export type StackOffset = keyof typeof STACK_OFFSET_INDEX;
export function isStackOffset(s: string): s is StackOffset { return s in STACK_OFFSET_INDEX;}
export interface StackProperties { /** Dimension axis of the stack. */ groupbyChannel?: 'x' | 'y' | 'theta' | 'radius';
/** Field for groupbyChannel. */ groupbyField?: FieldName;
/** Measure axis of the stack. */ fieldChannel: 'x' | 'y' | 'theta' | 'radius';
/** Stack-by fields e.g., color, detail */ stackBy: { fieldDef: TypedFieldDef<string>; channel: NonPositionChannel; }[];
/** * See `stack` property of Position Field Def. */ offset: StackOffset;
/** * Whether this stack will produce impute transform */ impute: boolean;}
export const STACKABLE_MARKS = new Set<Mark>([ARC, BAR, AREA, RULE, POINT, CIRCLE, SQUARE, LINE, TEXT, TICK]);export const STACK_BY_DEFAULT_MARKS = new Set<Mark>([BAR, AREA, ARC]);
function potentialStackedChannel( encoding: Encoding<string>, x: 'x' | 'theta'): 'x' | 'y' | 'theta' | 'radius' | undefined { const y = x === 'x' ? 'y' : 'radius';
const xDef = encoding[x]; const yDef = encoding[y];
if (isFieldDef(xDef) && isFieldDef(yDef)) { if (channelDefType(xDef) === 'quantitative' && channelDefType(yDef) === 'quantitative') { if (xDef.stack) { return x; } else if (yDef.stack) { return y; } const xAggregate = isFieldDef(xDef) && !!xDef.aggregate; const yAggregate = isFieldDef(yDef) && !!yDef.aggregate; // if there is no explicit stacking, only apply stack if there is only one aggregate for x or y if (xAggregate !== yAggregate) { return xAggregate ? x : y; } else { const xScale = xDef.scale?.type; const yScale = yDef.scale?.type;
if (xScale && xScale !== 'linear') { return y; } else if (yScale && yScale !== 'linear') { return x; } } } else if (channelDefType(xDef) === 'quantitative') { return x; } else if (channelDefType(yDef) === 'quantitative') { return y; } } else if (channelDefType(xDef) === 'quantitative') { return x; } else if (channelDefType(yDef) === 'quantitative') { return y; } return undefined;}
function getDimensionChannel(channel: 'x' | 'y' | 'theta' | 'radius') { switch (channel) { case 'x': return 'y'; case 'y': return 'x'; case 'theta': return 'radius'; case 'radius': return 'theta'; }}
// Note: CompassQL uses this method and only pass in required properties of each argument object.// If required properties change, make sure to update CompassQL.export function stack( m: Mark | MarkDef, encoding: Encoding<string>, opt: { disallowNonLinearStack?: boolean; // This option is for CompassQL } = {}): StackProperties { const mark = isMarkDef(m) ? m.type : m; // Should have stackable mark if (!STACKABLE_MARKS.has(mark)) { return null; }
// Run potential stacked twice, one for Cartesian and another for Polar, // so text marks can be stacked in any of the coordinates.
// Note: The logic here is not perfectly correct. If we want to support stacked dot plots where each dot is a pie chart with label, we have to change the stack logic here to separate Cartesian stacking for polar stacking. // However, since we probably never want to do that, let's just note the limitation here. const fieldChannel = potentialStackedChannel(encoding, 'x') || potentialStackedChannel(encoding, 'theta');
if (!fieldChannel) { return null; }
const stackedFieldDef = encoding[fieldChannel] as PositionFieldDef<string> | PositionDatumDef<string>; const stackedField = isFieldDef(stackedFieldDef) ? vgField(stackedFieldDef, {}) : undefined;
let dimensionChannel: 'x' | 'y' | 'theta' | 'radius' = getDimensionChannel(fieldChannel); let dimensionDef = encoding[dimensionChannel];
let dimensionField = isFieldDef(dimensionDef) ? vgField(dimensionDef, {}) : undefined;
// avoid grouping by the stacked field if (dimensionField === stackedField) { dimensionField = undefined; dimensionDef = undefined; dimensionChannel = undefined; }
// Should have grouping level of detail that is different from the dimension field const stackBy = NONPOSITION_CHANNELS.reduce((sc, channel) => { // Ignore tooltip in stackBy (https://github.com/vega/vega-lite/issues/4001) if (channel !== 'tooltip' && channelHasField(encoding, channel)) { const channelDef = encoding[channel]; for (const cDef of array(channelDef)) { const fieldDef = getFieldDef(cDef); if (fieldDef.aggregate) { continue; }
// Check whether the channel's field is identical to x/y's field or if the channel is a repeat const f = vgField(fieldDef, {}); if ( // if fielddef is a repeat, just include it in the stack by !f || // otherwise, the field must be different from x and y fields. f !== dimensionField ) { sc.push({channel, fieldDef}); } } } return sc; }, []);
// Automatically determine offset let offset: StackOffset; if (stackedFieldDef.stack !== undefined) { if (isBoolean(stackedFieldDef.stack)) { offset = stackedFieldDef.stack ? 'zero' : null; } else { offset = stackedFieldDef.stack; } } else if (stackBy.length > 0 && STACK_BY_DEFAULT_MARKS.has(mark)) { // Bar and Area with sum ops are automatically stacked by default offset = 'zero'; }
if (!offset || !isStackOffset(offset)) { return null; }
if (isAggregate(encoding) && stackBy.length === 0) { return null; }
// warn when stacking non-linear if (stackedFieldDef.scale && stackedFieldDef.scale.type && stackedFieldDef.scale.type !== ScaleType.LINEAR) { if (opt.disallowNonLinearStack) { return null; } else { log.warn(log.message.cannotStackNonLinearScale(stackedFieldDef.scale.type)); } }
// Check if it is a ranged mark if (isFieldOrDatumDef(encoding[getSecondaryRangeChannel(fieldChannel)])) { if (stackedFieldDef.stack !== undefined) { log.warn(log.message.cannotStackRangedMark(fieldChannel)); } return null; }
// Warn if stacking non-summative aggregate if (isFieldDef(stackedFieldDef) && stackedFieldDef.aggregate && !contains(SUM_OPS, stackedFieldDef.aggregate)) { log.warn(log.message.stackNonSummativeAggregate(stackedFieldDef.aggregate)); }
return { groupbyChannel: dimensionDef ? dimensionChannel : undefined, groupbyField: dimensionField, fieldChannel, impute: stackedFieldDef.impute === null ? false : isPathMark(mark), stackBy, offset };}