@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.
Files changed (44) hide show
  1. package/dist/infographic.min.js +98 -94
  2. package/dist/infographic.min.js.map +1 -1
  3. package/esm/designs/structures/chart-pie.d.ts +25 -0
  4. package/esm/designs/structures/chart-pie.js +182 -23
  5. package/esm/designs/utils/index.d.ts +1 -0
  6. package/esm/designs/utils/index.js +1 -0
  7. package/esm/designs/utils/normalize-percent.d.ts +19 -0
  8. package/esm/designs/utils/normalize-percent.js +32 -0
  9. package/esm/editor/interactions/zoom-wheel.d.ts +3 -0
  10. package/esm/editor/interactions/zoom-wheel.js +46 -23
  11. package/esm/editor/managers/state.js +8 -2
  12. package/esm/templates/built-in.js +3 -77
  13. package/esm/templates/chart-pie.d.ts +2 -0
  14. package/esm/templates/chart-pie.js +87 -0
  15. package/esm/utils/viewbox.d.ts +20 -0
  16. package/esm/utils/viewbox.js +10 -0
  17. package/esm/version.d.ts +1 -1
  18. package/esm/version.js +1 -1
  19. package/lib/designs/structures/chart-pie.d.ts +25 -0
  20. package/lib/designs/structures/chart-pie.js +182 -22
  21. package/lib/designs/utils/index.d.ts +1 -0
  22. package/lib/designs/utils/index.js +1 -0
  23. package/lib/designs/utils/normalize-percent.d.ts +19 -0
  24. package/lib/designs/utils/normalize-percent.js +35 -0
  25. package/lib/editor/interactions/zoom-wheel.d.ts +3 -0
  26. package/lib/editor/interactions/zoom-wheel.js +45 -22
  27. package/lib/editor/managers/state.js +8 -2
  28. package/lib/templates/built-in.js +3 -77
  29. package/lib/templates/chart-pie.d.ts +2 -0
  30. package/lib/templates/chart-pie.js +90 -0
  31. package/lib/utils/viewbox.d.ts +20 -0
  32. package/lib/utils/viewbox.js +12 -0
  33. package/lib/version.d.ts +1 -1
  34. package/lib/version.js +1 -1
  35. package/package.json +1 -2
  36. package/src/designs/structures/chart-pie.tsx +259 -26
  37. package/src/designs/utils/index.ts +1 -0
  38. package/src/designs/utils/normalize-percent.ts +33 -0
  39. package/src/editor/interactions/zoom-wheel.ts +64 -22
  40. package/src/editor/managers/state.ts +10 -5
  41. package/src/templates/built-in.ts +2 -81
  42. package/src/templates/chart-pie.ts +89 -0
  43. package/src/utils/viewbox.ts +23 -0
  44. 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
+ };
@@ -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;
@@ -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.12";
1
+ export declare const VERSION = "0.2.13";
package/lib/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.2.12';
4
+ exports.VERSION = '0.2.13';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antv/infographic",
3
- "version": "0.2.12",
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 { getColorPrimary, getPaletteColor, getThemeColors } from '../utils';
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 * extensionFactor + textGap + labelWidth;
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 * 1.15)
123
- .outerRadius(outerRadius * 1.15);
154
+ .innerRadius(outerRadius * ELBOW_RADIUS_FACTOR)
155
+ .outerRadius(outerRadius * ELBOW_RADIUS_FACTOR);
124
156
 
125
- const percentTextRadius = innerRadius + (outerRadius - innerRadius) * 0.5;
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 * 0.85)
133
- .outerRadius(outerRadius * 0.85);
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
- // 1. 起点
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
- // 3. 终点 (水平拉伸)
177
- const labelXOffset = outerRadius * extensionFactor * (isRight ? 1 : -1);
178
- const p2 = [labelXOffset, p1[1]];
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]} L${centerX + p1[0]} ${centerY + p1[1]} L${centerX + p2[0]} ${centerY + p2[1]}`}
184
- stroke={colorPrimary}
185
- strokeOpacity={0.45}
186
- strokeWidth={2}
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 = centerX + p2[0] + (isRight ? textGap : -textGap - labelWidth);
194
- const itemY = centerY + p2[1] - labelHeight / 2;
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={currentItem}
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 * 1.0;
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
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './color';
2
2
  export * from './hierarchy-color';
3
3
  export * from './item';
4
+ export * from './normalize-percent';
@@ -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
+ }