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 = { bottom: 'top', top: 'bottom', left: 'right', right: 'left', }; export function parseLayerAxes(model: LayerModel) { const {axes, resolve} = model.component; const axisCount: Record = {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( merged.getWithExplicit(prop), child.getWithExplicit(prop), prop, 'axis', // Tie breaker function (v1: Explicit, v2: Explicit) => { 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(v1, v2, prop, 'axis'); }, ); merged.setWithExplicit(prop, mergedValueWithExplicit); } return merged; } function isExplicit( value: T, property: keyof AxisComponentProps, axis: Axis, 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 | PositionDatumDef; 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; }