@antv/infographic 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/infographic.min.js +98 -94
- package/dist/infographic.min.js.map +1 -1
- package/esm/designs/structures/chart-pie.d.ts +25 -0
- package/esm/designs/structures/chart-pie.js +182 -23
- package/esm/designs/utils/index.d.ts +1 -0
- package/esm/designs/utils/index.js +1 -0
- package/esm/designs/utils/normalize-percent.d.ts +19 -0
- package/esm/designs/utils/normalize-percent.js +32 -0
- package/esm/editor/interactions/zoom-wheel.d.ts +3 -0
- package/esm/editor/interactions/zoom-wheel.js +46 -23
- package/esm/editor/managers/state.js +8 -2
- package/esm/templates/built-in.js +3 -77
- package/esm/templates/chart-pie.d.ts +2 -0
- package/esm/templates/chart-pie.js +87 -0
- package/esm/utils/viewbox.d.ts +20 -0
- package/esm/utils/viewbox.js +10 -0
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/lib/designs/structures/chart-pie.d.ts +25 -0
- package/lib/designs/structures/chart-pie.js +182 -22
- package/lib/designs/utils/index.d.ts +1 -0
- package/lib/designs/utils/index.js +1 -0
- package/lib/designs/utils/normalize-percent.d.ts +19 -0
- package/lib/designs/utils/normalize-percent.js +35 -0
- package/lib/editor/interactions/zoom-wheel.d.ts +3 -0
- package/lib/editor/interactions/zoom-wheel.js +45 -22
- package/lib/editor/managers/state.js +8 -2
- package/lib/templates/built-in.js +3 -77
- package/lib/templates/chart-pie.d.ts +2 -0
- package/lib/templates/chart-pie.js +90 -0
- package/lib/utils/viewbox.d.ts +20 -0
- package/lib/utils/viewbox.js +12 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -2
- package/src/designs/structures/chart-pie.tsx +259 -26
- package/src/designs/utils/index.ts +1 -0
- package/src/designs/utils/normalize-percent.ts +33 -0
- package/src/editor/interactions/zoom-wheel.ts +64 -22
- package/src/editor/managers/state.ts +10 -5
- package/src/templates/built-in.ts +2 -81
- package/src/templates/chart-pie.ts +89 -0
- package/src/utils/viewbox.ts +23 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.chartPieTemplates = void 0;
|
|
4
|
+
exports.chartPieTemplates = {
|
|
5
|
+
'chart-pie-plain-text': {
|
|
6
|
+
design: {
|
|
7
|
+
title: 'default',
|
|
8
|
+
structure: {
|
|
9
|
+
type: 'chart-pie',
|
|
10
|
+
},
|
|
11
|
+
items: [
|
|
12
|
+
{
|
|
13
|
+
type: 'plain-text',
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
'chart-pie-compact-card': {
|
|
19
|
+
design: {
|
|
20
|
+
title: 'default',
|
|
21
|
+
structure: {
|
|
22
|
+
type: 'chart-pie',
|
|
23
|
+
avoidLabelOverlap: true,
|
|
24
|
+
},
|
|
25
|
+
items: [
|
|
26
|
+
{
|
|
27
|
+
type: 'compact-card',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
'chart-pie-pill-badge': {
|
|
33
|
+
design: {
|
|
34
|
+
title: 'default',
|
|
35
|
+
structure: {
|
|
36
|
+
type: 'chart-pie',
|
|
37
|
+
avoidLabelOverlap: true,
|
|
38
|
+
},
|
|
39
|
+
items: [
|
|
40
|
+
{
|
|
41
|
+
type: 'pill-badge',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
'chart-pie-donut-plain-text': {
|
|
47
|
+
design: {
|
|
48
|
+
title: 'default',
|
|
49
|
+
structure: {
|
|
50
|
+
type: 'chart-pie',
|
|
51
|
+
innerRadius: 90,
|
|
52
|
+
},
|
|
53
|
+
items: [
|
|
54
|
+
{
|
|
55
|
+
type: 'plain-text',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
'chart-pie-donut-compact-card': {
|
|
61
|
+
design: {
|
|
62
|
+
title: 'default',
|
|
63
|
+
structure: {
|
|
64
|
+
type: 'chart-pie',
|
|
65
|
+
innerRadius: 90,
|
|
66
|
+
avoidLabelOverlap: true,
|
|
67
|
+
},
|
|
68
|
+
items: [
|
|
69
|
+
{
|
|
70
|
+
type: 'compact-card',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
'chart-pie-donut-pill-badge': {
|
|
76
|
+
design: {
|
|
77
|
+
title: 'default',
|
|
78
|
+
structure: {
|
|
79
|
+
type: 'chart-pie',
|
|
80
|
+
innerRadius: 90,
|
|
81
|
+
avoidLabelOverlap: true,
|
|
82
|
+
},
|
|
83
|
+
items: [
|
|
84
|
+
{
|
|
85
|
+
type: 'pill-badge',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
package/lib/utils/viewbox.d.ts
CHANGED
|
@@ -4,3 +4,23 @@ export declare function getViewBox(svg: SVGSVGElement): {
|
|
|
4
4
|
width: number;
|
|
5
5
|
height: number;
|
|
6
6
|
};
|
|
7
|
+
export declare function calculateZoomedViewBox(current: {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}, factor: number, pivot: {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
}): {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function viewBoxToString(box: {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}): string;
|
package/lib/utils/viewbox.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getViewBox = getViewBox;
|
|
4
|
+
exports.calculateZoomedViewBox = calculateZoomedViewBox;
|
|
5
|
+
exports.viewBoxToString = viewBoxToString;
|
|
4
6
|
function getViewBox(svg) {
|
|
5
7
|
const viewBox = svg.getAttribute('viewBox');
|
|
6
8
|
if (viewBox) {
|
|
@@ -13,3 +15,13 @@ function getViewBox(svg) {
|
|
|
13
15
|
const height = Number(heightStr) || 0;
|
|
14
16
|
return { x: 0, y: 0, width, height };
|
|
15
17
|
}
|
|
18
|
+
function calculateZoomedViewBox(current, factor, pivot) {
|
|
19
|
+
const newWidth = current.width * factor;
|
|
20
|
+
const newHeight = current.height * factor;
|
|
21
|
+
const newX = pivot.x - (pivot.x - current.x) * factor;
|
|
22
|
+
const newY = pivot.y - (pivot.y - current.y) * factor;
|
|
23
|
+
return { x: newX, y: newY, width: newWidth, height: newHeight };
|
|
24
|
+
}
|
|
25
|
+
function viewBoxToString(box) {
|
|
26
|
+
return `${box.x} ${box.y} ${box.width} ${box.height}`;
|
|
27
|
+
}
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.2.
|
|
1
|
+
export declare const VERSION = "0.2.13";
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antv/infographic",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "An Infographic Generation and Rendering Framework, bring words to life!",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antv",
|
|
@@ -126,7 +126,6 @@
|
|
|
126
126
|
"@types/node": "^24.3.1",
|
|
127
127
|
"@types/tinycolor2": "^1.4.6",
|
|
128
128
|
"@vitest/coverage-v8": "^3.2.4",
|
|
129
|
-
"any-skills": "^0.1.1",
|
|
130
129
|
"csstype": "^3.2.2",
|
|
131
130
|
"eslint": "^9.35.0",
|
|
132
131
|
"globals": "^16.4.0",
|
|
@@ -5,15 +5,46 @@ import { getElementBounds, Group, Path, Text } from '../../jsx';
|
|
|
5
5
|
import { ItemDatum } from '../../types';
|
|
6
6
|
import { BtnAdd, BtnRemove, BtnsGroup, ItemsGroup } from '../components';
|
|
7
7
|
import { FlexLayout } from '../layouts';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getColorPrimary,
|
|
10
|
+
getPaletteColor,
|
|
11
|
+
getThemeColors,
|
|
12
|
+
normalizePercent,
|
|
13
|
+
} from '../utils';
|
|
9
14
|
import { registerStructure } from './registry';
|
|
10
15
|
import type { BaseStructureProps } from './types';
|
|
11
16
|
|
|
17
|
+
// === 连线布局常量 ===
|
|
18
|
+
/** 连线水平拉伸系数:控制拐点相对于外半径的延伸比例 */
|
|
19
|
+
const EXTENSION_FACTOR = 1.35;
|
|
20
|
+
/** 文本与连线终点之间的水平间距 */
|
|
21
|
+
const TEXT_GAP = 8;
|
|
22
|
+
/** 平滑系数:控制 Y 轴偏移对 X 轴补偿的影响幅度 */
|
|
23
|
+
const SMOOTH_FACTOR = 0.3;
|
|
24
|
+
/** 最大预期偏移系数:相对于外半径的比例 */
|
|
25
|
+
const MAX_EXPECTED_SHIFT_FACTOR = 0.2;
|
|
26
|
+
/** 文本锚点与拐点之间的固定间距 */
|
|
27
|
+
const FIXED_TEXT_RADIUS_GAP = 20;
|
|
28
|
+
/** 连线拐点半径系数:相对于外半径的比例 */
|
|
29
|
+
const ELBOW_RADIUS_FACTOR = 1.15;
|
|
30
|
+
/** 百分比文本位置系数:从内半径到外半径的比例 (0.5 = 中间) */
|
|
31
|
+
const PERCENT_TEXT_POSITION = 0.5;
|
|
32
|
+
/** 删除按钮半径系数:相对于外半径的比例 */
|
|
33
|
+
const DELETE_BUTTON_RADIUS_FACTOR = 0.85;
|
|
34
|
+
/** 添加按钮半径系数:相对于外半径的比例 */
|
|
35
|
+
const ADD_BUTTON_RADIUS_FACTOR = 1.0;
|
|
36
|
+
/** 连线透明度 */
|
|
37
|
+
const CONNECTOR_STROKE_OPACITY = 0.45;
|
|
38
|
+
/** 连线宽度 */
|
|
39
|
+
const CONNECTOR_STROKE_WIDTH = 2;
|
|
40
|
+
|
|
12
41
|
export interface ChartPieProps extends BaseStructureProps {
|
|
13
42
|
radius?: number;
|
|
14
43
|
innerRadius?: number;
|
|
15
44
|
padding?: number;
|
|
16
45
|
showPercentage?: boolean;
|
|
46
|
+
avoidLabelOverlap?: boolean;
|
|
47
|
+
minShowLabelPercent?: number | string;
|
|
17
48
|
}
|
|
18
49
|
|
|
19
50
|
export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
@@ -25,9 +56,14 @@ export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
|
25
56
|
innerRadius = 0,
|
|
26
57
|
padding = 30,
|
|
27
58
|
showPercentage = true,
|
|
59
|
+
avoidLabelOverlap = false,
|
|
60
|
+
minShowLabelPercent: rawMinShowLabelPercent = 0,
|
|
28
61
|
options,
|
|
29
62
|
} = props;
|
|
30
63
|
|
|
64
|
+
// 规范化百分比阈值
|
|
65
|
+
const minShowLabelPercent = normalizePercent(rawMinShowLabelPercent);
|
|
66
|
+
|
|
31
67
|
const { title, desc, items = [] } = data;
|
|
32
68
|
const titleContent = Title ? <Title title={title} desc={desc} /> : null;
|
|
33
69
|
|
|
@@ -51,14 +87,10 @@ export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
|
51
87
|
// 基础半径设置
|
|
52
88
|
const outerRadius = Math.max(radius, 60);
|
|
53
89
|
|
|
54
|
-
// 连线水平拉伸的系数
|
|
55
|
-
const extensionFactor = 1.35;
|
|
56
|
-
const textGap = 8;
|
|
57
|
-
|
|
58
90
|
// 计算画布中心和总尺寸
|
|
59
91
|
// 水平方向:半径 * 系数 + 间距 + 标签宽度 + 边缘padding
|
|
60
92
|
const maxHorizontalDistance =
|
|
61
|
-
outerRadius *
|
|
93
|
+
outerRadius * EXTENSION_FACTOR + TEXT_GAP + labelWidth;
|
|
62
94
|
const maxVerticalDistance = outerRadius;
|
|
63
95
|
|
|
64
96
|
const centerX = padding + maxHorizontalDistance;
|
|
@@ -119,28 +151,29 @@ export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
|
119
151
|
|
|
120
152
|
// 连线拐点
|
|
121
153
|
const outerArc = arc<PieArcDatum<ItemDatum>>()
|
|
122
|
-
.innerRadius(outerRadius *
|
|
123
|
-
.outerRadius(outerRadius *
|
|
154
|
+
.innerRadius(outerRadius * ELBOW_RADIUS_FACTOR)
|
|
155
|
+
.outerRadius(outerRadius * ELBOW_RADIUS_FACTOR);
|
|
124
156
|
|
|
125
|
-
const percentTextRadius =
|
|
157
|
+
const percentTextRadius =
|
|
158
|
+
innerRadius + (outerRadius - innerRadius) * PERCENT_TEXT_POSITION;
|
|
126
159
|
const percentageArc = arc<PieArcDatum<ItemDatum>>()
|
|
127
160
|
.innerRadius(percentTextRadius)
|
|
128
161
|
.outerRadius(percentTextRadius);
|
|
129
162
|
|
|
130
163
|
// 删除按钮位置
|
|
131
164
|
const deleteButtonArc = arc<PieArcDatum<ItemDatum>>()
|
|
132
|
-
.innerRadius(outerRadius *
|
|
133
|
-
.outerRadius(outerRadius *
|
|
165
|
+
.innerRadius(outerRadius * DELETE_BUTTON_RADIUS_FACTOR)
|
|
166
|
+
.outerRadius(outerRadius * DELETE_BUTTON_RADIUS_FACTOR);
|
|
134
167
|
|
|
135
168
|
const sliceElements: JSXElement[] = [];
|
|
136
169
|
const percentElements: JSXElement[] = [];
|
|
137
170
|
const connectorElements: JSXElement[] = [];
|
|
138
171
|
const itemElements: JSXElement[] = [];
|
|
139
172
|
const btnElements: JSXElement[] = [];
|
|
173
|
+
const labelItems: LabelItem[] = [];
|
|
140
174
|
|
|
141
175
|
// 3. 遍历生成图形
|
|
142
176
|
arcData.forEach((arcDatum) => {
|
|
143
|
-
const currentItem = arcDatum.data;
|
|
144
177
|
const originalIndex = arcDatum.index;
|
|
145
178
|
|
|
146
179
|
const color =
|
|
@@ -165,38 +198,126 @@ export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
|
165
198
|
// --- 计算关键点 ---
|
|
166
199
|
const midAngle =
|
|
167
200
|
arcDatum.startAngle + (arcDatum.endAngle - arcDatum.startAngle) / 2;
|
|
168
|
-
const isRight = midAngle < Math.PI;
|
|
169
201
|
|
|
170
|
-
|
|
202
|
+
const normalizedAngle = midAngle < 0 ? midAngle + Math.PI * 2 : midAngle;
|
|
203
|
+
const isRight = normalizedAngle < Math.PI;
|
|
204
|
+
|
|
205
|
+
// 计算扇形占比,如果小于 minShowLabelPercent 则跳过生成连线和标签
|
|
206
|
+
const slicePercent =
|
|
207
|
+
totalValue > 0 ? (arcDatum.value / totalValue) * 100 : 0;
|
|
208
|
+
if (slicePercent < minShowLabelPercent) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const centroid = outerArc.centroid(arcDatum);
|
|
213
|
+
|
|
214
|
+
labelItems.push({
|
|
215
|
+
arcDatum,
|
|
216
|
+
originalIndex,
|
|
217
|
+
x: centroid[0],
|
|
218
|
+
y: centroid[1], // 中心点 Y 坐标
|
|
219
|
+
height: labelHeight,
|
|
220
|
+
isRight,
|
|
221
|
+
color,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
let finalLabels: LabelItem[] = labelItems;
|
|
225
|
+
|
|
226
|
+
if (avoidLabelOverlap) {
|
|
227
|
+
// 标签中心点的上下边界(中心点不能超出此范围)
|
|
228
|
+
const labelMinY = -maxVerticalDistance * EXTENSION_FACTOR;
|
|
229
|
+
const labelMaxY = maxVerticalDistance * EXTENSION_FACTOR;
|
|
230
|
+
|
|
231
|
+
const leftItems = labelItems.filter((item) => !item.isRight);
|
|
232
|
+
const rightItems = labelItems.filter((item) => item.isRight);
|
|
233
|
+
|
|
234
|
+
const labelSpacing = labelHeight;
|
|
235
|
+
|
|
236
|
+
const adjustedRight = distributeLabels(
|
|
237
|
+
rightItems,
|
|
238
|
+
labelSpacing,
|
|
239
|
+
labelMinY,
|
|
240
|
+
labelMaxY,
|
|
241
|
+
);
|
|
242
|
+
const adjustedLeft = distributeLabels(
|
|
243
|
+
leftItems,
|
|
244
|
+
labelSpacing,
|
|
245
|
+
labelMinY,
|
|
246
|
+
labelMaxY,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
finalLabels = [...adjustedLeft, ...adjustedRight];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
finalLabels.forEach((item) => {
|
|
253
|
+
const { arcDatum, originalIndex, isRight, color, y: adjustedY } = item;
|
|
254
|
+
|
|
255
|
+
// 1. P0: 连线起点 (内部扇形质心)
|
|
171
256
|
const p0 = innerArc.centroid(arcDatum);
|
|
172
257
|
|
|
173
|
-
// 2.
|
|
258
|
+
// 2. P1: 第一拐点 (外部扇形质心)
|
|
174
259
|
const p1 = outerArc.centroid(arcDatum);
|
|
175
260
|
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
const
|
|
261
|
+
// 计算因避障算法导致的 Y 轴偏移量
|
|
262
|
+
// adjustedY 已经是中心点坐标
|
|
263
|
+
const labelCenterY = adjustedY;
|
|
264
|
+
const deltaY = Math.abs(labelCenterY - p1[1]);
|
|
265
|
+
|
|
266
|
+
// --- 动态补偿策略 (Dynamic Compensation) ---
|
|
267
|
+
// 根据 Y 轴的偏移量动态向外推移 X 轴,以缓解连线折角过于陡峭的问题
|
|
268
|
+
const dynamicShift = deltaY * SMOOTH_FACTOR;
|
|
269
|
+
|
|
270
|
+
// 计算基础拐点半径
|
|
271
|
+
const baseElbowRadius = outerRadius * EXTENSION_FACTOR;
|
|
272
|
+
|
|
273
|
+
// 计算实际拐点半径 (基础半径 + 动态补偿)
|
|
274
|
+
const currentElbowRadius = baseElbowRadius + dynamicShift;
|
|
275
|
+
|
|
276
|
+
// 设定文本锚点的固定半径,确保所有标签在垂直方向上对齐
|
|
277
|
+
const maxExpectedShift = outerRadius * MAX_EXPECTED_SHIFT_FACTOR;
|
|
278
|
+
const fixedTextRadius =
|
|
279
|
+
baseElbowRadius + maxExpectedShift + FIXED_TEXT_RADIUS_GAP;
|
|
280
|
+
|
|
281
|
+
// 计算 P2 X 坐标 (注意方向性)
|
|
282
|
+
// 限制 Elbow X 不超过文本锚点半径,防止连线出现回折
|
|
283
|
+
const elbowRadiusClamped = Math.min(currentElbowRadius, fixedTextRadius);
|
|
284
|
+
const elbowX = elbowRadiusClamped * (isRight ? 1 : -1);
|
|
285
|
+
|
|
286
|
+
// 计算文本锚点 X 坐标 (始终对齐)
|
|
287
|
+
const textX = fixedTextRadius * (isRight ? 1 : -1);
|
|
288
|
+
|
|
289
|
+
// 3. P2: 第二拐点 (动态调整后的 Elbow 位置)
|
|
290
|
+
// 连线终点对齐标签垂直中心
|
|
291
|
+
const p2 = [elbowX, labelCenterY];
|
|
292
|
+
|
|
293
|
+
// 4. P3: 终点 (文本锚点)
|
|
294
|
+
const p3 = [textX, labelCenterY];
|
|
179
295
|
|
|
180
296
|
// --- 绘制连线 ---
|
|
181
297
|
connectorElements.push(
|
|
182
298
|
<Path
|
|
183
|
-
d={`M${centerX + p0[0]} ${centerY + p0[1]}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
299
|
+
d={`M${centerX + p0[0]} ${centerY + p0[1]}
|
|
300
|
+
L${centerX + p1[0]} ${centerY + p1[1]}
|
|
301
|
+
L${centerX + p2[0]} ${centerY + p2[1]}
|
|
302
|
+
L${centerX + p3[0]} ${centerY + p3[1]}
|
|
303
|
+
`}
|
|
304
|
+
stroke={color}
|
|
305
|
+
strokeOpacity={CONNECTOR_STROKE_OPACITY}
|
|
306
|
+
strokeWidth={CONNECTOR_STROKE_WIDTH}
|
|
187
307
|
fill="none"
|
|
188
308
|
data-element-type="shape"
|
|
189
309
|
/>,
|
|
190
310
|
);
|
|
191
311
|
|
|
192
312
|
// --- 绘制 Item ---
|
|
193
|
-
const itemX =
|
|
194
|
-
|
|
313
|
+
const itemX =
|
|
314
|
+
centerX + p3[0] + (isRight ? TEXT_GAP : -TEXT_GAP - labelWidth);
|
|
315
|
+
const itemY = centerY + adjustedY - labelHeight / 2; // 转换为顶部坐标用于渲染
|
|
195
316
|
|
|
196
317
|
itemElements.push(
|
|
197
318
|
<Item
|
|
198
319
|
indexes={[originalIndex]}
|
|
199
|
-
datum={
|
|
320
|
+
datum={arcDatum.data}
|
|
200
321
|
data={data}
|
|
201
322
|
x={itemX}
|
|
202
323
|
y={itemY}
|
|
@@ -257,7 +378,7 @@ export const ChartPie: ComponentType<ChartPieProps> = (props) => {
|
|
|
257
378
|
arcData[nextIndex].startAngle + (nextIndex === 0 ? Math.PI * 2 : 0);
|
|
258
379
|
const midAngle = (currentEnd + nextStart) / 2;
|
|
259
380
|
|
|
260
|
-
const btnR = outerRadius *
|
|
381
|
+
const btnR = outerRadius * ADD_BUTTON_RADIUS_FACTOR;
|
|
261
382
|
const btnX = Math.sin(midAngle) * btnR;
|
|
262
383
|
const btnY = -Math.cos(midAngle) * btnR;
|
|
263
384
|
|
|
@@ -296,3 +417,115 @@ registerStructure('chart-pie', {
|
|
|
296
417
|
component: ChartPie,
|
|
297
418
|
composites: ['title', 'item'],
|
|
298
419
|
});
|
|
420
|
+
|
|
421
|
+
export interface LabelItem {
|
|
422
|
+
arcDatum: PieArcDatum<ItemDatum>;
|
|
423
|
+
originalIndex: number;
|
|
424
|
+
/** 标签中心点 Y 坐标 */
|
|
425
|
+
y: number;
|
|
426
|
+
x: number;
|
|
427
|
+
height: number;
|
|
428
|
+
isRight: boolean;
|
|
429
|
+
color: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 核心避障逻辑:蜘蛛腿算法 (Spider Leg Layout)
|
|
434
|
+
*
|
|
435
|
+
* 注意:y 坐标表示标签的中心点坐标
|
|
436
|
+
*
|
|
437
|
+
* @param items 待处理的标签数组(y 为中心点坐标)
|
|
438
|
+
* @param spacing 垂直最小间距(标签边缘之间的间距)
|
|
439
|
+
* @param minY 标签中心点的上边界
|
|
440
|
+
* @param maxY 标签中心点的下边界
|
|
441
|
+
*/
|
|
442
|
+
export function distributeLabels(
|
|
443
|
+
items: LabelItem[],
|
|
444
|
+
spacing: number,
|
|
445
|
+
minY: number,
|
|
446
|
+
maxY: number,
|
|
447
|
+
): LabelItem[] {
|
|
448
|
+
// 避免除零风险
|
|
449
|
+
if (items.length <= 1) return items.map((item) => ({ ...item }));
|
|
450
|
+
|
|
451
|
+
// 按照 Y 坐标排序 (从上到下)
|
|
452
|
+
const sorted = items.map((item) => ({ ...item })).sort((a, b) => a.y - b.y);
|
|
453
|
+
|
|
454
|
+
// === 预检测:是否需要退避 ===
|
|
455
|
+
// 检查是否有任何标签重叠或超出边界
|
|
456
|
+
const hasOverlap = sorted.some((item, i) => {
|
|
457
|
+
if (i === 0) return false;
|
|
458
|
+
const prev = sorted[i - 1];
|
|
459
|
+
// 中心点间距 < 两个半高度之和 → 有重叠
|
|
460
|
+
return item.y - prev.y < (prev.height + item.height) / 2;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const firstItem = sorted[0];
|
|
464
|
+
const lastItem = sorted[sorted.length - 1];
|
|
465
|
+
const isOutOfBounds =
|
|
466
|
+
firstItem.y - firstItem.height / 2 < minY ||
|
|
467
|
+
lastItem.y + lastItem.height / 2 > maxY;
|
|
468
|
+
|
|
469
|
+
// 如果没有重叠且都在边界内,直接返回原位置
|
|
470
|
+
if (!hasOverlap && !isOutOfBounds) {
|
|
471
|
+
return sorted;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// === 第一步:计算总高度需求,动态调整间距 ===
|
|
475
|
+
const totalLabelsHeight = sorted.reduce((sum, item) => sum + item.height, 0);
|
|
476
|
+
const availableSpace = maxY - minY;
|
|
477
|
+
const requiredSpaceWithIdealSpacing =
|
|
478
|
+
totalLabelsHeight + spacing * (sorted.length - 1);
|
|
479
|
+
|
|
480
|
+
// 如果理想间距放不下,动态压缩间距(最小为0)
|
|
481
|
+
let actualSpacing = spacing;
|
|
482
|
+
if (requiredSpaceWithIdealSpacing > availableSpace) {
|
|
483
|
+
const excessSpace = availableSpace - totalLabelsHeight;
|
|
484
|
+
actualSpacing = Math.max(0, excessSpace / (sorted.length - 1));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// === 第二步:向下挤压 (Downwards push) ===
|
|
488
|
+
// y 为中心点坐标
|
|
489
|
+
// 当前标签中心 必须 >= 前一标签中心 + 两个半高度之和 + 间距
|
|
490
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
491
|
+
const prev = sorted[i - 1];
|
|
492
|
+
const curr = sorted[i];
|
|
493
|
+
|
|
494
|
+
const minAllowedY =
|
|
495
|
+
prev.y + (prev.height + curr.height) / 2 + actualSpacing;
|
|
496
|
+
if (curr.y < minAllowedY) {
|
|
497
|
+
curr.y = minAllowedY;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// === 第三步:边界钳制 + 向上回推 (Upwards push) ===
|
|
502
|
+
// 如果最后一个标签超出下边界,从下往上回推
|
|
503
|
+
const lastIdx = sorted.length - 1;
|
|
504
|
+
const last = sorted[lastIdx];
|
|
505
|
+
if (last.y + last.height / 2 > maxY) {
|
|
506
|
+
// 先把最后一个钳制到边界内(中心点 = 下边界 - 半高度)
|
|
507
|
+
last.y = maxY - last.height / 2;
|
|
508
|
+
|
|
509
|
+
// 然后从下往上检查,如果上一个标签被挤到了,就往上推
|
|
510
|
+
for (let i = lastIdx - 1; i >= 0; i--) {
|
|
511
|
+
const next = sorted[i + 1];
|
|
512
|
+
const curr = sorted[i];
|
|
513
|
+
|
|
514
|
+
const maxAllowedY =
|
|
515
|
+
next.y - (next.height + curr.height) / 2 - actualSpacing;
|
|
516
|
+
if (curr.y > maxAllowedY) {
|
|
517
|
+
curr.y = maxAllowedY;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// === 第四步:上边界钳制 ===
|
|
523
|
+
// 如果向上回推后,第一个标签超出上边界,整体往下移
|
|
524
|
+
const first = sorted[0];
|
|
525
|
+
if (first.y - first.height / 2 < minY) {
|
|
526
|
+
const shift = minY - (first.y - first.height / 2);
|
|
527
|
+
sorted.forEach((item) => (item.y += shift));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return sorted;
|
|
531
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 规范化百分比输入
|
|
3
|
+
*
|
|
4
|
+
* 支持以下格式:
|
|
5
|
+
* - "2%" 或 "2.5%" → 2 或 2.5
|
|
6
|
+
* - 2 或 2.5 (数字直接作为百分比) → 2 或 2.5
|
|
7
|
+
*
|
|
8
|
+
* @param value - 百分比值,可以是数字或带 "%" 的字符串
|
|
9
|
+
* @returns 规范化后的百分比数值
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* normalizePercent("2%"); // 返回 2
|
|
14
|
+
* normalizePercent("2.5%"); // 返回 2.5
|
|
15
|
+
* normalizePercent(2); // 返回 2
|
|
16
|
+
* normalizePercent(2.5); // 返回 2.5
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function normalizePercent(value: number | string | undefined): number {
|
|
20
|
+
if (value === undefined || value === null) return 0;
|
|
21
|
+
|
|
22
|
+
// 处理字符串格式 (如 "2%" 或 "2.5%")
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
// 移除可能的 '%' 后缀,然后解析
|
|
26
|
+
const numStr = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed;
|
|
27
|
+
const num = parseFloat(numStr);
|
|
28
|
+
return isNaN(num) ? 0 : num;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 数字直接作为百分比使用
|
|
32
|
+
return value;
|
|
33
|
+
}
|