Skip to content

Commit

Permalink
feat(pie-label): add pie-outer label layout (#2726)
Browse files Browse the repository at this point in the history
* feat(pie-label): add pie-outer-layout

[x] new feature, no breaking change
[x] testcases

* test(pie-label): add normal test cases

* fix(pie-label): 修改 cr 建议

1. 将饼图 label 相关的布局,调整至 `label/layout/pie/*` 下
2. 增加 文档 说明

Co-authored-by: xinming <xinming.lxj@antfin.com>
  • Loading branch information
visiky and xinming committed Aug 4, 2020
1 parent 2262089 commit d94157d
Show file tree
Hide file tree
Showing 7 changed files with 893 additions and 5 deletions.
1 change: 1 addition & 0 deletions docs/api/geometry/geometry.en.md
Expand Up @@ -545,6 +545,7 @@ label(
* 1. overlap: label 防遮挡,为了防止 label 之间相互覆盖,通过尝试向**四周偏移**来剔除放不下的 label。
* 2. fixed-overlap: 不改变 label 位置的情况下对相互重叠的 label 进行调整。
* 3. limit-in-shape: 剔除 shape 容纳不了的 label。
* 4. pie-outer: 饼图 label 防遮挡,为了防止 label 之间相互覆盖,在一定的程度上对相互重叠的 label 进行调整。
*
* @example
* ```ts
Expand Down
3 changes: 2 additions & 1 deletion docs/api/geometry/geometry.zh.md
Expand Up @@ -541,10 +541,11 @@ label(
/**
* 文本布局类型,支持多种布局函数组合使用。
*
* 目前提供了三种:'overlap','fixedOverlap','limitInShape':
* 目前提供了四种:'overlap','fixedOverlap','limitInShape','pie-outer':
* 1. overlap: label 防遮挡,为了防止 label 之间相互覆盖,通过尝试向**四周偏移**来剔除放不下的 label。
* 2. fixed-overlap: 不改变 label 位置的情况下对相互重叠的 label 进行调整。
* 3. limit-in-shape: 剔除 shape 容纳不了的 label。
* 4. pie-outer: 饼图 label 防遮挡,为了防止 label 之间相互覆盖,在一定的程度上对相互重叠的 label 进行调整。
*
* @example
* ```ts
Expand Down
@@ -1,9 +1,9 @@
import { isObject, each, find, get } from '@antv/util';

import { BBox, IGroup, IShape } from '../../../dependents';
import { LabelItem } from '../interface';
import { BBox, IGroup, IShape } from '../../../../dependents';
import { LabelItem } from '../../interface';

import { polarToCartesian } from '../../../util/graphics';
import { polarToCartesian } from '../../../../util/graphics';
import { IElement } from '@antv/g-base';

/** label text和line距离 4px */
Expand Down
334 changes: 334 additions & 0 deletions src/geometry/label/layout/pie/outer.ts
@@ -0,0 +1,334 @@
import { Coordinate } from '@antv/coord';
import { BBox, IGroup, IShape, IElement } from '@antv/g-base';
import { isObject, each, find, get } from '@antv/util';
import { Point } from '../../../../interface';
import { polarToCartesian } from '../../../../util/graphics';
import { LabelItem } from '../../interface';

/** label text和line距离 4px */
const MARGIN = 4;

function antiCollision(
labelShapes: IGroup[],
labels: LabelItem[],
labelHeight: number,
plotRange,
center: Point,
radius: number,
isRight
) {
// sorted by y, mutable
labels.sort((a, b) => a.y - b.y);

// adjust y position of labels to avoid overlapping
const start = plotRange.start;
const end = plotRange.end;
const startY = Math.min(start.y, end.y);
const endY = Math.max(start.y, end.y);
let i;

const boxes = labels.map((label) => {
return {
content: label.content,
size: labelHeight,
pos: label.y,
targets: [label.y],
};
});

const maxPos = Math.max(...boxes.map((b) => b.pos));
const minPos = Math.min(...boxes.map((b) => b.pos));
/**
* when in right, shift from up to down
*/
if (isRight) {
const minY = Math.min(minPos, endY - (boxes.length - 1) * labelHeight);
const maxY = Math.max(minY + boxes.length * labelHeight, maxPos + labelHeight);
let overlapping = true;
while (overlapping) {
// detect overlapping and join boxes
overlapping = false;
i = boxes.length;
while (i--) {
if (i > 0) {
const previousBox = boxes[i - 1];
const box = boxes[i];
// overlap
if (previousBox.pos + previousBox.size > box.pos) {
if (box.pos + i * labelHeight < maxY) {
// join boxes
previousBox.size += box.size;
previousBox.targets = previousBox.targets.concat(box.targets);
// removing box
boxes.splice(i, 1);
} else {
previousBox.pos = box.pos - previousBox.size;
}
overlapping = true;
}
}
}
}
} else {
const maxY = Math.max(startY + (boxes.length - 1) * labelHeight, maxPos);
const minY = Math.min(minPos, maxY - (boxes.length - 1) * labelHeight);
let overlapping = true;
while (overlapping) {
// detect overlapping and join boxes
overlapping = false;
i = boxes.length;
while (i--) {
if (i > 0) {
const previousBox = boxes[i - 1];
const box = boxes[i];
// overlap
if (previousBox.pos + previousBox.size > box.pos) {
if (previousBox.pos - minY > i * labelHeight) {
previousBox.pos -= previousBox.size;
} else {
// join boxes
previousBox.size += box.size;
previousBox.targets = previousBox.targets.concat(box.targets);
// removing box
boxes.splice(i, 1);
}
overlapping = true;
}
}
}
}
}

// step 4: normalize y and adjust x
i = 0;
boxes.forEach((b) => {
let posInCompositeBox = b.pos;
b.targets.forEach(() => {
labels[i].y = posInCompositeBox;
posInCompositeBox += labelHeight;
i++;
});
});

const labelsMap = {};
for (const labelShape of labelShapes) {
labelsMap[labelShape.get('id')] = labelShape;
}

// (x - cx)^2 + (y - cy)^2 = totalR^2
let totalR = (Math.max(...labels.map((l) => l.y)) - Math.min(...labels.map((l) => l.y))) / 2;
totalR = Math.max(totalR, radius);
labels.forEach((label) => {
const labelShape = labelsMap[label.id];

// because group could not effect text-shape, should set text-shape position manually
const textShape = labelShape.find(child => child.get('type') === 'text') as IElement;

// textShape 发生过调整
if (textShape && textShape.attr('y') !== label.y) {
const rPow2 = totalR * totalR;
const dyPow2 = Math.pow(Math.abs(label.y - center.y), 2);
if (rPow2 < dyPow2) {
label.x = center.x;
} else {
const dx = Math.sqrt(rPow2 - dyPow2);
if (!isRight) {
// left
label.x = center.x - dx;
} else {
// right
label.x = center.x + dx;
}
}
}

// adjust labelShape
labelShape.attr('x', label.x);
labelShape.attr('y', label.y);

// @ts-ignore
if (textShape) {
textShape.attr('y', label.y);
textShape.attr('x', label.x);
}
});
}

export function pieOuterLabelLayout(items: LabelItem[], labels: IGroup[], shapes: IShape[] | IGroup[], region: BBox) {
const offset = items[0] ? items[0].offset : 0;
const coordinate: Coordinate = labels[0].get('coordinate');
const radius = coordinate.getRadius();
const center = coordinate.getCenter();

if (offset > 0) {
// note labelHeight 可以控制 label 的行高
const lineHeight: number = get(items[0], 'labelHeight', 14);
const totalR = radius + offset;
const totalHeight = totalR * 2 + lineHeight * 2;
const plotRange = {
start: coordinate.start,
end: coordinate.end,
};

// step 1: separate labels
const halves: LabelItem[][] = [
[], // left
[], // right
];
items.forEach((labelItem) => {
if (!labelItem) {
return;
}
if (labelItem.x < center.x) {
// left
halves[0].push(labelItem);
} else {
// right or center will be put on the right side
halves[1].push(labelItem);
}
});

halves.forEach((half, index) => {
// step 2: reduce labels
const maxLabelsCountForOneSide = totalHeight / lineHeight;
if (half.length > maxLabelsCountForOneSide) {
half.sort((a, b) => {
// sort by percentage DESC
// fixme-xinming 目前还获取不到,需要使用 scale 去获取 percent
return b['data.percent'] - a['data.percent'];
});

const hidden = half.splice(maxLabelsCountForOneSide, half.length - maxLabelsCountForOneSide + 1);
hidden.forEach((l) => {
const idx = labels.findIndex((item) => item.get('id') === l.id);
if (labels[idx]) {
labels[idx].remove(true);
// 同时移除
labels.splice(idx, 1);
}
});
}
antiCollision(labels, half, lineHeight, plotRange, center, totalR, index === 1);
});
}

// 配置 labelLine
each(items, (item) => {
if (item && item.labelLine) {
const { angle } = item;
// 贴近圆周
const startPoint = polarToCartesian(center.x, center.y, radius, angle);
const itemX = item.x + get(item, 'offsetX', 0) * (Math.cos(angle) > 0 ? 1 : -1);
const itemY = item.y + get(item, 'offsetY', 0) * (Math.sin(angle) > 0 ? 1 : -1);

const endPoint = {
x: itemX - Math.cos(angle) * MARGIN,
y: itemY - Math.sin(angle) * MARGIN,
};

const smoothConnector = item.labelLine.smooth;
const path = [];
const dx = endPoint.x - center.x;
const dy = endPoint.y - center.y;
let endAngle = Math.atan(dy / dx);
// 第三象限 & 第四象限
if (dx < 0) {
endAngle += Math.PI;
}

// 默认 smooth, undefined 也为 smooth
if (smoothConnector === false) {
if (!isObject(item.labelLine)) {
// labelLine: true
item.labelLine = {};
}

// 表示弧线的方向,0 表示从起点到终点沿逆时针画弧, 1 表示顺时针
let sweepFlag = 0;

// 第一象限
if ((angle < 0 && angle > -Math.PI / 2) || angle > Math.PI * 1.5) {
if (endPoint.y > startPoint.y) {
sweepFlag = 1;
}
}

// 第二象限
if (angle >= 0 && angle < Math.PI / 2) {
if (endPoint.y > startPoint.y) {
sweepFlag = 1;
}
}

// 第三象限
if (angle >= Math.PI / 2 && angle < Math.PI) {
if (startPoint.y > endPoint.y) {
sweepFlag = 1;
}
}

// 第四象限
if (angle < -Math.PI / 2 || (angle >= Math.PI && angle < Math.PI * 1.5)) {
if (startPoint.y > endPoint.y) {
sweepFlag = 1;
}
}

const distance = offset / 2 > 4 ? 4 : Math.max(offset / 2 - 1, 0);
const breakPoint = polarToCartesian(center.x, center.y, radius + distance, angle);
// 圆弧的结束点
const breakPoint3 = polarToCartesian(center.x, center.y, radius + offset / 2, endAngle);

/**
* @example
* M 100 100 L100 90 A 50 50 0 0 0 150 50
* 移动至 (100, 100), 连接到 (100, 90), 以 (50, 50) 为圆心,绘制圆弧至 (150, 50);
* A 命令的第 4 个参数 large-arc-flag, 决定弧线是大于还是小于 180 度: 0 表示小角度弧,1 表示大角
* 第 5 个参数: 是否顺时针绘制
*/
// 默认小弧
const largeArcFlag = 0;
// step1: 移动至起点
path.push(`M ${startPoint.x} ${startPoint.y}`);
// step2: 连接拐点
path.push(`L ${breakPoint.x} ${breakPoint.y}`);
// step3: 绘制圆弧 至 结束点
path.push(`A ${center.x} ${center.y} 0 ${largeArcFlag} ${sweepFlag} ${breakPoint3.x} ${breakPoint3.y}`);
// step4: 连接结束点
path.push(`L ${endPoint.x} ${endPoint.y}`);
} else {
const breakPoint = polarToCartesian(
center.x,
center.y,
radius + (offset / 2 > 4 ? 4 : Math.max(offset / 2 - 1, 0)),
angle
);
// G2 旧的拉线
// path.push('Q', `${breakPoint.x}`, `${breakPoint.y}`, `${endPoint.x}`, `${endPoint.y}`);
const xSign = startPoint.x < center.x ? 1 : -1;
// step1: 连接结束点
path.push(`M ${endPoint.x} ${endPoint.y}`);
const slope1 = (startPoint.y - center.y) / (startPoint.x - center.x);
const slope2 = (endPoint.y - center.y) / (endPoint.x - center.x);
if (Math.abs(slope1 - slope2) > Math.pow(Math.E, -16)) {
// step2: 绘制 curve line (起点 & 结合点与圆心的斜率不等时, 由于存在误差, 使用近似处理)
path.push(
...[
'C',
endPoint.x + xSign * 4,
endPoint.y,
2 * breakPoint.x - startPoint.x,
2 * breakPoint.y - startPoint.y,
startPoint.x,
startPoint.y,
]
);
}
// step3: 连接至起点
path.push(`L ${startPoint.x} ${startPoint.y}`);
}

item.labelLine.path = path.join(' ');
}
});
}
4 changes: 3 additions & 1 deletion src/index.ts
Expand Up @@ -78,7 +78,8 @@ registerGeometryLabel('polar', PolarLabel);

// 注册 Geometry label 内置的布局函数
import { registerGeometryLabelLayout } from './core';
import { distribute } from './geometry/label/layout/distribute';
import { distribute } from './geometry/label/layout/pie/distribute';
import { pieOuterLabelLayout } from './geometry/label/layout/pie/outer';
import { limitInCanvas } from './geometry/label/layout/limit-in-canvas';
import { limitInShape } from './geometry/label/layout/limit-in-shape';
import { fixedOverlap, overlap } from './geometry/label/layout/overlap';
Expand All @@ -88,6 +89,7 @@ registerGeometryLabelLayout('distribute', distribute);
registerGeometryLabelLayout('fixed-overlap', fixedOverlap);
registerGeometryLabelLayout('limit-in-shape', limitInShape);
registerGeometryLabelLayout('limit-in-canvas', limitInCanvas);
registerGeometryLabelLayout('pie-outer', pieOuterLabelLayout);

// 注册需要的动画执行函数
import { fadeIn, fadeOut } from './animate/animation/fade';
Expand Down

0 comments on commit d94157d

Please sign in to comment.