import { arc, pie, type PieArcDatum } from 'd3'; import { ElementTypeEnum } from '../../constants'; import type { ComponentType, JSXElement } from '../../jsx'; import { getElementBounds, Group, Path, Text } from '../../jsx'; import { ItemDatum } from '../../types'; import { BtnAdd, BtnRemove, BtnsGroup, ItemsGroup } from '../components'; import { FlexLayout } from '../layouts'; import { getColorPrimary, getPaletteColor, getThemeColors, normalizePercent, } from '../utils'; import { registerStructure } from './registry'; import type { BaseStructureProps } from './types'; // === 连线布局常量 === /** 连线水平拉伸系数:控制拐点相对于外半径的延伸比例 */ const EXTENSION_FACTOR = 1.35; /** 文本与连线终点之间的水平间距 */ const TEXT_GAP = 8; /** 平滑系数:控制 Y 轴偏移对 X 轴补偿的影响幅度 */ const SMOOTH_FACTOR = 0.3; /** 最大预期偏移系数:相对于外半径的比例 */ const MAX_EXPECTED_SHIFT_FACTOR = 0.2; /** 文本锚点与拐点之间的固定间距 */ const FIXED_TEXT_RADIUS_GAP = 20; /** 连线拐点半径系数:相对于外半径的比例 */ const ELBOW_RADIUS_FACTOR = 1.15; /** 百分比文本位置系数:从内半径到外半径的比例 (0.5 = 中间) */ const PERCENT_TEXT_POSITION = 0.5; /** 删除按钮半径系数:相对于外半径的比例 */ const DELETE_BUTTON_RADIUS_FACTOR = 0.85; /** 添加按钮半径系数:相对于外半径的比例 */ const ADD_BUTTON_RADIUS_FACTOR = 1.0; /** 连线透明度 */ const CONNECTOR_STROKE_OPACITY = 0.45; /** 连线宽度 */ const CONNECTOR_STROKE_WIDTH = 2; export interface ChartPieProps extends BaseStructureProps { radius?: number; innerRadius?: number; padding?: number; showPercentage?: boolean; avoidLabelOverlap?: boolean; minShowLabelPercent?: number | string; } export const ChartPie: ComponentType = (props) => { const { Title, Item, data, radius = 140, innerRadius = 0, padding = 30, showPercentage = true, avoidLabelOverlap = false, minShowLabelPercent: rawMinShowLabelPercent = 0, options, } = props; // 规范化百分比阈值 const minShowLabelPercent = normalizePercent(rawMinShowLabelPercent); const { title, desc, items = [] } = data; const titleContent = Title ? : null; const btnBounds = getElementBounds(<BtnAdd indexes={[0]} />); // 获取 Item 的预估尺寸 const sampleDatum: ItemDatum = items[0] ?? { label: '', value: 0 }; const itemBounds = getElementBounds( <Item indexes={[0]} datum={sampleDatum} data={data} positionH="center" positionV="middle" />, ); const labelWidth = itemBounds.width || 140; const labelHeight = itemBounds.height || 32; // 基础半径设置 const outerRadius = Math.max(radius, 60); // 计算画布中心和总尺寸 // 水平方向:半径 * 系数 + 间距 + 标签宽度 + 边缘padding const maxHorizontalDistance = outerRadius * EXTENSION_FACTOR + TEXT_GAP + labelWidth; const maxVerticalDistance = outerRadius; const centerX = padding + maxHorizontalDistance; const centerY = padding + maxVerticalDistance; const totalWidth = centerX * 2; const totalHeight = centerY * 2; // 空数据处理 if (items.length === 0) { return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} <Group width={totalWidth} height={totalHeight}> <BtnsGroup> <BtnAdd indexes={[0]} x={centerX - btnBounds.width / 2} y={centerY - btnBounds.height / 2} /> </BtnsGroup> </Group> </FlexLayout> ); } const totalValue = items.reduce( (sum, item) => sum + Math.max(item.value ?? 0, 0), 0, ); const colorPrimary = getColorPrimary(options); const themeColors = getThemeColors(options.themeConfig); // 1. 饼图生成器 const pieGenerator = pie<ItemDatum>() .value((item) => Math.max(item.value ?? 0, 0)) .sort(null) .startAngle(0) .endAngle(Math.PI * 2); const arcData = pieGenerator(items); // 2. 弧形生成器 const arcGenerator = arc<PieArcDatum<ItemDatum>>() .innerRadius(innerRadius) .outerRadius(outerRadius) .cornerRadius(2); // 连线起点 const innerArc = arc<PieArcDatum<ItemDatum>>() .innerRadius(outerRadius) .outerRadius(outerRadius); // 连线拐点 const outerArc = arc<PieArcDatum<ItemDatum>>() .innerRadius(outerRadius * ELBOW_RADIUS_FACTOR) .outerRadius(outerRadius * ELBOW_RADIUS_FACTOR); const percentTextRadius = innerRadius + (outerRadius - innerRadius) * PERCENT_TEXT_POSITION; const percentageArc = arc<PieArcDatum<ItemDatum>>() .innerRadius(percentTextRadius) .outerRadius(percentTextRadius); // 删除按钮位置 const deleteButtonArc = arc<PieArcDatum<ItemDatum>>() .innerRadius(outerRadius * DELETE_BUTTON_RADIUS_FACTOR) .outerRadius(outerRadius * DELETE_BUTTON_RADIUS_FACTOR); const sliceElements: JSXElement[] = []; const percentElements: JSXElement[] = []; const connectorElements: JSXElement[] = []; const itemElements: JSXElement[] = []; const btnElements: JSXElement[] = []; const labelItems: LabelItem[] = []; // 3. 遍历生成图形 arcData.forEach((arcDatum) => { const originalIndex = arcDatum.index; const color = getPaletteColor(options, [originalIndex]) || themeColors.colorPrimary || colorPrimary; // --- 绘制扇形 --- const pathD = arcGenerator(arcDatum) || ''; sliceElements.push( <Path d={pathD} fill={color} stroke={themeColors.colorBg} strokeWidth={1} data-element-type="shape" width={outerRadius * 2} height={outerRadius * 2} />, ); // --- 计算关键点 --- const midAngle = arcDatum.startAngle + (arcDatum.endAngle - arcDatum.startAngle) / 2; const normalizedAngle = midAngle < 0 ? midAngle + Math.PI * 2 : midAngle; const isRight = normalizedAngle < Math.PI; // 计算扇形占比,如果小于 minShowLabelPercent 则跳过生成连线和标签 const slicePercent = totalValue > 0 ? (arcDatum.value / totalValue) * 100 : 0; if (slicePercent < minShowLabelPercent) { return; } const centroid = outerArc.centroid(arcDatum); labelItems.push({ arcDatum, originalIndex, x: centroid[0], y: centroid[1], // 中心点 Y 坐标 height: labelHeight, isRight, color, }); }); let finalLabels: LabelItem[] = labelItems; if (avoidLabelOverlap) { // 标签中心点的上下边界(中心点不能超出此范围) const labelMinY = -maxVerticalDistance * EXTENSION_FACTOR; const labelMaxY = maxVerticalDistance * EXTENSION_FACTOR; const leftItems = labelItems.filter((item) => !item.isRight); const rightItems = labelItems.filter((item) => item.isRight); const labelSpacing = labelHeight; const adjustedRight = distributeLabels( rightItems, labelSpacing, labelMinY, labelMaxY, ); const adjustedLeft = distributeLabels( leftItems, labelSpacing, labelMinY, labelMaxY, ); finalLabels = [...adjustedLeft, ...adjustedRight]; } finalLabels.forEach((item) => { const { arcDatum, originalIndex, isRight, color, y: adjustedY } = item; // 1. P0: 连线起点 (内部扇形质心) const p0 = innerArc.centroid(arcDatum); // 2. P1: 第一拐点 (外部扇形质心) const p1 = outerArc.centroid(arcDatum); // 计算因避障算法导致的 Y 轴偏移量 // adjustedY 已经是中心点坐标 const labelCenterY = adjustedY; const deltaY = Math.abs(labelCenterY - p1[1]); // --- 动态补偿策略 (Dynamic Compensation) --- // 根据 Y 轴的偏移量动态向外推移 X 轴,以缓解连线折角过于陡峭的问题 const dynamicShift = deltaY * SMOOTH_FACTOR; // 计算基础拐点半径 const baseElbowRadius = outerRadius * EXTENSION_FACTOR; // 计算实际拐点半径 (基础半径 + 动态补偿) const currentElbowRadius = baseElbowRadius + dynamicShift; // 设定文本锚点的固定半径,确保所有标签在垂直方向上对齐 const maxExpectedShift = outerRadius * MAX_EXPECTED_SHIFT_FACTOR; const fixedTextRadius = baseElbowRadius + maxExpectedShift + FIXED_TEXT_RADIUS_GAP; // 计算 P2 X 坐标 (注意方向性) // 限制 Elbow X 不超过文本锚点半径,防止连线出现回折 const elbowRadiusClamped = Math.min(currentElbowRadius, fixedTextRadius); const elbowX = elbowRadiusClamped * (isRight ? 1 : -1); // 计算文本锚点 X 坐标 (始终对齐) const textX = fixedTextRadius * (isRight ? 1 : -1); // 3. P2: 第二拐点 (动态调整后的 Elbow 位置) // 连线终点对齐标签垂直中心 const p2 = [elbowX, labelCenterY]; // 4. P3: 终点 (文本锚点) const p3 = [textX, labelCenterY]; // --- 绘制连线 --- connectorElements.push( <Path d={`M${centerX + p0[0]} ${centerY + p0[1]} L${centerX + p1[0]} ${centerY + p1[1]} L${centerX + p2[0]} ${centerY + p2[1]} L${centerX + p3[0]} ${centerY + p3[1]} `} stroke={color} strokeOpacity={CONNECTOR_STROKE_OPACITY} strokeWidth={CONNECTOR_STROKE_WIDTH} fill="none" data-element-type="shape" />, ); // --- 绘制 Item --- const itemX = centerX + p3[0] + (isRight ? TEXT_GAP : -TEXT_GAP - labelWidth); const itemY = centerY + adjustedY - labelHeight / 2; // 转换为顶部坐标用于渲染 itemElements.push( <Item indexes={[originalIndex]} datum={arcDatum.data} data={data} x={itemX} y={itemY} width={labelWidth} height={labelHeight} positionH={isRight ? 'normal' : 'flipped'} positionV="middle" themeColors={getThemeColors({ colorPrimary: color }, options)} />, ); // --- 绘制百分比 --- if (showPercentage && totalValue > 0) { const percentPos = percentageArc.centroid(arcDatum); const value = Math.max(arcDatum.value, 0); const percentText = ((value * 100) / totalValue).toFixed(1); // 定义文本框尺寸 const textWidth = 50; const textHeight = 20; percentElements.push( <Text x={centerX + percentPos[0] - textWidth / 2} y={centerY + percentPos[1] - textHeight / 2} width={textWidth} height={textHeight} alignHorizontal="center" alignVertical="middle" fontSize={12} fontWeight="bold" fill="#ffffff" data-value={value} data-indexes={[originalIndex]} data-element-type={ElementTypeEnum.ItemValue} > {`${percentText}%`} </Text>, ); } // --- 绘制删除按钮 --- const deletePos = deleteButtonArc.centroid(arcDatum); btnElements.push( <BtnRemove indexes={[originalIndex]} x={centerX + deletePos[0] - btnBounds.width / 2} y={centerY + deletePos[1] - btnBounds.height / 2} />, ); }); // --- 绘制添加按钮 --- arcData.forEach((arcDatum, index) => { const nextIndex = (index + 1) % arcData.length; const currentEnd = arcDatum.endAngle; const nextStart = arcData[nextIndex].startAngle + (nextIndex === 0 ? Math.PI * 2 : 0); const midAngle = (currentEnd + nextStart) / 2; const btnR = outerRadius * ADD_BUTTON_RADIUS_FACTOR; const btnX = Math.sin(midAngle) * btnR; const btnY = -Math.cos(midAngle) * btnR; btnElements.push( <BtnAdd indexes={[index + 1]} x={centerX + btnX - btnBounds.width / 2} y={centerY + btnY - btnBounds.height / 2} />, ); }); return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" gap={30} > {titleContent} <Group width={totalWidth} height={totalHeight}> <Group x={centerX} y={centerY}> {sliceElements} </Group> <Group>{connectorElements}</Group> <Group>{percentElements}</Group> <ItemsGroup>{itemElements}</ItemsGroup> <BtnsGroup>{btnElements}</BtnsGroup> </Group> </FlexLayout> ); }; registerStructure('chart-pie', { component: ChartPie, composites: ['title', 'item'], }); export interface LabelItem { arcDatum: PieArcDatum<ItemDatum>; originalIndex: number; /** 标签中心点 Y 坐标 */ y: number; x: number; height: number; isRight: boolean; color: string; } /** * 核心避障逻辑:蜘蛛腿算法 (Spider Leg Layout) * * 注意:y 坐标表示标签的中心点坐标 * * @param items 待处理的标签数组(y 为中心点坐标) * @param spacing 垂直最小间距(标签边缘之间的间距) * @param minY 标签中心点的上边界 * @param maxY 标签中心点的下边界 */ export function distributeLabels( items: LabelItem[], spacing: number, minY: number, maxY: number, ): LabelItem[] { // 避免除零风险 if (items.length <= 1) return items.map((item) => ({ ...item })); // 按照 Y 坐标排序 (从上到下) const sorted = items.map((item) => ({ ...item })).sort((a, b) => a.y - b.y); // === 预检测:是否需要退避 === // 检查是否有任何标签重叠或超出边界 const hasOverlap = sorted.some((item, i) => { if (i === 0) return false; const prev = sorted[i - 1]; // 中心点间距 < 两个半高度之和 → 有重叠 return item.y - prev.y < (prev.height + item.height) / 2; }); const firstItem = sorted[0]; const lastItem = sorted[sorted.length - 1]; const isOutOfBounds = firstItem.y - firstItem.height / 2 < minY || lastItem.y + lastItem.height / 2 > maxY; // 如果没有重叠且都在边界内,直接返回原位置 if (!hasOverlap && !isOutOfBounds) { return sorted; } // === 第一步:计算总高度需求,动态调整间距 === const totalLabelsHeight = sorted.reduce((sum, item) => sum + item.height, 0); const availableSpace = maxY - minY; const requiredSpaceWithIdealSpacing = totalLabelsHeight + spacing * (sorted.length - 1); // 如果理想间距放不下,动态压缩间距(最小为0) let actualSpacing = spacing; if (requiredSpaceWithIdealSpacing > availableSpace) { const excessSpace = availableSpace - totalLabelsHeight; actualSpacing = Math.max(0, excessSpace / (sorted.length - 1)); } // === 第二步:向下挤压 (Downwards push) === // y 为中心点坐标 // 当前标签中心 必须 >= 前一标签中心 + 两个半高度之和 + 间距 for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1]; const curr = sorted[i]; const minAllowedY = prev.y + (prev.height + curr.height) / 2 + actualSpacing; if (curr.y < minAllowedY) { curr.y = minAllowedY; } } // === 第三步:边界钳制 + 向上回推 (Upwards push) === // 如果最后一个标签超出下边界,从下往上回推 const lastIdx = sorted.length - 1; const last = sorted[lastIdx]; if (last.y + last.height / 2 > maxY) { // 先把最后一个钳制到边界内(中心点 = 下边界 - 半高度) last.y = maxY - last.height / 2; // 然后从下往上检查,如果上一个标签被挤到了,就往上推 for (let i = lastIdx - 1; i >= 0; i--) { const next = sorted[i + 1]; const curr = sorted[i]; const maxAllowedY = next.y - (next.height + curr.height) / 2 - actualSpacing; if (curr.y > maxAllowedY) { curr.y = maxAllowedY; } } } // === 第四步:上边界钳制 === // 如果向上回推后,第一个标签超出上边界,整体往下移 const first = sorted[0]; if (first.y - first.height / 2 < minY) { const shift = minY - (first.y - first.height / 2); sorted.forEach((item) => (item.y += shift)); } return sorted; }