import {LabelOverlap, LegendOrient, LegendType, Orientation, SignalRef, SymbolShape} from 'vega'; import {isArray} from 'vega-util'; import {isColorChannel} from '../../channel.js'; import { DatumDef, MarkPropFieldOrDatumDef, title as fieldDefTitle, TypedFieldDef, valueArray, } from '../../channeldef.js'; import {Config} from '../../config.js'; import {Encoding} from '../../encoding.js'; import {Legend, LegendConfig, LegendInternal} from '../../legend.js'; import {Mark, MarkDef} from '../../mark.js'; import {isContinuousToContinuous, ScaleType} from '../../scale.js'; import {TimeUnit} from '../../timeunit.js'; import {contains, getFirstDefined} from '../../util.js'; import {isSignalRef} from '../../vega.schema.js'; import {guideFormat, guideFormatType} from '../format.js'; import {Model} from '../model.js'; import {UnitModel} from '../unit.js'; import {NonPositionScaleChannel} from './../../channel.js'; import {LegendComponentProps} from './component.js'; import {getFirstConditionValue} from './encode.js'; export interface LegendRuleParams { legend: LegendInternal; channel: NonPositionScaleChannel; model: UnitModel; markDef: MarkDef; encoding: Encoding; fieldOrDatumDef: MarkPropFieldOrDatumDef; legendConfig: LegendConfig; config: Config; scaleType: ScaleType; orient: LegendOrient; legendType: LegendType; direction: Orientation; } export const legendRules: { [k in keyof LegendComponentProps]?: (params: LegendRuleParams) => LegendComponentProps[k]; } = { direction: ({direction}) => direction, format: ({fieldOrDatumDef, legend, config}) => { const {format, formatType} = legend; return guideFormat(fieldOrDatumDef, fieldOrDatumDef.type, format, formatType, config, false); }, formatType: ({legend, fieldOrDatumDef, scaleType}) => { const {formatType} = legend; return guideFormatType(formatType, fieldOrDatumDef, scaleType); }, gradientLength: (params) => { const {legend, legendConfig} = params; return legend.gradientLength ?? legendConfig.gradientLength ?? defaultGradientLength(params); }, labelOverlap: ({legend, legendConfig, scaleType}) => legend.labelOverlap ?? legendConfig.labelOverlap ?? defaultLabelOverlap(scaleType), symbolType: ({legend, markDef, channel, encoding}) => legend.symbolType ?? defaultSymbolType(markDef.type, channel, encoding.shape, markDef.shape), title: ({fieldOrDatumDef, config}) => fieldDefTitle(fieldOrDatumDef, config, {allowDisabling: true}), type: ({legendType, scaleType, channel}) => { if (isColorChannel(channel) && isContinuousToContinuous(scaleType)) { if (legendType === 'gradient') { return undefined; } } else if (legendType === 'symbol') { return undefined; } return legendType; }, // depended by other property, let's define upfront values: ({fieldOrDatumDef, legend}) => values(legend, fieldOrDatumDef), }; export function values(legend: LegendInternal, fieldOrDatumDef: TypedFieldDef | DatumDef) { const vals = legend.values; if (isArray(vals)) { return valueArray(fieldOrDatumDef, vals); } else if (isSignalRef(vals)) { return vals; } return undefined; } export function defaultSymbolType( mark: Mark, channel: NonPositionScaleChannel, shapeChannelDef: Encoding['shape'], markShape: SymbolShape | SignalRef, ): SymbolShape | SignalRef { if (channel !== 'shape') { // use the value from the shape encoding or the mark config if they exist const shape = getFirstConditionValue(shapeChannelDef) ?? markShape; if (shape) { return shape; } } switch (mark) { case 'bar': case 'rect': case 'image': case 'square': return 'square'; case 'line': case 'trail': case 'rule': return 'stroke'; case 'arc': case 'point': case 'circle': case 'tick': case 'geoshape': case 'area': case 'text': return 'circle'; } } export function clipHeight(legendType: LegendType) { if (legendType === 'gradient') { return 20; } return undefined; } export function getLegendType(params: { legend: LegendInternal; channel: NonPositionScaleChannel; timeUnit?: TimeUnit; scaleType: ScaleType; }): LegendType { const {legend} = params; return getFirstDefined(legend.type, defaultType(params)); } export function defaultType({ channel, timeUnit, scaleType, }: { channel: NonPositionScaleChannel; timeUnit?: TimeUnit; scaleType: ScaleType; }): LegendType { // Following the logic in https://github.com/vega/vega-parser/blob/master/src/parsers/legend.js if (isColorChannel(channel)) { if (contains(['quarter', 'month', 'day'], timeUnit)) { return 'symbol'; } if (isContinuousToContinuous(scaleType)) { return 'gradient'; } } return 'symbol'; } export function getDirection({ legendConfig, legendType, orient, legend, }: { orient: LegendOrient; legendConfig: LegendConfig; legendType: LegendType; legend: Legend; }): Orientation { return ( legend.direction ?? legendConfig[legendType ? 'gradientDirection' : 'symbolDirection'] ?? defaultDirection(orient, legendType) ); } export function defaultDirection(orient: LegendOrient, legendType: LegendType): 'horizontal' | undefined { switch (orient) { case 'top': case 'bottom': return 'horizontal'; case 'left': case 'right': case 'none': case undefined: // undefined = "right" in Vega return undefined; // vertical is Vega's default default: // top-left / ... // For inner legend, uses compact layout like Tableau return legendType === 'gradient' ? 'horizontal' : undefined; } } export function defaultGradientLength({ legendConfig, model, direction, orient, scaleType, }: { scaleType: ScaleType; direction: Orientation; orient: LegendOrient; model: Model; legendConfig: LegendConfig; }) { const { gradientHorizontalMaxLength, gradientHorizontalMinLength, gradientVerticalMaxLength, gradientVerticalMinLength, } = legendConfig; if (isContinuousToContinuous(scaleType)) { if (direction === 'horizontal') { if (orient === 'top' || orient === 'bottom') { return gradientLengthSignal(model, 'width', gradientHorizontalMinLength, gradientHorizontalMaxLength); } else { return gradientHorizontalMinLength; } } else { // vertical / undefined (Vega uses vertical by default) return gradientLengthSignal(model, 'height', gradientVerticalMinLength, gradientVerticalMaxLength); } } return undefined; } function gradientLengthSignal(model: Model, sizeType: 'width' | 'height', min: number, max: number) { const sizeSignal = model.getSizeSignalRef(sizeType).signal; return {signal: `clamp(${sizeSignal}, ${min}, ${max})`}; } export function defaultLabelOverlap(scaleType: ScaleType): LabelOverlap { if (contains(['quantile', 'threshold', 'log', 'symlog'], scaleType)) { return 'greedy'; } return undefined; }