@antv/g2
Version:
the Grammar of Graphics in Javascript
685 lines (625 loc) • 18.7 kB
text/typescript
import { Linear, createInterpolateValue } from '@antv/scale';
import { extent, max } from 'd3-array';
import * as d3ScaleChromatic from 'd3-scale-chromatic';
import { deepMix, omit, upperFirst } from '@antv/util';
import { firstOf, lastOf, unique } from '../utils/array';
import { defined, identity, isStrictObject } from '../utils/helper';
import { Primitive, G2Theme, G2MarkState, ChannelGroups } from './types/common';
import {
G2CoordinateOptions,
G2Library,
G2ScaleOptions,
G2PaletteOptions,
G2Mark,
G2View,
} from './types/options';
import {
ScaleComponent,
PaletteComponent,
Palette,
Scale,
} from './types/component';
import { isTheta } from './coordinate';
import { useLibrary } from './library';
import { MarkChannel } from './types/mark';
export function inferScale(
name: string,
values: Primitive[][],
options: Record<string, any>,
coordinates: G2CoordinateOptions[],
theme: G2Theme,
library: G2Library,
) {
const { guide = {} } = options;
const type = inferScaleType(name, values, options);
if (typeof type !== 'string') return options;
const expectedDomain = inferScaleDomain(type, name, values, options);
const actualDomain = maybeRatio(type, expectedDomain, options);
return {
...options,
...inferScaleOptions(type, name, values, options, coordinates),
domain: actualDomain,
range: inferScaleRange(
type,
name,
values,
options,
actualDomain,
theme,
library,
),
expectedDomain,
guide,
name,
type,
};
}
export function applyScale(
channels: ChannelGroups[],
scale: Record<string, Scale>,
): MarkChannel {
const scaledValue = {};
for (const channel of channels) {
const { values, name: scaleName } = channel;
const scaleInstance = scale[scaleName];
for (const value of values) {
const { name, value: V } = value;
scaledValue[name] = V.map((d) => scaleInstance.map(d));
}
}
return scaledValue;
}
export function collectScales(states: G2MarkState[], options: G2View) {
const { components = [] } = options;
const NONE_STATIC_KEYS = [
'scale',
'encode',
'axis',
'legend',
'data',
'transform',
];
// From normal marks.
const scales = Array.from(
new Set(states.flatMap((d) => d.channels.map((d) => d.scale))),
);
// From static marks.
const nameScale = new Map(scales.map((scale) => [scale.name, scale]));
for (const component of components) {
const channels = inferChannelsForComponent(component);
for (const channel of channels) {
const scale = nameScale.get(channel);
const staticScale = component.scale?.[channel] || {};
const { independent = false } = staticScale;
if (scale && !independent) {
// Merged with exist scales if is not independent.
const { guide } = scale;
const guide1 = typeof guide === 'boolean' ? {} : guide;
scale.guide = deepMix({}, guide1, component);
Object.assign(scale, staticScale);
} else {
// Append new scales without exit scales or independent.
const options1 = {
...staticScale,
expectedDomain: staticScale.domain,
name: channel,
guide: omit(component, NONE_STATIC_KEYS),
};
scales.push(options1);
}
}
}
return scales;
}
export function useRelation(
relations: [any, any][],
): [(scale: Scale) => Scale, (scale: Scale) => Scale] {
if (!relations || !Array.isArray(relations)) return [identity, identity];
// Store original map and invert.
let map;
let invert;
const conditionalize = (scale: Scale) => {
map = scale.map.bind(scale);
invert = scale.invert?.bind(scale);
// Distinguish functions[function, output] and value[vale, output] relations.
const funcRelations = relations.filter(([v]) => typeof v === 'function');
const valueRelations = relations.filter(([v]) => typeof v !== 'function');
// Update scale.map
const valueOutput = new Map(valueRelations);
scale.map = (x) => {
for (const [verify, value] of funcRelations) {
if (verify(x)) return value;
}
if (valueOutput.has(x)) return valueOutput.get(x);
return map(x);
};
if (!invert) return scale;
// Update scale.invert
const outputValue = new Map(valueRelations.map(([a, b]) => [b, a]));
const outputFunc = new Map(funcRelations.map(([a, b]) => [b, a]));
scale.invert = (x) => {
if (outputFunc.has(x)) return x;
if (outputValue.has(x)) return outputValue.get(x);
return invert(x);
};
return scale;
};
const deconditionalize = (scale: Scale) => {
if (map !== null) scale.map = map;
if (invert !== null) scale.invert = invert;
return scale;
};
return [conditionalize, deconditionalize];
}
export function assignScale(
target: Record<string, Scale>,
source: Record<string, Scale>,
): Record<string, Scale> {
const keys = Object.keys(target);
for (const scale of Object.values(source)) {
const { name } = scale.getOptions();
if (!(name in target)) target[name] = scale;
else {
const I = keys
.filter((d) => d.startsWith(name))
// Reg is for extract `1` from `x1`;
.map((d) => +(d.replace(name, '') || 0));
const index = max(I) + 1;
const newKey = `${name}${index}`;
target[newKey] = scale;
scale.getOptions().key = newKey;
}
}
return target;
}
export function useRelationScale(
options: Record<string, any>,
library: G2Library,
) {
const [useScale] = useLibrary<G2ScaleOptions, ScaleComponent, Scale>(
'scale',
library,
);
const { relations } = options;
const [conditionalize] = useRelation(relations);
const scale = useScale(options);
return conditionalize(scale);
}
export function syncFacetsScales(states: Map<G2Mark, G2MarkState>[]): void {
const scales = states
.flatMap((d) => Array.from(d.values()))
.flatMap((d) => d.channels.map((d) => d.scale));
syncFacetsScaleByChannel(scales, 'x');
syncFacetsScaleByChannel(scales, 'y');
}
function inferChannelsForComponent(component) {
const { channels = [], type, scale = {} } = component;
const L = ['shape', 'color', 'opacity', 'size'];
if (channels.length !== 0) return channels;
if (type === 'axisX') return ['x'];
if (type === 'axisY') return ['y'];
if (type === 'legends')
return Object.keys(scale).filter((d) => L.includes(d));
return [];
}
function syncFacetsScaleByChannel(
scales: G2ScaleOptions[],
channel: 'x' | 'y',
): void {
const S = scales.filter(
({ name, facet = true }) => facet && name === channel,
);
const D = S.flatMap((d) => d.domain);
const syncedD = S.every(isQuantitativeScale)
? extent(D)
: S.every(isDiscreteScale)
? Array.from(new Set(D))
: null;
if (syncedD === null) return;
for (const scale of S) {
scale.domain = syncedD;
}
}
function maybeRatio(
type: string,
domain: Primitive[],
options: G2ScaleOptions,
) {
const { ratio } = options;
if (ratio === undefined || ratio === null) return domain;
if (isQuantitativeScale({ type })) {
return clampQuantitativeScale(domain as number[], ratio, type);
}
if (isDiscreteScale({ type })) return clampDiscreteScale(domain, ratio);
return domain;
}
function clampQuantitativeScale(domain: number[], ratio: number, type: string) {
const D = domain.map(Number);
const scale = new Linear({
domain: D,
range: [D[0], D[0] + (D[D.length - 1] - D[0]) * ratio],
});
if (type === 'time') return domain.map((d) => new Date(scale.map(d)));
return domain.map((d) => scale.map(d));
}
function clampDiscreteScale(domain: Primitive[], ratio: number) {
const index = Math.round(domain.length * ratio);
return domain.slice(0, index);
}
function isQuantitativeScale(scale: G2ScaleOptions) {
const { type } = scale;
if (typeof type !== 'string') return false;
// Do not take quantize, quantile or threshold scale into account,
// because they are not for position scales. If they are, there is
// no need to sync them.
const names = ['linear', 'log', 'pow', 'time'];
return names.includes(type);
}
function isDiscreteScale(scale: G2ScaleOptions) {
const { type } = scale;
if (typeof type !== 'string') return false;
const names = ['band', 'point', 'ordinal'];
return names.includes(type);
}
// @todo More accurate inference for different cases.
function inferScaleType(
name: string,
values: Primitive[][],
options: G2ScaleOptions,
): string | ScaleComponent {
const { type, domain, range, quantitative, ordinal } = options;
if (type !== undefined) return type;
if (isObject(values)) return 'identity';
if (typeof range === 'string') return 'linear';
if ((domain || range || []).length > 2) return asOrdinalType(name, ordinal);
if (domain !== undefined) {
if (isOrdinal([domain])) return asOrdinalType(name, ordinal);
if (isTemporal(values)) return 'time';
return asQuantitativeType(name, range, quantitative);
}
if (isOrdinal(values)) return asOrdinalType(name, ordinal);
if (isTemporal(values)) return 'time';
return asQuantitativeType(name, range, quantitative);
}
function inferScaleDomain(
type: string,
name: string,
values,
options: G2ScaleOptions,
): Primitive[] {
const { domain } = options;
if (domain !== undefined) return domain;
switch (type) {
case 'linear':
case 'time':
case 'log':
case 'pow':
case 'sqrt':
case 'quantize':
case 'threshold':
return maybeMinMax(inferDomainQ(values, options), options);
case 'band':
case 'ordinal':
case 'point':
return inferDomainC(values);
case 'quantile':
return inferDomainO(values);
case 'sequential':
return maybeMinMax(inferDomainS(values), options);
default:
return [];
}
}
function inferScaleRange(
type: string,
name: string,
values: Primitive[][],
options: G2ScaleOptions,
domain: Primitive[],
theme: G2Theme,
library: G2Library,
) {
const { range } = options;
if (typeof range === 'string') return gradientColors(range);
if (range !== undefined) return range;
const { rangeMin, rangeMax } = options;
switch (type) {
case 'linear':
case 'time':
case 'log':
case 'pow':
case 'sqrt': {
const colors = categoricalColors(values, options, domain, theme, library);
const [r0, r1] = inferRangeQ(name, colors);
return [rangeMin ?? r0, rangeMax ?? r1];
}
case 'band':
case 'point': {
const min = name === 'size' ? 5 : 0;
const max = name === 'size' ? 10 : 1;
return [rangeMin ?? min, rangeMax ?? max];
}
case 'ordinal': {
return categoricalColors(values, options, domain, theme, library);
}
case 'sequential':
return undefined;
case 'constant':
return [values[0][0]];
default:
return [];
}
}
function inferScaleOptions(
type: string,
name: string,
values: Primitive[][],
options: G2ScaleOptions,
coordinates: G2CoordinateOptions[],
): G2ScaleOptions {
switch (type) {
case 'linear':
case 'time':
case 'log':
case 'pow':
case 'sqrt':
return inferOptionsQ(coordinates, options);
case 'band':
case 'point':
return inferOptionsC(type, name, coordinates, options);
case 'sequential':
return inferOptionsS(options);
default:
return options;
}
}
function categoricalColors(
values: Primitive[][],
options: G2ScaleOptions,
domain: Primitive[],
theme: G2Theme,
library: G2Library,
) {
const [usePalette] = useLibrary<G2PaletteOptions, PaletteComponent, Palette>(
'palette',
library,
);
const { category10: c10, category20: c20 } = theme;
const defaultPalette = unique(values.flat()).length <= c10.length ? c10 : c20;
const { palette = defaultPalette, offset } = options;
if (Array.isArray(palette)) return palette;
// Built-in palettes have higher priority.
try {
return usePalette({ type: palette });
} catch (e) {
const colors = interpolatedColors(palette, domain, offset);
if (colors) return colors;
throw new Error(`Unknown Component: ${palette} `);
}
}
function gradientColors(range: string): string[] {
return range.split('-');
}
function interpolatedColors(
palette: string,
domain: Primitive[],
offset = (d) => d,
): string[] {
if (!palette) return null;
const fullName = upperFirst(palette);
// If scheme have enough colors, then return pre-defined colors.
const scheme = d3ScaleChromatic[`scheme${fullName}`];
const interpolator = d3ScaleChromatic[`interpolate${fullName}`];
if (!scheme && !interpolator) return null;
if (scheme) {
// If is a one dimension array, return it.
if (!scheme.some(Array.isArray)) return scheme;
const schemeColors = scheme[domain.length];
if (schemeColors) return schemeColors;
}
// Otherwise interpolate to get full colors.
return domain.map((_, i) => interpolator(offset(i / domain.length)));
}
function inferOptionsS(options) {
const { palette = 'ylGnBu', offset } = options;
const name = upperFirst(palette);
const interpolator = d3ScaleChromatic[`interpolate${name}`];
if (!interpolator) throw new Error(`Unknown palette: ${name}`);
return {
interpolator: offset ? (x) => interpolator(offset(x)) : interpolator,
};
}
function inferOptionsQ(
coordinates: G2CoordinateOptions[],
options: G2ScaleOptions,
): G2ScaleOptions {
const {
interpolate = createInterpolateValue,
nice = false,
tickCount = 5,
} = options;
return { ...options, interpolate, nice, tickCount };
}
function inferOptionsC(
type: string,
name: string,
coordinates: G2CoordinateOptions[],
options: G2ScaleOptions,
): G2ScaleOptions {
if (
options.padding !== undefined ||
options.paddingInner !== undefined ||
options.paddingOuter !== undefined
) {
return { ...options, unknown: NaN };
}
const padding = inferPadding(type, name, coordinates);
const { paddingInner = padding, paddingOuter = padding } = options;
return {
...options,
paddingInner,
paddingOuter,
padding,
unknown: NaN,
};
}
function inferPadding(
type: string,
name: string,
coordinates: G2CoordinateOptions[],
): number {
// The scale for enterDelay and enterDuration should has zero padding by default.
// Because there is no need to add extra delay for the start and the end.
if (name === 'enterDelay' || name === 'enterDuration') return 0;
if (name === 'size') return 0;
if (type === 'band') return isTheta(coordinates) ? 0 : 0.1;
// Point scale need 0.5 padding to make interval between first and last point
// equal to other intervals in polar coordinate.
if (type === 'point') return 0.5;
return 0;
}
function asOrdinalType(name: string, defaults: string) {
if (defaults) return defaults;
return isQuantitative(name) ? 'point' : 'ordinal';
}
function asQuantitativeType(
name: string,
range: Primitive[],
defaults: string,
) {
if (defaults) return defaults;
if (name !== 'color') return 'linear';
return range ? 'linear' : 'sequential';
}
function maybeMinMax(
domain: Primitive[],
options: G2ScaleOptions,
): Primitive[] {
if (domain.length === 0) return domain;
const { domainMin, domainMax } = options;
const [d0, d1] = domain;
return [domainMin ?? d0, domainMax ?? d1];
}
function inferDomainQ(values: Primitive[][], options: G2ScaleOptions) {
const { zero = false } = options;
let min = Infinity;
let max = -Infinity;
for (const value of values) {
for (const d of value) {
if (defined(d)) {
min = Math.min(min, +d);
max = Math.max(max, +d);
}
}
}
if (min === Infinity) return [];
return zero ? [Math.min(0, min), max] : [min, max];
}
function inferDomainC(values: Primitive[][]) {
return Array.from(new Set(values.flat()));
}
function inferDomainO(values: Primitive[][]) {
return values.flat().sort();
}
function inferDomainS(values: Primitive[][]) {
let min = Infinity;
let max = -Infinity;
for (const value of values) {
for (const d of value) {
if (defined(d)) {
min = Math.min(min, +d);
max = Math.max(max, +d);
}
}
}
if (min === Infinity) return [];
return [min < 0 ? -max : min, max];
}
/**
* @todo More nice default range for enterDelay and enterDuration.
* @todo Move these to channel definition.
*/
function inferRangeQ(name: string, palette: Palette): Primitive[] {
if (name === 'enterDelay') return [0, 1000];
if (name == 'enterDuration') return [300, 1000];
if (name.startsWith('y') || name.startsWith('position')) return [1, 0];
if (name === 'color') return [firstOf(palette), lastOf(palette)];
if (name === 'opacity') return [0, 1];
if (name === 'size') return [1, 10];
return [0, 1];
}
function isOrdinal(values: Primitive[][]): boolean {
return some(values, (d) => {
const type = typeof d;
return type === 'string' || type === 'boolean';
});
}
function isTemporal(values: Primitive[][]): boolean {
return some(values, (d) => d instanceof Date);
}
function isObject(values: Primitive[][]): boolean {
return some(values, isStrictObject);
}
function some(values, callback) {
for (const V of values) {
if (V.some(callback)) return true;
}
return false;
}
function isQuantitative(name: string): boolean {
return (
name.startsWith('x') ||
name.startsWith('y') ||
name.startsWith('position') ||
name.startsWith('size')
);
}
// Spatial and temporal position.
export function isPosition(name: string): boolean {
return (
name.startsWith('x') ||
name.startsWith('y') ||
name.startsWith('position') ||
name === 'enterDelay' ||
name === 'enterDuration' ||
name === 'updateDelay' ||
name === 'updateDuration' ||
name === 'exitDelay' ||
name === 'exitDuration'
);
}
export function isValidScale(scale: G2ScaleOptions) {
if (!scale || !scale.type) return false;
if (typeof scale.type === 'function') return true;
const { type, domain, range, interpolator } = scale;
const isValidDomain = domain && domain.length > 0;
const isValidRange = range && range.length > 0;
if (
[
'linear',
'sqrt',
'log',
'time',
'pow',
'threshold',
'quantize',
'quantile',
'ordinal',
'band',
'point',
].includes(type) &&
isValidDomain &&
isValidRange
) {
return true;
}
if (
['sequential'].includes(type) &&
isValidDomain &&
(isValidRange || interpolator)
) {
return true;
}
if (['constant', 'identity'].includes(type) && isValidRange) return true;
return false;
}