@antv/g2
Version:
the Grammar of Graphics in Javascript
1,678 lines (1,549 loc) • 54.7 kB
text/typescript
import { Vector2 } from '@antv/coord';
import { DisplayObject, IAnimation as GAnimation, Rect } from '@antv/g';
import { deepMix, upperFirst, isArray } from '@antv/util';
import { group, groups } from 'd3-array';
import { format } from 'd3-format';
import { mapObject } from '../utils/array';
import { ChartEvent } from '../utils/event';
import {
isStrictObject,
appendTransform,
compose,
copyAttributes,
defined,
error,
maybeSubObject,
subObject,
useMemo,
} from '../utils/helper';
import { G2Element, select, Selection } from '../utils/selection';
import {
groupComponents,
inferComponent,
normalizeComponents,
renderComponent,
} from './component';
import {
AREA_CLASS_NAME,
COMPONENT_CLASS_NAME,
ELEMENT_CLASS_NAME,
LABEL_CLASS_NAME,
LABEL_LAYER_CLASS_NAME,
MAIN_LAYER_CLASS_NAME,
PLOT_CLASS_NAME,
VIEW_CLASS_NAME,
} from './constant';
import { coordinate2Transform, createCoordinate } from './coordinate';
import {
computeLayout,
computeRoughPlotSize,
placeComponents,
processAxisZ,
} from './layout';
import { documentOf, useLibrary } from './library';
import { initializeMark } from './mark';
import {
applyScale,
assignScale,
collectScales,
inferScale,
syncFacetsScales,
useRelationScale,
} from './scale';
import { applyDataTransform } from './transform';
import {
G2MarkState,
G2Theme,
G2ViewDescriptor,
G2ViewInstance,
Primitive,
} from './types/common';
import {
Animation,
AnimationComponent,
Composition,
CompositionComponent,
Interaction,
InteractionComponent,
LabelTransform,
LabelTransformComponent,
Scale,
Shape,
ShapeComponent,
Theme,
ThemeComponent,
} from './types/component';
import { Mark, MarkComponent, SingleMark } from './types/mark';
import {
G2AnimationOptions,
G2CompositionOptions,
G2Context,
G2GuideComponentOptions,
G2InteractionOptions,
G2LabelTransformOptions,
G2Library,
G2Mark,
G2MarkOptions,
G2ScaleOptions,
G2ShapeOptions,
G2ThemeOptions,
G2View,
G2ViewTree,
} from './types/options';
type Store = Map<any, (options: G2ViewTree) => G2ViewTree>;
export async function plot<T extends G2ViewTree>(
options: T,
selection: Selection,
context: G2Context,
): Promise<any> {
const { library } = context;
const [useComposition] = useLibrary<
G2CompositionOptions,
CompositionComponent,
Composition
>('composition', library);
const [useInteraction] = useLibrary<
G2InteractionOptions,
InteractionComponent,
Interaction
>('interaction', library);
// Some helper functions.
const marks = new Set(
Object.keys(library)
.map((d) => /mark\.(.*)/.exec(d)?.[1])
.filter(defined),
);
const staticMarks = new Set(
Object.keys(library)
.map((d) => /component\.(.*)/.exec(d)?.[1])
.filter(defined),
);
const typeOf = (node: G2ViewTree) => {
const { type } = node;
if (typeof type === 'function') {
// @ts-ignore
const { props = {} } = type;
const { composite = true } = props;
if (composite) return 'mark';
}
if (typeof type !== 'string') return type;
if (marks.has(type) || staticMarks.has(type)) return 'mark';
return type;
};
const isMark = (node: G2ViewTree) => typeOf(node) === 'mark';
const isStandardView = (node: G2ViewTree) => typeOf(node) === 'standardView';
const isStaticMark = (node: G2ViewTree) => {
const { type } = node;
if (typeof type !== 'string') return false;
if (staticMarks.has(type)) return true;
return false;
};
const transform = (node: G2ViewTree) => {
if (isStandardView(node)) return [node];
const type = typeOf(node);
const composition = useComposition({ type, static: isStaticMark(node) });
return composition(node);
};
// Some temporary variables help parse the view tree.
const views: G2ViewDescriptor[] = [];
const viewNode = new Map<G2ViewDescriptor, G2ViewTree>();
const nodeState = new Map<G2ViewTree, Map<G2Mark, G2MarkState>>();
const discovered: G2ViewTree[] = [options];
const nodeGenerators: Generator<G2ViewTree, void, void>[] = [];
while (discovered.length) {
const node = discovered.shift();
if (isStandardView(node)) {
// Initialize view to get data to be visualized. If the marks
// of the view have already been initialized (facet view),
// initialize the view based on the initialized mark states,
// otherwise initialize it from beginning.
const state = nodeState.get(node);
const [view, children] = state
? initializeState(state, node, library)
: await initializeView(node, context);
viewNode.set(view, node);
views.push(view);
// Transform children, they will be transformed into
// standardView if they are mark or view node.
const transformedNodes = children
.flatMap(transform)
.map((d) => coordinate2Transform(d, library));
discovered.push(...transformedNodes);
// Only StandardView can be treated as facet and it
// should sync position scales among facets normally.
if (transformedNodes.every(isStandardView)) {
const states = await Promise.all(
transformedNodes.map((d) => initializeMarks(d, context)),
);
// Note!!!
// This will mutate scales for marks.
syncFacetsScales(states);
for (let i = 0; i < transformedNodes.length; i++) {
const nodeT = transformedNodes[i];
const state = states[i];
nodeState.set(nodeT, state);
}
}
} else {
// Apply transform to get data in advance for non-mark composition
// node, which makes sure that composition node can preprocess the
// data to produce more nodes based on it.
const n = isMark(node) ? node : await applyTransform(node, context);
const N = transform(n);
if (Array.isArray(N)) discovered.push(...N);
else if (typeof N === 'function') nodeGenerators.push(N());
}
}
context.emitter.emit(ChartEvent.BEFORE_PAINT);
// Plot chart.
const enterContainer = new Map<G2ViewDescriptor, DisplayObject>();
const updateContainer = new Map<G2ViewDescriptor, DisplayObject>();
const transitions: GAnimation[] = [];
selection
.selectAll(className(VIEW_CLASS_NAME))
.data(views, (d) => d.key)
.join(
(enter) =>
enter
.append('g')
.attr('className', VIEW_CLASS_NAME)
.attr('id', (view) => view.key)
.call(applyTranslate)
.each(function (view, i, element) {
plotView(view, select(element), transitions, context);
enterContainer.set(view, element);
}),
(update) =>
update.call(applyTranslate).each(function (view, i, element) {
plotView(view, select(element), transitions, context);
updateContainer.set(view, element);
}),
(exit) =>
exit
.each(function (d, i, element) {
// Remove existed interactions.
const interactions = element['nameInteraction'].values();
for (const interaction of interactions) {
interaction.destroy();
}
})
.remove(),
);
// Apply interactions.
const viewInstanceof = (
viewContainer: Map<G2ViewDescriptor, DisplayObject>,
updateInteractions?: (
container: Map<G2ViewDescriptor, DisplayObject>,
updateTypes?: string[],
store?: Store,
) => void,
oldStore?: Store,
) => {
return Array.from(viewContainer.entries()).map(([view, container]) => {
// Index state by component or interaction name,
// such as legend, scrollbar, brushFilter.
// Each state transform options to another options.
const store =
oldStore || new Map<any, (options: G2ViewTree) => G2ViewTree>();
const setState = (key, reducer = (x) => x) => store.set(key, reducer);
const options = viewNode.get(view);
const update = createUpdateView(select(container), options, context);
return {
view,
container,
options,
setState,
update: async (from, updateTypes) => {
// Apply all state functions to get new options.
const reducer = compose(Array.from(store.values()));
const newOptions = reducer(options);
return await update(newOptions, from, () => {
if (isArray(updateTypes)) {
updateInteractions(viewContainer, updateTypes, store);
}
});
},
};
});
};
const updateInteractions = (
container = updateContainer,
updateType?: string[],
oldStore?: Map<any, (options: G2ViewTree) => G2ViewTree>,
) => {
// Interactions for update views.
const updateViewInstances = viewInstanceof(
container,
updateInteractions,
oldStore,
);
for (const target of updateViewInstances) {
const { options, container } = target;
const nameInteraction = container['nameInteraction'];
let typeOptions = inferInteraction(options);
if (updateType) {
typeOptions = typeOptions.filter((v) => updateType.includes(v[0]));
}
for (const typeOption of typeOptions) {
const [type, option] = typeOption;
// Remove interaction for existed views.
const prevInteraction = nameInteraction.get(type);
if (prevInteraction) prevInteraction.destroy?.();
// Apply new interaction.
if (option) {
const interaction = useThemeInteraction(
target.view,
type,
option as Record<string, any>,
useInteraction,
);
const destroy = interaction(
target,
updateViewInstances,
context.emitter,
);
nameInteraction.set(type, { destroy });
}
}
}
};
// Interactions for enter views.
const enterViewInstances = viewInstanceof(enterContainer, updateInteractions);
for (const target of enterViewInstances) {
const { options } = target;
// A Map index interaction by interaction name.
const nameInteraction = new Map();
target.container['nameInteraction'] = nameInteraction;
// Apply interactions.
for (const typeOption of inferInteraction(options)) {
const [type, option] = typeOption;
if (option) {
const interaction = useThemeInteraction(
target.view,
type,
option as Record<string, any>,
useInteraction,
);
const destroy = interaction(
target,
enterViewInstances,
context.emitter,
);
nameInteraction.set(type, { destroy });
}
}
}
updateInteractions();
// Author animations.
const { width, height } = options;
const keyframes = [];
for (const nodeGenerator of nodeGenerators) {
// Delay the rendering of animation keyframe. Different animation
// created by different nodeGenerator will play in the same time.
// eslint-disable-next-line no-async-promise-executor
const keyframe = new Promise<void>(async (resolve) => {
for (const node of nodeGenerator) {
const sizedNode = { width, height, ...node };
await plot(sizedNode, selection, context);
}
resolve();
});
keyframes.push(keyframe);
}
context.views = views;
// Clear and update animation.
context.animations?.forEach((animation) => animation?.cancel());
context.animations = transitions;
context.emitter.emit(ChartEvent.AFTER_PAINT);
// Note!!!
// The returned promise will never resolved if one of nodeGenerator
// never stop to yield node, which may created by a keyframe composition
// with iteration count set to infinite.
const finished = transitions
.filter(defined)
.map(cancel)
.map((d) => d.finished);
return Promise.all([...finished, ...keyframes]);
}
function applyTranslate(selection: Selection) {
selection.style(
'transform',
(d) => `translate(${d.layout.x}, ${d.layout.y})`,
);
}
function definedInteraction(library: G2Library) {
const [, createInteraction] = useLibrary<
G2InteractionOptions,
InteractionComponent,
Interaction
>('interaction', library);
return (d) => {
const [name, options] = d;
try {
return [name, createInteraction(name)] as const;
} catch {
return [name, options.type] as const;
}
};
}
function createUpdateView(
selection: Selection,
options: G2ViewTree,
context: G2Context,
): G2ViewInstance['update'] {
const { library } = context;
const createDefinedInteraction = definedInteraction(library);
const filter = (d) => d[1] && d[1].props && d[1].props.reapplyWhenUpdate;
const interactions = inferInteraction(options);
const updates = interactions
.map(createDefinedInteraction)
.filter(filter)
.map((d) => d[0]);
return async (newOptions, source, callback) => {
const transitions = [];
const [newView, newChildren] = await initializeView(newOptions, context);
plotView(newView, selection, transitions, context);
// Update interaction need to reapply when update.
for (const name of updates.filter((d) => d !== source)) {
updateInteraction(name, selection, newOptions, newView, context);
}
for (const child of newChildren) {
plot(child, selection, context);
}
callback();
return { options: newOptions, view: newView };
};
}
function updateInteraction(
name: string,
selection: Selection,
options: G2ViewTree,
view: G2ViewDescriptor,
context: G2Context,
) {
const { library } = context;
const [useInteraction] = useLibrary<
G2InteractionOptions,
InteractionComponent,
Interaction
>('interaction', library);
// Instances for interaction.
const container = selection.node();
const nameInteraction = container['nameInteraction'];
const interactionOptions = inferInteraction(options).find(
([d]) => d === name,
);
// Destroy older interaction.
const interaction = nameInteraction.get(name);
if (!interaction) return;
interaction.destroy?.();
if (!interactionOptions[1]) return;
// Apply new interaction.
const applyInteraction = useThemeInteraction(
view,
name,
interactionOptions[1] as any,
useInteraction,
);
const target = {
options,
view,
container: selection.node(),
update: (options) => Promise.resolve(options),
};
const destroy = applyInteraction(target, [], context.emitter);
nameInteraction.set(name, { destroy });
}
async function initializeView(
options: G2View,
context: G2Context,
): Promise<[G2ViewDescriptor, G2ViewTree[]]> {
const { library } = context;
const flattenOptions = await transformMarks(options, context);
const mergedOptions = bubbleOptions(flattenOptions);
// @todo Remove this.
// !!! NOTE: Mute original view options.
// Update interaction and coordinate for this view.
options.interaction = mergedOptions.interaction;
options.coordinate = mergedOptions.coordinate;
// @ts-ignore
options.marks = [...mergedOptions.marks, ...mergedOptions.components];
const transformedOptions = coordinate2Transform(mergedOptions, library);
const state = await initializeMarks(transformedOptions, context);
return initializeState(state, transformedOptions, library);
}
function bubbleOptions(options: G2View): G2View {
const {
coordinate: viewCoordinate = {},
interaction: viewInteraction = {},
style: viewStyle = {},
marks,
...rest
} = options;
const markCoordinates = marks.map((d) => d.coordinate || {});
const markInteractions = marks.map((d) => d.interaction || {});
const markViewStyles = marks.map((d) => d.viewStyle || {});
const newCoordinate = [...markCoordinates, viewCoordinate].reduceRight(
(prev, cur) => deepMix(prev, cur),
{},
);
const newInteraction = [viewInteraction, ...markInteractions].reduce(
(prev, cur) => deepMix(prev, cur),
{},
);
const newStyle = [...markViewStyles, viewStyle].reduce(
(prev, cur) => deepMix(prev, cur),
{},
);
return {
...rest,
marks,
coordinate: newCoordinate,
interaction: newInteraction,
style: newStyle,
};
}
async function transformMarks(
options: G2View,
context: G2Context,
): Promise<G2View> {
const { library } = context;
const [useMark, createMark] = useLibrary<G2MarkOptions, MarkComponent, Mark>(
'mark',
library,
);
const staticMarks = new Set(
Object.keys(library)
.map((d) => /component\.(.*)/.exec(d)?.[1])
.filter(defined),
);
const { marks } = options;
const flattenMarks = [];
const components = [];
const discovered = [...marks];
const { width, height } = computeRoughPlotSize(options);
const markOptions = { options, width, height };
// Pre order traversal.
while (discovered.length) {
const [node] = discovered.splice(0, 1);
// Apply data transform to get data.
const mark = (await applyTransform(node, context)) as G2Mark;
const { type = error('G2Mark type is required.'), key } = mark;
// For components.
if (staticMarks.has(type as string)) components.push(mark);
else {
const { props = {} } = createMark(type);
const { composite = true } = props;
if (!composite) flattenMarks.push(mark);
else {
// Unwrap data from { value: data } to data,
// then the composite mark can process the normalized data.
const { data } = mark;
const newMark = {
...mark,
data: data ? (Array.isArray(data) ? data : data.value) : data,
};
// Convert composite mark to marks.
const marks = await useMark(newMark, markOptions);
const M = Array.isArray(marks) ? marks : [marks];
discovered.unshift(...M.map((d, i) => ({ ...d, key: `${key}-${i}` })));
}
}
}
return { ...options, marks: flattenMarks, components };
}
async function initializeMarks(
options: G2View,
context: G2Context,
): Promise<Map<G2Mark, G2MarkState>> {
const { library } = context;
const [useTheme] = useLibrary<G2ThemeOptions, ThemeComponent, Theme>(
'theme',
library,
);
const [, createMark] = useLibrary<G2MarkOptions, MarkComponent, Mark>(
'mark',
library,
);
const {
theme: partialTheme,
marks: partialMarks,
coordinates = [],
} = options;
const theme = useTheme(inferTheme(partialTheme));
const markState = new Map<G2Mark, G2MarkState>();
// Initialize channels for marks.
for (const markOptions of partialMarks) {
const { type } = markOptions;
const { props = {} } = createMark(type);
const markAndState = await initializeMark(markOptions, props, context);
if (markAndState) {
const [initializedMark, state] = markAndState;
markState.set(initializedMark, state);
}
}
// Group channels by scale key, each group has scale.
const scaleChannels = group(
Array.from(markState.values()).flatMap((d) => d.channels),
({ scaleKey }) => scaleKey,
);
// Infer scale for each channel groups.
for (const channels of scaleChannels.values()) {
// Merge scale options for these channels.
const scaleOptions = channels.reduce(
(total, { scale }) => deepMix(total, scale),
{},
);
const { scaleKey } = channels[0];
// Use the fields of the first channel as the title.
const { values: FV } = channels[0];
const fields = Array.from(new Set(FV.map((d) => d.field).filter(defined)));
const options = deepMix(
{
guide: { title: fields.length === 0 ? undefined : fields },
field: fields[0],
},
scaleOptions,
);
// Use the name of the first channel as the scale name.
const { name } = channels[0];
const values = channels.flatMap(({ values }) => values.map((d) => d.value));
const scale = {
...inferScale(name, values, options, coordinates, theme, library),
key: scaleKey,
};
channels.forEach((channel) => (channel.scale = scale));
}
return markState;
}
function useThemeInteraction(
view: G2ViewDescriptor,
type: string,
option: Record<string, any>,
useInteraction: (options: G2InteractionOptions, context?: any) => Interaction,
): Interaction {
const theme = view.theme;
const defaults = typeof type === 'string' ? theme[type] || {} : {};
const interaction = useInteraction(
deepMix(defaults, { type, ...(option as any) }),
);
return interaction;
}
function initializeState(
markState: Map<G2Mark, G2MarkState>,
options: G2View,
library: G2Library,
): [G2ViewDescriptor, G2ViewTree[]] {
const [useMark] = useLibrary<G2MarkOptions, MarkComponent, Mark>(
'mark',
library,
);
const [useTheme] = useLibrary<G2ThemeOptions, ThemeComponent, Theme>(
'theme',
library,
);
const [useLabelTransform] = useLibrary<
G2LabelTransformOptions,
LabelTransformComponent,
LabelTransform
>('labelTransform', library);
const {
key,
frame = false,
theme: partialTheme,
clip,
style = {},
labelTransform = [],
} = options;
const theme = useTheme(inferTheme(partialTheme));
// Infer components and compute layout.
const states = Array.from(markState.values());
const scales = collectScales(states, options);
const components = normalizeComponents(
inferComponent(
inferComponentScales(Array.from(scales), states, markState),
options,
library,
),
);
const layout = computeLayout(components, options, theme, library);
const coordinate = createCoordinate(layout, options, library);
const framedStyle = frame
? deepMix({ mainLineWidth: 1, mainStroke: '#000' }, style)
: style;
// Place components and mutate their bbox.
placeComponents(groupComponents(components), coordinate, layout);
// AxisZ need a copy of axisX and axisY to show grids in X-Z & Y-Z planes.
processAxisZ(components);
// Scale from marks and components.
const scaleInstance: Record<string, Scale> = {};
// Initialize scale from components.
for (const component of components) {
const { scales: scaleDescriptors = [] } = component;
const scales = [];
for (const descriptor of scaleDescriptors) {
const { name } = descriptor;
const scale = useRelationScale(descriptor, library);
scales.push(scale);
// Delivery the scale of axisX to the AxisY,
// in order to calculate the angle of axisY component when rendering radar chart.
if (name === 'y') {
scale.update({
...scale.getOptions(),
xScale: scaleInstance.x,
});
}
assignScale(scaleInstance, { [name]: scale });
}
component.scaleInstances = scales;
}
// Calc data to be rendered for each mark.
// @todo More readable APIs for Container which stays
// the same style with JS standard and lodash APIs.
// @todo More proper way to index scale for different marks.
const children = [];
for (const [mark, state] of markState.entries()) {
const {
// scale,
// Callback to create children options based on this mark.
children: createChildren,
// The total count of data (both show and hide)for this facet.
// This is for unit visualization to sync data domain.
dataDomain,
modifier,
key: markKey,
} = mark;
const { index, channels, tooltip } = state;
const scale = Object.fromEntries(
channels.map(({ name, scale }) => [name, scale]),
);
// Transform abstract value to visual value by scales.
const markScaleInstance = mapObject(scale, (options) => {
return useRelationScale(options, library);
});
assignScale(scaleInstance, markScaleInstance);
const value = applyScale(channels, markScaleInstance);
// Calc points and transformation for each data,
// and then transform visual value to visual data.
const calcPoints = (useMark as (options: G2MarkOptions) => SingleMark)(
mark,
);
const [I, P, S] = filterValid(
calcPoints(index, markScaleInstance, value, coordinate),
);
const count = dataDomain || I.length;
const T = modifier ? modifier(P, count, layout) : [];
const titleOf = (i) => tooltip.title?.[i]?.value;
const itemsOf = (i) => tooltip.items.map((V) => V[i]);
const visualData: Record<string, any>[] = I.map((d, i) => {
const datum = {
points: P[i],
transform: T[i],
index: d,
markKey,
viewKey: key,
...(tooltip && {
title: titleOf(d),
items: itemsOf(d),
}),
};
for (const [k, V] of Object.entries(value)) {
datum[k] = V[d];
if (S) datum[`series${upperFirst(k)}`] = S[i].map((i) => V[i]);
}
if (S) datum['seriesIndex'] = S[i];
if (S && tooltip) {
datum['seriesItems'] = S[i].map((si) => itemsOf(si));
datum['seriesTitle'] = S[i].map((si) => titleOf(si));
}
return datum;
});
state.data = visualData;
state.index = I;
// Create children options by children callback,
// and then propagate data to each child.
const markChildren = createChildren?.(
visualData,
markScaleInstance,
layout,
);
children.push(...(markChildren || []));
}
const view = {
layout,
theme,
coordinate,
markState,
key,
clip,
scale: scaleInstance,
style: framedStyle,
components,
labelTransform: compose(labelTransform.map(useLabelTransform)),
};
return [view, children];
}
async function plotView(
view: G2ViewDescriptor,
selection: Selection,
transitions: GAnimation[],
context: G2Context,
): Promise<void> {
const { library } = context;
const {
components,
theme,
layout,
markState,
coordinate,
key,
style,
clip,
scale,
} = view;
// Render background for the different areas.
const { x, y, width, height, ...rest } = layout;
const areaKeys = ['view', 'plot', 'main', 'content'];
const I = areaKeys.map((_, i) => i);
const sizeKeys = ['a', 'margin', 'padding', 'inset'];
const areaStyles = areaKeys.map((d) =>
maybeSubObject(Object.assign({}, theme.view, style), d),
);
const areaSizes = sizeKeys.map((d) => subObject(rest, d));
const styleArea = (selection) =>
selection
.style('x', (i) => areaLayouts[i].x)
.style('y', (i) => areaLayouts[i].y)
.style('width', (i) => areaLayouts[i].width)
.style('height', (i) => areaLayouts[i].height)
.each(function (i, d, element) {
applyStyle(select(element), areaStyles[i]);
});
let px = 0;
let py = 0;
let pw = width;
let ph = height;
const areaLayouts = I.map((i) => {
const size = areaSizes[i];
const { left = 0, top = 0, bottom = 0, right = 0 } = size;
px += left;
py += top;
pw -= left + right;
ph -= top + bottom;
return {
x: px,
y: py,
width: pw,
height: ph,
};
});
selection
.selectAll(className(AREA_CLASS_NAME))
.data(
// Only render area with defined style.
I.filter((i) => defined(areaStyles[i])),
(i) => areaKeys[i],
)
.join(
(enter) =>
enter
.append('rect')
.attr('className', AREA_CLASS_NAME)
.style('zIndex', -2)
.call(styleArea),
(update) => update.call(styleArea),
(exit) => exit.remove(),
);
const animationExtent = computeAnimationExtent(markState);
const componentAnimateOptions = animationExtent
? { duration: animationExtent[1] }
: false;
// Render components.
// @todo renderComponent return ctor and options.
// Key for each type of component.
// Index them grouped by position.
for (const [, C] of groups(components, (d) => `${d.type}-${d.position}`)) {
C.forEach((d, i) => (d.index = i));
}
const componentsTransitions = selection
.selectAll(className(COMPONENT_CLASS_NAME))
.data(components, (d) => `${d.type}-${d.position}-${d.index}`)
.join(
(enter) =>
enter
.append('g')
.style('zIndex', ({ zIndex }) => zIndex || -1)
.attr('className', COMPONENT_CLASS_NAME)
.append((options) =>
renderComponent(
deepMix({ animate: componentAnimateOptions, scale }, options),
coordinate,
theme,
library,
markState,
),
),
(update) =>
update.transition(function (
options: G2GuideComponentOptions,
i,
element,
) {
const { preserve = false } = options;
if (preserve) return;
const newComponent = renderComponent(
deepMix({ animate: componentAnimateOptions, scale }, options),
coordinate,
theme,
library,
markState,
);
const { attributes } = newComponent;
const [node] = element.childNodes;
return node.update(attributes, false);
}),
)
.transitions();
transitions.push(...componentsTransitions.flat().filter(defined));
// Main layer is for showing the main visual representation such as marks. There
// may be multiple main layers for a view, each main layer correspond to one of marks.
// @todo Test DOM structure.
const T = selection
.selectAll(className(PLOT_CLASS_NAME))
.data([layout], () => key)
.join(
(enter) =>
enter
// Make this layer interactive, such as click and mousemove events.
.append('rect')
.style('zIndex', 0)
.style('fill', 'transparent')
.attr('className', PLOT_CLASS_NAME)
.call(updateBBox)
.call(updateLayers, Array.from(markState.keys()))
.call(applyClip, clip),
(update) =>
update
.call(updateLayers, Array.from(markState.keys()))
.call((selection) => {
return animationExtent
? animateBBox(selection, animationExtent)
: updateBBox(selection);
})
.call(applyClip, clip),
)
.transitions();
transitions.push(...T.flat());
// Render marks with corresponding data.
for (const [mark, state] of markState.entries()) {
const { data } = state;
const { key, class: cls, type } = mark;
const viewNode = selection.select(`#${key}`);
const shapeFunction = createMarkShapeFunction(mark, state, view, context);
const enterFunction = createEnterFunction(mark, state, view, library);
const updateFunction = createUpdateFunction(mark, state, view, library);
const exitFunction = createExitFunction(mark, state, view, library);
const facetElements = selectFacetElements(
selection,
viewNode,
cls,
'element',
);
const T = viewNode
.selectAll(className(ELEMENT_CLASS_NAME))
.selectFacetAll(facetElements)
.data(
data,
(d) => d.key,
(d) => d.groupKey,
)
.join(
(enter) =>
enter
.append(shapeFunction)
// Note!!! Only one className can be set.
// Using attribute as alternative for other classNames.
.attr('className', ELEMENT_CLASS_NAME)
.attr('markType', type)
.transition(function (data, i, element) {
return enterFunction(data, [element]);
}),
(update) =>
update.call((selection) => {
const parent = selection.parent();
const origin = useMemo<DisplayObject, [number, number]>((node) => {
const [x, y] = node.getBounds().min;
return [x, y];
});
selection
.transition(function (data, index, element) {
maybeFacetElement(element, parent, origin);
const node = shapeFunction(data, index);
const animation = updateFunction(data, [element], [node]);
if (animation !== null) return animation;
if (
element.nodeName === node.nodeName &&
node.nodeName !== 'g'
) {
copyAttributes(element, node);
} else {
element.parentNode.replaceChild(node, element);
node.className = ELEMENT_CLASS_NAME;
// @ts-ignore
node.markType = type;
// @ts-ignore
node.__data__ = element.__data__;
}
return animation;
})
.attr('markType', type)
.attr('className', ELEMENT_CLASS_NAME);
}),
(exit) => {
return exit
.each(function (d, i, element) {
element.__removed__ = true;
})
.transition(function (data, i, element) {
return exitFunction(data, [element]);
})
.remove();
},
(merge) =>
merge
// Append elements to be merged.
.append(shapeFunction)
.attr('className', ELEMENT_CLASS_NAME)
.attr('markType', type)
.transition(function (data, i, element) {
// Remove merged elements after animation finishing.
const { __fromElements__: fromElements } = element;
const transition = updateFunction(data, fromElements, [element]);
const exit = new Selection(
fromElements,
null,
element.parentNode,
);
exit.transition(transition).remove();
return transition;
}),
(split) =>
split
.transition(function (data, i, element) {
// Append splitted shapes.
const enter = new Selection(
[],
element.__toData__,
element.parentNode,
);
const toElements = enter
.append(shapeFunction)
.attr('className', ELEMENT_CLASS_NAME)
.attr('markType', type)
.nodes();
return updateFunction(data, [element], toElements);
})
// Remove elements to be splitted after animation finishing.
.remove(),
)
.transitions();
transitions.push(...T.flat());
}
// Plot label for this view.
plotLabel(view, selection, transitions, library, context);
}
/**
* Auto hide labels be specify label layout.
*/
function plotLabel(
view: G2ViewDescriptor,
selection: Selection,
transitions: GAnimation[],
library: G2Library,
context: G2Context,
) {
const [useLabelTransform] = useLibrary<
G2LabelTransformOptions,
LabelTransformComponent,
LabelTransform
>('labelTransform', library);
const { markState, labelTransform } = view;
const labelLayer = selection.select(className(LABEL_LAYER_CLASS_NAME)).node();
// A Map index shapeFunction by label.
const labelShapeFunction = new Map();
// A Map index options by label.
const labelDescriptor = new Map();
// Get all labels for this view.
const labels = Array.from(markState.entries()).flatMap(([mark, state]) => {
const { labels: labelOptions = [], key } = mark;
const shapeFunction = createLabelShapeFunction(
mark,
state,
view,
library,
context,
);
const elements = selection
.select(`#${key}`)
.selectAll(className(ELEMENT_CLASS_NAME))
.nodes()
// Only select the valid element.
.filter((n) => !n.__removed__);
return labelOptions.flatMap((labelOption, i) => {
const { transform = [], ...options } = labelOption;
return elements.flatMap((e) => {
const L = getLabels(options, i, e);
L.forEach((l) => {
labelShapeFunction.set(l, shapeFunction);
labelDescriptor.set(l, labelOption);
});
return L;
});
});
});
// Render all labels.
const labelShapes = select(labelLayer)
.selectAll(className(LABEL_CLASS_NAME))
.data(labels, (d) => d.key)
.join(
(enter) =>
enter
.append((d) => labelShapeFunction.get(d)(d))
.attr('className', LABEL_CLASS_NAME),
(update) =>
update.each(function (d, i, element) {
// @todo Handle Label with different type.
const shapeFunction = labelShapeFunction.get(d);
const node = shapeFunction(d);
copyAttributes(element, node);
}),
(exit) => exit.remove(),
)
.nodes();
// Apply group-level transforms.
const labelGroups = group(labelShapes, (d) =>
labelDescriptor.get(d.__data__),
);
const { coordinate } = view;
const labelTransformContext = {
canvas: context.canvas,
coordinate,
};
for (const [label, shapes] of labelGroups) {
const { transform = [] } = label;
const transformFunction = compose(transform.map(useLabelTransform));
transformFunction(shapes, labelTransformContext);
}
// Apply view-level transform.
if (labelTransform) {
labelTransform(labelShapes, labelTransformContext);
}
}
function getLabels(
label: Record<string, any>,
labelIndex: number,
element: G2Element,
): Record<string, any>[] {
const { seriesIndex: SI, seriesKey, points, key, index } = element.__data__;
const bounds = getLocalBounds(element);
if (!SI) {
return [
{
...label,
key: `${key}-${labelIndex}`,
bounds,
index,
points,
dependentElement: element,
},
];
}
const selector = normalizeLabelSelector(label);
const F = SI.map((index: number, i: number) => ({
...label,
key: `${seriesKey[i]}-${labelIndex}`,
bounds: [points[i]],
index,
points,
dependentElement: element,
}));
return selector ? selector(F) : F;
}
function filterValid([I, P, S]: [number[], Vector2[][], number[][]?]): [
number[],
Vector2[][],
number[][]?,
] {
if (S) return [I, P, S];
const definedIndex = [];
const definedPoints = [];
for (let i = 0; i < I.length; i++) {
const d = I[i];
const p = P[i];
if (p.every(([x, y]) => defined(x) && defined(y))) {
definedIndex.push(d);
definedPoints.push(p);
}
}
return [definedIndex, definedPoints];
}
function normalizeLabelSelector(
label: Record<string, any>,
): (I: number[]) => number[] {
const { selector } = label;
if (!selector) return null;
if (typeof selector === 'function') return selector;
if (selector === 'first') return (I) => [I[0]];
if (selector === 'last') return (I) => [I[I.length - 1]];
throw new Error(`Unknown selector: ${selector}`);
}
/**
* Avoid getting error bounds caused by element animations.
* @todo Remove this temporary handle method, if runtime supports
* correct process: drawElement, do label layout and then do
* transitions together.
*/
function getLocalBounds(element: DisplayObject) {
const cloneElement = element.cloneNode();
const animations = element.getAnimations();
cloneElement.style.visibility = 'hidden';
animations.forEach((animation) => {
const keyframes = animation.effect.getKeyframes();
cloneElement.attr(keyframes[keyframes.length - 1]);
});
element.parentNode.appendChild(cloneElement);
const bounds = cloneElement.getLocalBounds();
cloneElement.destroy();
const { min, max } = bounds;
return [min, max];
}
function createLabelShapeFunction(
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
library: G2Library,
context: G2Context,
): (options: Record<string, any>) => DisplayObject {
const [useShape] = useLibrary<G2ShapeOptions, ShapeComponent, Shape>(
'shape',
library,
);
const { data: abstractData, encode } = mark;
const { data: visualData, defaultLabelShape } = state;
const point2d = visualData.map((d) => d.points);
const channel = mapObject(encode, (d) => d.value);
// Assemble Context.
const { theme, coordinate } = view;
const shapeContext = {
...context,
document: documentOf(context),
theme,
coordinate,
};
return (options) => {
// Computed values from data and styles.
const { index, points } = options;
const datum = abstractData[index];
const {
formatter = (d) => `${d}`,
transform,
style: abstractStyle,
render,
...abstractOptions
} = options;
const visualOptions = mapObject(
{ ...abstractOptions, ...abstractStyle } as Record<string, any>,
(d) => valueOf(d, datum, index, abstractData, { channel }),
);
const { shape = defaultLabelShape, text, ...style } = visualOptions;
const f = typeof formatter === 'string' ? format(formatter) : formatter;
const value = {
...style,
text: f(text, datum, index, abstractData),
datum,
};
// Params for create shape.
const shapeOptions = { type: `label.${shape}`, render, ...style };
const shapeFunction = useShape(shapeOptions, shapeContext);
const defaults = getDefaultsStyle(theme, 'label', shape, 'label');
return shapeFunction(points, value, defaults, point2d);
};
}
function valueOf(
value: Primitive | ((d: any, i: number, array: any, channel: any) => any),
datum: Record<string, any>,
i: number,
data: Record<string, any>,
options: { channel: Record<string, any> },
) {
if (typeof value === 'function') return value(datum, i, data, options);
if (typeof value !== 'string') return value;
if (isStrictObject(datum) && datum[value] !== undefined) return datum[value];
return value;
}
/**
* Compute max duration for this frame.
*/
function computeAnimationExtent(markState): [number, number] {
let maxDuration = -Infinity;
let minDelay = Infinity;
for (const [mark, state] of markState) {
const { animate = {} } = mark;
const { data } = state;
const { enter = {}, update = {}, exit = {} } = animate;
const {
type: defaultUpdateType,
duration: defaultUpdateDuration = 300,
delay: defaultUpdateDelay = 0,
} = update;
const {
type: defaultEnterType,
duration: defaultEnterDuration = 300,
delay: defaultEnterDelay = 0,
} = enter;
const {
type: defaultExitType,
duration: defaultExitDuration = 300,
delay: defaultExitDelay = 0,
} = exit;
for (const d of data) {
const {
updateType = defaultUpdateType,
updateDuration = defaultUpdateDuration,
updateDelay = defaultUpdateDelay,
enterType = defaultEnterType,
enterDuration = defaultEnterDuration,
enterDelay = defaultEnterDelay,
exitDuration = defaultExitDuration,
exitDelay = defaultExitDelay,
exitType = defaultExitType,
} = d;
if (updateType === undefined || updateType) {
maxDuration = Math.max(maxDuration, updateDuration + updateDelay);
minDelay = Math.min(minDelay, updateDelay);
}
if (exitType === undefined || exitType) {
maxDuration = Math.max(maxDuration, exitDuration + exitDelay);
minDelay = Math.min(minDelay, exitDelay);
}
if (enterType === undefined || enterType) {
maxDuration = Math.max(maxDuration, enterDuration + enterDelay);
minDelay = Math.min(minDelay, enterDelay);
}
}
}
if (maxDuration === -Infinity) return null;
return [minDelay, maxDuration - minDelay];
}
function selectFacetElements(
selection: Selection,
current: Selection,
facetClassName: string,
elementClassName: string,
): DisplayObject[] {
const group = selection.node().parentElement;
return group
.findAll(
(node) =>
node.style.facet !== undefined &&
node.style.facet === facetClassName &&
node !== current.node(), // Exclude current view.
)
.flatMap((node) => node.getElementsByClassName(elementClassName));
}
/**
* Update the parent of element and apply transform to make it
* stay in original position.
*/
function maybeFacetElement(
element: G2Element,
parent: DisplayObject,
originOf: (node: DisplayObject) => [number, number],
): void {
if (!element.__facet__) return;
// element -> g#main -> rect#plot
const prePlot = element.parentNode.parentNode as DisplayObject;
// g#main -> rect#plot
const newPlot = parent.parentNode as DisplayObject;
const [px, py] = originOf(prePlot);
const [x, y] = originOf(newPlot);
const translate = `translate(${px - x}, ${py - y})`;
appendTransform(element, translate);
parent.append(element);
}
function createMarkShapeFunction(
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
context: G2Context,
): (
data: Record<string, any>,
index: number,
element?: DisplayObject,
) => DisplayObject {
const { library } = context;
const [useShape] = useLibrary<G2ShapeOptions, ShapeComponent, Shape>(
'shape',
library,
);
const { data: abstractData, encode } = mark;
const { defaultShape, data, shape: shapeLibrary } = state;
const channel = mapObject(encode, (d) => d.value);
const point2d = data.map((d) => d.points);
const { theme, coordinate } = view;
const { type: markType, style = {} } = mark;
const shapeContext = {
...context,
document: documentOf(context),
coordinate,
theme,
};
return (data) => {
const { shape: styleShape = defaultShape } = style;
const { shape = styleShape, points, seriesIndex, index: i, ...v } = data;
const value = { ...v, index: i };
// Get data-driven style.
// If it is a series shape, such as area and line,
// provides the series of abstract data and indices
// for this shape, otherwise the single datum and
// index.
const abstractDatum = seriesIndex
? seriesIndex.map((i) => abstractData[i])
: abstractData[i];
const I = seriesIndex ? seriesIndex : i;
const visualStyle = mapObject(style, (d) =>
valueOf(d, abstractDatum, I, abstractData, { channel }),
);
// Try get shape from mark first, then from library.
const shapeFunction = shapeLibrary[shape]
? shapeLibrary[shape](visualStyle, shapeContext)
: useShape(
{ ...visualStyle, type: shapeName(mark, shape) },
shapeContext,
);
const defaults = getDefaultsStyle(theme, markType, shape, defaultShape);
return shapeFunction(points, value, defaults, point2d);
};
}
function getDefaultsStyle(
theme: G2Theme,
mark: string | MarkComponent,
shape: string,
defaultShape: string,
) {
if (typeof mark !== 'string') return;
const { color } = theme;
const markTheme = theme[mark] || {};
const shapeTheme = markTheme[shape] || markTheme[defaultShape];
return Object.assign({ color }, shapeTheme);
}
function createAnimationFunction(
type: 'enter' | 'exit' | 'update',
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
library: G2Library,
): (
data: Record<string, any>,
from: DisplayObject[],
to: DisplayObject[],
) => GAnimation[] {
const [, createShape] = useLibrary<G2ShapeOptions, ShapeComponent, Shape>(
'shape',
library,
);
const [useAnimation] = useLibrary<
G2AnimationOptions,
AnimationComponent,
Animation
>('animation', library);
const { defaultShape, shape: shapeLibrary } = state;
const { theme, coordinate } = view;
const upperType = upperFirst(type) as 'Enter' | 'Exit' | 'Update';
const key:
| 'defaultEnterAnimation'
| 'defaultExitAnimation'
| 'defaultUpdateAnimation' = `default${upperType}Animation`;
// Get shape from mark first, then from library.
const { [key]: defaultAnimation } =
shapeLibrary[defaultShape]?.props ||
createShape(shapeName(mark, defaultShape)).props;
const { [type]: defaultEffectTiming = {} } = theme;
const animate = mark.animate?.[type] || {};
const context = { coordinate };
return (data, from, to) => {
const {
[`${type}Type`]: animation,
[`${type}Delay`]: delay,
[`${type}Duration`]: duration,
[`${type}Easing`]: easing,
} = data;
const options = {
type: animation || defaultAnimation,
...animate,
};
if (!options.type) return null;
const animateFunction = useAnimation(options, context);
const value = { delay, duration, easing };
const A = animateFunction(from, to, deepMix(defaultEffectTiming, value));
if (!Array.isArray(A)) return [A];
return A;
};
}
function createEnterFunction(
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
library: G2Library,
): (
data: Record<string, any>,
from?: DisplayObject[],
to?: DisplayObject[],
) => GAnimation[] {
return createAnimationFunction('enter', mark, state, view, library);
}
/**
* Animation will not cancel automatically, it should be canceled
* manually. This is very important for performance.
*/
function cancel(animation: GAnimation): GAnimation {
animation.finished.then(() => {
animation.cancel();
});
return animation;
}
function createUpdateFunction(
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
library: G2Library,
): (
data: Record<string, any>,
from?: DisplayObject[],
to?: DisplayObject[],
) => GAnimation[] {
return createAnimationFunction('update', mark, state, view, library);
}
function createExitFunction(
mark: G2Mark,
state: G2MarkState,
view: G2ViewDescriptor,
library: G2Library,
): (
data: Record<string, any>,
from?: DisplayObject[],
to?: DisplayObject[],
) => GAnimation[] {
return createAnimationFunction('exit', mark, state, view, library);
}
function inferTheme(theme: G2ThemeOptions = {}): G2ThemeOptions {
if (typeof theme === 'string') return { type: theme };
const { type = 'light', ...rest } = theme;
return { ...rest, type };
}
/**
* @todo Infer builtin tooltips.
*/
function inferInteraction(
view: G2View,
): [string, bo