@antv/g2
Version:
the Grammar of Graphics in Javascript
906 lines (839 loc) • 23.3 kB
text/typescript
import { Coordinate } from '@antv/coord';
import { ascending, group, max, min, sum } from 'd3-array';
import { deepMix } from '@antv/util';
import { isParallel, isPolar, isRadar, radiusOf } from '../utils/coordinate';
import { capitalizeFirst, defined } from '../utils/helper';
import { divide } from '../utils/array';
import { camelCase } from '../utils/string';
import {
GuideComponentPosition as GCP,
GuideComponentOrientation as GCO,
Layout,
Section,
SectionArea,
G2Theme,
GuideComponentPlane,
} from './types/common';
import {
computeComponentSize,
computeLabelsBBox,
computeTitleBBox,
createScale,
groupComponents,
styleOf,
} from './component';
import { G2GuideComponentOptions, G2Library, G2View } from './types/options';
import {
isPolar as isPolarOptions,
isRadial as isRadarOptions,
} from './coordinate';
export function processAxisZ(components: G2GuideComponentOptions[]) {
const axisX = components.find(({ type }) => type === 'axisX');
const axisY = components.find(({ type }) => type === 'axisY');
const axisZ = components.find(({ type }) => type === 'axisZ');
if (axisX && axisY && axisZ) {
axisX.plane = 'xy';
axisY.plane = 'xy';
axisZ.plane = 'yz';
axisZ.origin = [axisX.bbox.x, axisX.bbox.y, 0];
axisZ.eulerAngles = [0, -90, 0];
axisZ.bbox.x = axisX.bbox.x;
axisZ.bbox.y = axisX.bbox.y;
components.push({
...axisX,
plane: 'xz',
showLabel: false,
showTitle: false,
origin: [axisX.bbox.x, axisX.bbox.y, 0],
eulerAngles: [-90, 0, 0],
});
components.push({
...axisY,
plane: 'yz',
showLabel: false,
showTitle: false,
origin: [axisY.bbox.x + axisY.bbox.width, axisY.bbox.y, 0],
eulerAngles: [0, -90, 0],
});
components.push({
...axisZ,
plane: 'xz',
actualPosition: 'left',
showLabel: false,
showTitle: false,
eulerAngles: [90, -90, 0],
});
}
}
export function computeLayout(
components: G2GuideComponentOptions[],
options: G2View,
theme: G2Theme,
library: G2Library,
): Layout {
const {
width,
height,
depth,
x = 0,
y = 0,
z = 0,
inset = theme.inset ?? 0,
insetLeft = inset,
insetTop = inset,
insetBottom = inset,
insetRight = inset,
margin = theme.margin ?? 0,
marginLeft = margin,
marginBottom = margin,
marginTop = margin,
marginRight = margin,
padding = theme.padding,
paddingBottom = padding,
paddingLeft = padding,
paddingRight = padding,
paddingTop = padding,
} = computeInset(components, options, theme, library);
const MIN_CONTENT_RATIO = 1 / 4;
const maybeClamp = (viewWidth, paddingLeft, paddingRight, pl0, pr0) => {
// Only clamp when has marks.
const { marks } = options;
if (marks.length === 0) return [pl0, pr0];
// If size of content is enough, skip.
const contentSize = viewWidth - pl0 - pr0;
const diff = contentSize - viewWidth * MIN_CONTENT_RATIO;
if (diff > 0) return [pl0, pr0];
// Shrink start and end size equally.
const shrinkSize = viewWidth * (1 - MIN_CONTENT_RATIO);
return [
paddingLeft === 'auto' ? (shrinkSize * pl0) / (pl0 + pr0) : pl0,
paddingRight === 'auto' ? (shrinkSize * pr0) / (pl0 + pr0) : pr0,
];
};
const roughPadding = (padding) => (padding === 'auto' ? 20 : padding ?? 20);
const rpt = roughPadding(paddingTop);
const rpb = roughPadding(paddingBottom);
// Compute paddingLeft and paddingRight first to get innerWidth.
const horizontalPadding = computePadding(
components,
height - rpt - rpb,
[rpt + marginTop, rpb + marginBottom],
['left', 'right'],
options,
theme,
library,
);
const { paddingLeft: pl0, paddingRight: pr0 } = horizontalPadding;
const viewWidth = width - marginLeft - marginRight;
const [pl, pr] = maybeClamp(viewWidth, paddingLeft, paddingRight, pl0, pr0);
const iw = viewWidth - pl - pr;
// Compute paddingBottom and paddingTop based on innerWidth.
const verticalPadding = computePadding(
components,
iw,
[pl + marginLeft, pr + marginRight],
['bottom', 'top'],
options,
theme,
library,
);
const { paddingTop: pt0, paddingBottom: pb0 } = verticalPadding;
const viewHeight = height - marginBottom - marginTop;
const [pb, pt] = maybeClamp(viewHeight, paddingBottom, paddingTop, pb0, pt0);
const ih = viewHeight - pb - pt;
return {
width,
height,
depth,
insetLeft,
insetTop,
insetBottom,
insetRight,
innerWidth: iw,
innerHeight: ih,
paddingLeft: pl,
paddingRight: pr,
paddingTop: pt,
paddingBottom: pb,
marginLeft,
marginBottom,
marginTop,
marginRight,
x,
y,
z,
};
}
// For composite mark with a layout algorithm and without axis,
// such as worldcloud, circlepack.
export function computeRoughPlotSize(options: G2View) {
const {
height,
width,
padding = 0,
paddingLeft = padding,
paddingRight = padding,
paddingTop = padding,
paddingBottom = padding,
margin = 16,
marginLeft = margin,
marginRight = margin,
marginTop = margin,
marginBottom = margin,
inset = 0,
insetLeft = inset,
insetRight = inset,
insetTop = inset,
insetBottom = inset,
} = options;
// @todo Add this padding to theme.
// 30 is default size for padding, which defined in runtime.
const maybeAuto = (padding) => (padding === 'auto' ? 20 : padding);
const finalWidth =
width -
maybeAuto(paddingLeft) -
maybeAuto(paddingRight) -
marginLeft -
marginRight -
insetLeft -
insetRight;
const finalHeight =
height -
maybeAuto(paddingTop) -
maybeAuto(paddingBottom) -
marginTop -
marginBottom -
insetTop -
insetBottom;
return { width: finalWidth, height: finalHeight };
}
function computeInset(
components: G2GuideComponentOptions[],
options: G2View,
theme: G2Theme,
library: G2Library,
) {
const { coordinates } = options;
if (!isPolarOptions(coordinates) && !isRadarOptions(coordinates)) {
return options;
}
// Filter axis.
const axes = components.filter(
(d) => typeof d.type === 'string' && d.type.startsWith('axis'),
);
if (axes.length === 0) return options;
const styles = axes.map((component) => {
const key = component.type === 'axisArc' ? 'arc' : 'linear';
return styleOf(component, key as any, theme);
});
// Compute max labelSpacing.
const maxLabelSpacing = max(styles, (d) => d.labelSpacing ?? 0);
// Compute labelBBoxes.
const labelBBoxes = axes
.flatMap((component, i) => {
const style = styles[i];
const scale = createScale(component, library);
const labels = computeLabelsBBox(style, scale);
return labels;
})
.filter(defined);
const size = max(labelBBoxes, (d) => d.height) + maxLabelSpacing;
// Compute titles.
const titleBBoxes = axes
.flatMap((_, i) => {
const style = styles[i];
return computeTitleBBox(style);
})
.filter((d) => d !== null);
const titleSize =
titleBBoxes.length === 0 ? 0 : max(titleBBoxes, (d) => d.height);
// Update inset.
const {
inset = size,
insetLeft = inset,
insetBottom = inset,
insetTop = inset + titleSize,
insetRight = inset,
} = options;
return { ...options, insetLeft, insetBottom, insetTop, insetRight };
}
/**
* @todo Support percentage size(e.g. 50%)
*/
function computePadding(
components: G2GuideComponentOptions[],
crossSize: number,
crossPadding: [number, number],
positions: GCP[],
options: G2View,
theme: G2Theme,
library: G2Library,
) {
const positionComponents = group(components, (d) => d.position);
const {
padding = theme.padding,
paddingLeft = padding,
paddingRight = padding,
paddingBottom = padding,
paddingTop = padding,
} = options;
const layout = {
paddingBottom,
paddingLeft,
paddingTop,
paddingRight,
};
for (const position of positions) {
const key = `padding${capitalizeFirst(camelCase(position))}`;
const components = positionComponents.get(position) || [];
const value = layout[key];
const defaultSizeOf = (d) => {
if (d.size === undefined) d.size = d.defaultSize;
};
const sizeOf = (d) => {
if (d.type === 'group') {
d.children.forEach(defaultSizeOf);
d.size = max(d.children, (d) => (d as any).size);
} else {
d.size = d.defaultSize;
}
};
const autoSizeOf = (d) => {
if (d.size) return;
if (value !== 'auto') sizeOf(d);
else {
// Compute component size dynamically.
computeComponentSize(
d,
crossSize,
crossPadding,
position,
theme,
library,
);
defaultSizeOf(d);
}
};
const maybeHide = (d) => {
if (!d.type.startsWith('axis')) return;
if (d.labelAutoHide === undefined) d.labelAutoHide = true;
};
const isHorizontal = position === 'bottom' || position === 'top';
// !!!Note
// Mute axis component padding.
// The first axis do not has padding.
const minOrder = min(components, (d) => d.order);
const axes = components.filter(
(d) => (d.type as string).startsWith('axis') && d.order == minOrder,
);
if (axes.length) axes[0].crossPadding = 0;
// Specified padding.
if (typeof value === 'number') {
components.forEach(defaultSizeOf);
components.forEach(maybeHide);
} else {
// Compute padding dynamically.
if (components.length === 0) {
layout[key] = 0;
} else {
const size = isHorizontal
? crossSize + crossPadding[0] + crossPadding[1]
: crossSize;
const grouped = groupComponents(components, size);
grouped.forEach(autoSizeOf);
const totalSize = grouped.reduce(
(sum, { size, crossPadding = 12 }) => sum + size + crossPadding,
0,
);
layout[key] = totalSize;
}
}
}
return layout;
}
export function placeComponents(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
layout: Layout,
): void {
// Group components by plane & position.
const positionComponents = group<G2GuideComponentOptions, string>(
components,
(d) => `${d.plane || 'xy'}-${d.position}`,
);
const {
paddingLeft,
paddingRight,
paddingTop,
paddingBottom,
marginLeft,
marginTop,
marginBottom,
marginRight,
innerHeight,
innerWidth,
insetBottom,
insetLeft,
insetRight,
insetTop,
height,
width,
depth,
} = layout;
const planes = {
xy: createSection({
width,
height,
paddingLeft,
paddingRight,
paddingTop,
paddingBottom,
marginLeft,
marginTop,
marginBottom,
marginRight,
innerHeight,
innerWidth,
insetBottom,
insetLeft,
insetRight,
insetTop,
}),
yz: createSection({
width: depth,
height: height,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
marginLeft: 0,
marginTop: 0,
marginBottom: 0,
marginRight: 0,
innerWidth: depth,
innerHeight: height,
insetBottom: 0,
insetLeft: 0,
insetRight: 0,
insetTop: 0,
}),
xz: createSection({
width,
height: depth,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
marginLeft: 0,
marginTop: 0,
marginBottom: 0,
marginRight: 0,
innerWidth: width,
innerHeight: depth,
insetBottom: 0,
insetLeft: 0,
insetRight: 0,
insetTop: 0,
}),
};
for (const [key, components] of positionComponents.entries()) {
const [plane, position] = key.split('-') as [GuideComponentPlane, GCP];
const area = planes[plane][position];
/**
* @description non-entity components: axis in the center, inner, outer, component in the center
* @description entity components: other components
* @description no volume components take up no extra space
*/
const [nonEntityComponents, entityComponents] = divide(
components,
(component) => {
if (typeof component.type !== 'string') return false;
if (position === 'center') return true;
if (
component.type.startsWith('axis') &&
['inner', 'outer'].includes(position)
) {
return true;
}
return false;
},
);
if (nonEntityComponents.length) {
placeNonEntityComponents(nonEntityComponents, coordinate, area, position);
}
if (entityComponents.length) {
placePaddingArea(components, coordinate, area);
}
}
}
function createSection({
width,
height,
paddingLeft,
paddingRight,
paddingTop,
paddingBottom,
marginLeft,
marginTop,
marginBottom,
marginRight,
innerHeight,
innerWidth,
insetBottom,
insetLeft,
insetRight,
insetTop,
}: {
width: number;
height: number;
paddingLeft: number;
paddingRight: number;
paddingTop: number;
paddingBottom: number;
marginLeft: number;
marginTop: number;
marginBottom: number;
marginRight: number;
innerHeight: number;
innerWidth: number;
insetBottom: number;
insetLeft: number;
insetRight: number;
insetTop: number;
}): Section {
const pl = paddingLeft + marginLeft;
const pt = paddingTop + marginTop;
const pr = paddingRight + marginRight;
const pb = paddingBottom + marginBottom;
const plotWidth = width - marginLeft - marginRight;
const centerSection: SectionArea = [
pl + insetLeft,
pt + insetTop,
innerWidth - insetLeft - insetRight,
innerHeight - insetTop - insetBottom,
'center',
null,
null,
];
const xySection: Section = {
top: [
pl,
0,
innerWidth,
pt,
'vertical',
true,
ascending,
marginLeft,
plotWidth,
],
right: [width - pr, pt, pr, innerHeight, 'horizontal', false, ascending],
bottom: [
pl,
height - pb,
innerWidth,
pb,
'vertical',
false,
ascending,
marginLeft,
plotWidth,
],
left: [0, pt, pl, innerHeight, 'horizontal', true, ascending],
'top-left': [pl, 0, innerWidth, pt, 'vertical', true, ascending],
'top-right': [pl, 0, innerWidth, pt, 'vertical', true, ascending],
'bottom-left': [
pl,
height - pb,
innerWidth,
pb,
'vertical',
false,
ascending,
],
'bottom-right': [
pl,
height - pb,
innerWidth,
pb,
'vertical',
false,
ascending,
],
center: centerSection,
inner: centerSection,
outer: centerSection,
};
return xySection;
}
function placeNonEntityComponents(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
position: GCP,
) {
const [axisComponents, nonAxisComponents] = divide(
components,
(component) => {
if (
typeof component.type === 'string' &&
component.type.startsWith('axis')
) {
return true;
}
return false;
},
);
placeNonEntityAxis(axisComponents, coordinate, area, position);
// in current stage, only legend component which located in the center can be placed
placeCenter(nonAxisComponents, coordinate, area);
}
function placeNonEntityAxis(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
position: GCP,
) {
if (position === 'center') {
if (isRadar(coordinate)) {
placeAxisRadar(components, coordinate, area, position);
} else if (isPolar(coordinate)) {
placeArcLinear(components, coordinate, area);
} else if (isParallel(coordinate)) {
placeAxisParallel(
components,
coordinate,
area,
components[0].orientation, // assume that all components have the same orientation
);
}
} else if (position === 'inner') {
placeAxisArcInner(components, coordinate, area);
} else if (position === 'outer') {
placeAxisArcOuter(components, coordinate, area);
}
}
function placeAxisArcInner(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
) {
const [x, y, , height] = area;
const [cx, cy] = coordinate.getCenter();
const [innerRadius] = radiusOf(coordinate);
const r = height / 2;
const size = innerRadius * r;
const x0 = cx - size;
const y0 = cy - size;
for (let i = 0; i < components.length; i++) {
const component = components[i];
component.bbox = {
x: x + x0,
y: y + y0,
width: size * 2,
height: size * 2,
};
}
}
function placeAxisArcOuter(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
) {
const [x, y, width, height] = area;
for (const component of components) {
component.bbox = { x, y, width, height };
}
}
/**
* @example arcX, arcY, axisLinear with angle
*/
function placeArcLinear(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
) {
const [x, y, width, height] = area;
for (const component of components) {
component.bbox = { x: x, y, width, height };
}
}
function placeAxisParallel(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
orientation: GCO,
) {
if (orientation === 'horizontal') {
placeAxisParallelHorizontal(components, coordinate, area);
} else if (orientation === 'vertical') {
placeAxisParallelVertical(components, coordinate, area);
}
}
function placeAxisParallelVertical(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
): void {
const [x, y, , height] = area;
// Create a high dimension vector and map to a list of two-dimension points.
// [0, 0, 0] -> [x0, 0, x1, 0, x2, 0]
const vector = new Array(components.length).fill(0);
const points = coordinate.map(vector);
// Extract x of each points.
// [x0, 0, x1, 0, x2, 0] -> [x0, x1, x2]
const X = points.filter((_, i) => i % 2 === 0).map((d) => d + x);
// Place each axis by coordinate in parallel coordinate.
for (let i = 0; i < components.length; i++) {
const component = components[i];
const x = X[i];
const width = X[i + 1] - x;
component.bbox = { x, y, width, height };
}
}
function placeAxisParallelHorizontal(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
): void {
const [x, y, width] = area;
// Create a high dimension vector and map to a list of two-dimension points.
// [0, 0, 0] -> [height, y0, height, y1, height, y2]
const vector = new Array(components.length).fill(0);
const points = coordinate.map(vector);
// Extract y of each points.
// [x0, 0, x1, 0, x2, 0] -> [x0, x1, x2]
const Y = points.filter((_, i) => i % 2 === 1).map((d) => d + y);
// Place each axis by coordinate in parallel coordinate.
for (let i = 0; i < components.length; i++) {
const component = components[i];
const y = Y[i];
const height = Y[i + 1] - y;
component.bbox = { x, y, width, height };
}
}
function placeAxisRadar(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
position: GCP,
) {
const [x, y, width, height] = area;
for (const component of components) {
component.bbox = { x, y, width, height };
component.radar = {
index: components.indexOf(component),
count: components.length,
};
}
}
function placePaddingArea(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
) {
const [x, y, width, height, direction, reverse, comparator, minX, totalSize] =
area;
const [
mainStartKey,
mainStartValue,
crossStartKey,
crossStartValue,
mainSizeKey,
mainSizeValue,
crossSizeKey,
crossSizeValue,
] =
direction === 'vertical'
? ['y', y, 'x', x, 'height', height, 'width', width]
: ['x', x, 'y', y, 'width', width, 'height', height];
// Sort components by order.
// The smaller the order, the closer to center.
components.sort((a, b) => comparator?.(a.order, b.order));
const isLarge = (type) =>
type === 'title' || type === 'group' || type.startsWith('legend');
const crossSizeOf = (type, small, bigger) => {
if (bigger === undefined) return small;
if (isLarge(type)) return bigger;
return small;
};
const crossStartOf = (type, x, minX) => {
if (minX === undefined) return x;
if (isLarge(type)) return minX;
return x;
};
const startValue = reverse ? mainStartValue + mainSizeValue : mainStartValue;
for (let i = 0, start = startValue; i < components.length; i++) {
const component = components[i];
const { crossPadding = 0, type } = component;
const { size } = component;
component.bbox = {
[mainStartKey]: reverse
? start - size - crossPadding
: start + crossPadding,
[crossStartKey]: crossStartOf(type, crossStartValue, minX),
[mainSizeKey]: size,
[crossSizeKey]: crossSizeOf(type, crossSizeValue, totalSize),
};
start += (size + crossPadding) * (reverse ? -1 : 1);
}
// Place group components.
const groupComponents = components.filter((d) => d.type === 'group');
for (const group of groupComponents) {
const { bbox, children } = group;
const size = bbox[crossSizeKey];
const step = size / children.length;
const justifyContent = children.reduce((j, child) => {
const j0 = child.layout?.justifyContent;
return j0 ? j0 : j;
}, 'flex-start');
const L = children.map((d, i) => {
const { length = step, padding = 0 } = d;
return length + (i === children.length - 1 ? 0 : padding);
});
const totalLength = sum(L);
const diff = size - totalLength;
const offset =
justifyContent === 'flex-start'
? 0
: justifyContent === 'center'
? diff / 2
: diff;
for (
let i = 0, start = bbox[crossStartKey] + offset;
i < children.length;
i++
) {
const component = children[i];
const { padding = 0 } = component;
const interval = i === children.length - 1 ? 0 : padding;
component.bbox = {
[mainSizeKey]: bbox[mainSizeKey],
[mainStartKey]: bbox[mainStartKey],
[crossStartKey]: start,
[crossSizeKey]: L[i] - interval,
};
deepMix(component, { layout: { justifyContent } });
start += L[i];
}
}
}
/**
* @example legend in the center of radial or polar system
*/
function placeCenter(
components: G2GuideComponentOptions[],
coordinate: Coordinate,
area: SectionArea,
) {
if (components.length === 0) return;
const [x, y, width, height] = area;
const [innerRadius] = radiusOf(coordinate);
const r = ((height / 2) * innerRadius) / Math.sqrt(2);
const cx = x + width / 2;
const cy = y + height / 2;
for (let i = 0; i < components.length; i++) {
const component = components[i];
component.bbox = { x: cx - r, y: cy - r, width: r * 2, height: r * 2 };
}
}