vega-lite
Version:
Vega-Lite is a concise high-level language for interactive visualization.
329 lines (283 loc) • 11.9 kB
text/typescript
import type {AxisEncode as VgAxisEncode, AxisOrient, SignalRef} from 'vega';
import {Axis, AXIS_PARTS, isAxisProperty, isConditionalAxisValue} from '../../axis.js';
import {PositionScaleChannel, POSITION_SCALE_CHANNELS} from '../../channel.js';
import {getFieldOrDatumDef, PositionDatumDef, PositionFieldDef} from '../../channeldef.js';
import {getFirstDefined, isEmpty, keys, normalizeAngle} from '../../util.js';
import {isSignalRef} from '../../vega.schema.js';
import {mergeTitleComponent} from '../common.js';
import {guideEncodeEntry} from '../guide.js';
import {LayerModel} from '../layer.js';
import {parseGuideResolve} from '../resolve.js';
import {defaultTieBreaker, Explicit, mergeValuesWithExplicit} from '../split.js';
import {UnitModel} from '../unit.js';
import {AxisComponent, AxisComponentIndex, AxisComponentProps, AXIS_COMPONENT_PROPERTIES} from './component.js';
import {getAxisConfig, getAxisConfigs} from './config.js';
import * as encode from './encode.js';
import {AxisRuleParams, axisRules, defaultOrient, getFieldDefTitle, getLabelAngle} from './properties.js';
import {guideFormat, guideFormatType} from '../format.js';
export function parseUnitAxes(model: UnitModel): AxisComponentIndex {
return POSITION_SCALE_CHANNELS.reduce((axis, channel) => {
if (model.component.scales[channel]) {
axis[channel] = [parseAxis(channel, model)];
}
return axis;
}, {} as AxisComponentIndex);
}
const OPPOSITE_ORIENT: Record<AxisOrient, AxisOrient> = {
bottom: 'top',
top: 'bottom',
left: 'right',
right: 'left',
};
export function parseLayerAxes(model: LayerModel) {
const {axes, resolve} = model.component;
const axisCount: Record<AxisOrient, number> = {top: 0, bottom: 0, right: 0, left: 0};
for (const child of model.children) {
child.parseAxesAndHeaders();
for (const channel of keys(child.component.axes)) {
resolve.axis[channel] = parseGuideResolve(model.component.resolve, channel);
if (resolve.axis[channel] === 'shared') {
// If the resolve says shared (and has not been overridden)
// We will try to merge and see if there is a conflict
axes[channel] = mergeAxisComponents(axes[channel], child.component.axes[channel]);
if (!axes[channel]) {
// If merge returns nothing, there is a conflict so we cannot make the axis shared.
// Thus, mark axis as independent and remove the axis component.
resolve.axis[channel] = 'independent';
delete axes[channel];
}
}
}
}
// Move axes to layer's axis component and merge shared axes
for (const channel of POSITION_SCALE_CHANNELS) {
for (const child of model.children) {
if (!child.component.axes[channel]) {
// skip if the child does not have a particular axis
continue;
}
if (resolve.axis[channel] === 'independent') {
// If axes are independent, concat the axisComponent array.
axes[channel] = (axes[channel] ?? []).concat(child.component.axes[channel]);
// Automatically adjust orient
for (const axisComponent of child.component.axes[channel]) {
const {value: orient, explicit} = axisComponent.getWithExplicit('orient');
if (isSignalRef(orient)) {
continue;
}
if (axisCount[orient] > 0 && !explicit) {
// Change axis orient if the number do not match
const oppositeOrient = OPPOSITE_ORIENT[orient];
if (axisCount[orient] > axisCount[oppositeOrient]) {
axisComponent.set('orient', oppositeOrient, false);
}
}
axisCount[orient]++;
// TODO(https://github.com/vega/vega-lite/issues/2634): automatically add extra offset?
}
}
// After merging, make sure to remove axes from child
delete child.component.axes[channel];
}
// Show gridlines for first axis only for dual-axis chart
if (resolve.axis[channel] === 'independent' && axes[channel] && axes[channel].length > 1) {
for (const [index, axisCmpt] of (axes[channel] || []).entries()) {
if (index > 0 && !!axisCmpt.get('grid') && !axisCmpt.explicit.grid) {
axisCmpt.implicit.grid = false;
}
}
}
}
}
function mergeAxisComponents(
mergedAxisCmpts: AxisComponent[],
childAxisCmpts: readonly AxisComponent[],
): AxisComponent[] {
if (mergedAxisCmpts) {
// FIXME: this is a bit wrong once we support multiple axes
if (mergedAxisCmpts.length !== childAxisCmpts.length) {
return undefined; // Cannot merge axis component with different number of axes.
}
const length = mergedAxisCmpts.length;
for (let i = 0; i < length; i++) {
const merged = mergedAxisCmpts[i];
const child = childAxisCmpts[i];
if (!!merged !== !!child) {
return undefined;
} else if (merged && child) {
const mergedOrient = merged.getWithExplicit('orient');
const childOrient = child.getWithExplicit('orient');
if (mergedOrient.explicit && childOrient.explicit && mergedOrient.value !== childOrient.value) {
// TODO: throw warning if resolve is explicit (We don't have info about explicit/implicit resolve yet.)
// Cannot merge due to inconsistent orient
return undefined;
} else {
mergedAxisCmpts[i] = mergeAxisComponent(merged, child);
}
}
}
} else {
// For first one, return a copy of the child
return childAxisCmpts.map((axisComponent) => axisComponent.clone());
}
return mergedAxisCmpts;
}
function mergeAxisComponent(merged: AxisComponent, child: AxisComponent): AxisComponent {
for (const prop of AXIS_COMPONENT_PROPERTIES) {
const mergedValueWithExplicit = mergeValuesWithExplicit<AxisComponentProps, any>(
merged.getWithExplicit(prop),
child.getWithExplicit(prop),
prop,
'axis',
// Tie breaker function
(v1: Explicit<any>, v2: Explicit<any>) => {
switch (prop) {
case 'title':
return mergeTitleComponent(v1, v2);
case 'gridScale':
return {
explicit: v1.explicit, // keep the old explicit
value: getFirstDefined(v1.value, v2.value),
};
}
return defaultTieBreaker<AxisComponentProps, any>(v1, v2, prop, 'axis');
},
);
merged.setWithExplicit(prop, mergedValueWithExplicit);
}
return merged;
}
function isExplicit<T extends string | number | boolean | unknown>(
value: T,
property: keyof AxisComponentProps,
axis: Axis<SignalRef>,
model: UnitModel,
channel: PositionScaleChannel,
) {
if (property === 'disable') {
return axis !== undefined; // if axis is specified or null/false, then its enable/disable state is explicit
}
axis = axis || {};
switch (property) {
case 'titleAngle':
case 'labelAngle':
return value === (isSignalRef(axis.labelAngle) ? axis.labelAngle : normalizeAngle(axis.labelAngle));
case 'values':
return !!axis.values;
// specified axis.values is already respected, but may get transformed.
case 'encode':
// both VL axis.encoding and axis.labelAngle affect VG axis.encode
return !!axis.encoding || !!axis.labelAngle;
case 'title':
// title can be explicit if fieldDef.title is set
if (value === getFieldDefTitle(model, channel)) {
return true;
}
}
// Otherwise, things are explicit if the returned value matches the specified property
return value === (axis as any)[property];
}
/**
* Properties to always include values from config
*/
const propsToAlwaysIncludeConfig = new Set([
'grid', // Grid is an exception because we need to set grid = true to generate another grid axis
'translate', // translate has dependent logic for bar's bin position and it's 0.5 by default in Vega. If a config overrides this value, we need to know.
// the rest are not axis configs in Vega, but are in VL, so we need to set too.
'format',
'formatType',
'orient',
'labelExpr',
'tickCount',
'position',
'tickMinStep',
]);
function parseAxis(channel: PositionScaleChannel, model: UnitModel): AxisComponent {
let axis = model.axis(channel);
const axisComponent = new AxisComponent();
const fieldOrDatumDef = getFieldOrDatumDef(model.encoding[channel]) as
| PositionFieldDef<string>
| PositionDatumDef<string>;
const {mark, config} = model;
const orient =
axis?.orient ||
config[channel === 'x' ? 'axisX' : 'axisY']?.orient ||
config.axis?.orient ||
defaultOrient(channel);
const scaleType = model.getScaleComponent(channel).get('type');
const axisConfigs = getAxisConfigs(channel, scaleType, orient, model.config);
const disable =
axis !== undefined ? !axis : getAxisConfig('disable', config.style, axis?.style, axisConfigs).configValue;
axisComponent.set('disable', disable, axis !== undefined);
if (disable) {
return axisComponent;
}
axis = axis || {};
const labelAngle = getLabelAngle(fieldOrDatumDef, axis, channel, config.style, axisConfigs);
const formatType = guideFormatType(axis.formatType, fieldOrDatumDef, scaleType);
const format = guideFormat(fieldOrDatumDef, fieldOrDatumDef.type, axis.format, axis.formatType, config, true);
const ruleParams: AxisRuleParams = {
fieldOrDatumDef,
axis,
channel,
model,
scaleType,
orient,
labelAngle,
format,
formatType,
mark,
config,
};
// 1.2. Add properties
for (const property of AXIS_COMPONENT_PROPERTIES) {
const value =
property in axisRules ? axisRules[property](ruleParams) : isAxisProperty(property) ? axis[property] : undefined;
const hasValue = value !== undefined;
const explicit = isExplicit(value, property, axis, model, channel);
if (hasValue && explicit) {
axisComponent.set(property, value, explicit);
} else {
const {configValue = undefined, configFrom = undefined} =
isAxisProperty(property) && property !== 'values'
? getAxisConfig(property, config.style, axis.style, axisConfigs)
: {};
const hasConfigValue = configValue !== undefined;
if (hasValue && !hasConfigValue) {
// only set property if it is explicitly set or has no config value (otherwise we will accidentally override config)
axisComponent.set(property, value, explicit);
} else if (
// Cases need implicit values
// 1. Axis config that aren't available in Vega
!(configFrom === 'vgAxisConfig') ||
// 2. Certain properties are always included (see `propsToAlwaysIncludeConfig`'s declaration for more details)
(propsToAlwaysIncludeConfig.has(property) && hasConfigValue) ||
// 3. Conditional axis values and signals
isConditionalAxisValue(configValue) ||
isSignalRef(configValue)
) {
// If a config is specified and is conditional, copy conditional value from axis config
axisComponent.set(property, configValue, false);
}
}
}
// 2) Add guide encode definition groups
const axisEncoding = axis.encoding ?? {};
const axisEncode = AXIS_PARTS.reduce((e: VgAxisEncode, part) => {
if (!axisComponent.hasAxisPart(part)) {
// No need to create encode for a disabled part.
return e;
}
const axisEncodingPart = guideEncodeEntry(axisEncoding[part] ?? {}, model);
const value = part === 'labels' ? encode.labels(model, channel, axisEncodingPart) : axisEncodingPart;
if (value !== undefined && !isEmpty(value)) {
e[part] = {update: value};
}
return e;
}, {} as VgAxisEncode);
// FIXME: By having encode as one property, we won't have fine grained encode merging.
if (!isEmpty(axisEncode)) {
axisComponent.set('encode', axisEncode, !!axis.encoding || axis.labelAngle !== undefined);
}
return axisComponent;
}