import type { ComponentType, JSXElement } from '../../jsx'; import { Ellipse, getElementBounds, Group, Path } from '../../jsx'; import { BtnAdd, BtnRemove, BtnsGroup, ItemIcon, ItemsGroup, } from '../components'; import { FlexLayout } from '../layouts'; import { getPaletteColor } from '../utils'; import { registerStructure } from './registry'; import type { BaseStructureProps } from './types'; const ITEM_AREA_HORIZONTAL_PADDING = 100; const CIRCLE_AREA_HORIZONTAL_PADDING = 50; const INNER_ARC_PADDING = 6; const ARC_BACKGROUND_OPACITY_HEX = '40'; export interface SequenceCircularProps extends BaseStructureProps { outerRadius?: number; innerRadius?: number; itemDistance?: number; gapAngle?: number; iconRadius?: number; iconBgRadius?: number; iconSize?: number; } export const SequenceCircular: ComponentType = ( props, ) => { const { Title, Item, data, options, outerRadius = 180, innerRadius = 120, itemDistance = 310, gapAngle = 5, iconRadius = 34, iconBgRadius = 38, iconSize = 36, } = props; const { title, desc, items = [] } = data; const titleContent = Title ? : null; // 计算布局中心点,确保最高点为0 const centerX = Math.max( itemDistance + ITEM_AREA_HORIZONTAL_PADDING, outerRadius + CIRCLE_AREA_HORIZONTAL_PADDING, ); const centerY = Math.min(itemDistance, outerRadius); const btnBounds = getElementBounds(<BtnAdd indexes={[0]} />); // 如果没有数据,显示中心添加按钮 if (items.length === 0) { return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} <Group> <BtnsGroup> <BtnAdd indexes={[0]} x={centerX - 20} y={centerY - 20} /> </BtnsGroup> </Group> </FlexLayout> ); } const btnElements: JSXElement[] = []; const itemElements: JSXElement[] = []; const arcElements: JSXElement[] = []; const iconElements: JSXElement[] = []; // 获取Item组件尺寸 const itemBounds = getElementBounds( <Item indexes={[0]} data={data} datum={items[0]} />, ); // 计算每个扇区的角度 const totalGapAngle = items.length * gapAngle; const availableAngle = 360 - totalGapAngle; const arcAngle = availableAngle / items.length; // 创建弧形路径的辅助函数 const createArcPath = ( centerX: number, centerY: number, innerR: number, outerR: number, startAngle: number, endAngle: number, ): string => { const startAngleRad = (startAngle * Math.PI) / 180; const endAngleRad = (endAngle * Math.PI) / 180; const x1 = centerX + innerR * Math.cos(startAngleRad); const y1 = centerY + innerR * Math.sin(startAngleRad); const x2 = centerX + outerR * Math.cos(startAngleRad); const y2 = centerY + outerR * Math.sin(startAngleRad); const x3 = centerX + outerR * Math.cos(endAngleRad); const y3 = centerY + outerR * Math.sin(endAngleRad); const x4 = centerX + innerR * Math.cos(endAngleRad); const y4 = centerY + innerR * Math.sin(endAngleRad); const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; return [ `M ${x1} ${y1}`, `L ${x2} ${y2}`, `A ${outerR} ${outerR} 0 ${largeArcFlag} 1 ${x3} ${y3}`, `L ${x4} ${y4}`, `A ${innerR} ${innerR} 0 ${largeArcFlag} 0 ${x1} ${y1}`, 'Z', ].join(' '); }; const computePosition = ({ centerX, outerRadius, angleRad, btnBounds, }: { centerX: number; outerRadius: number; angleRad: number; btnBounds: { width: number; height: number }; }) => { const x = centerX + (outerRadius + 20) * Math.cos(angleRad) - btnBounds.width / 2; const y = centerY + (outerRadius + 20) * Math.sin(angleRad) - btnBounds.height / 2; return { x, y, }; }; // 计算角度边距,使其在弧形上的实际距离等于径向边距 const padding = INNER_ARC_PADDING; const avgRadius = (innerRadius + outerRadius) / 2; const anglePadding = (padding / avgRadius) * (180 / Math.PI); items.forEach((item, index) => { const indexes = [index]; // 计算当前扇区的起始和结束角度(从顶部开始,270度对应顶部) const startAngle = index * (arcAngle + gapAngle) + 270; const endAngle = startAngle + arcAngle; // 计算扇区中心角度,用于定位Item和按钮 const midAngle = (startAngle + endAngle) / 2; const midAngleRad = (midAngle * Math.PI) / 180; // 获取当前项的颜色 const itemColor = getPaletteColor(options, indexes); const lightColor = itemColor + ARC_BACKGROUND_OPACITY_HEX; // 绘制外层浅色弧形 const outerArcPath = createArcPath( centerX, centerY, innerRadius, outerRadius, startAngle, endAngle, ); arcElements.push( <Path d={outerArcPath} fill={lightColor} width={outerRadius * 2} height={outerRadius * 2} data-element-type="shape" />, ); // 绘制内层主题色弧形 const innerArcPath = createArcPath( centerX, centerY, innerRadius + padding, outerRadius - padding, startAngle + anglePadding, endAngle - anglePadding, ); arcElements.push( <Path d={innerArcPath} fill={itemColor} width={outerRadius * 2} height={outerRadius * 2} data-element-type="shape" />, ); // 计算图标在弧形中心的位置 const iconDistance = (innerRadius + outerRadius) / 2; const iconCenterX = centerX + iconDistance * Math.cos(midAngleRad); const iconCenterY = centerY + iconDistance * Math.sin(midAngleRad); // 添加外层白色圆形背景 iconElements.push( <Ellipse x={iconCenterX - iconBgRadius} y={iconCenterY - iconBgRadius} width={iconBgRadius * 2} height={iconBgRadius * 2} fill="#ffffff" data-element-type="shape" />, ); // 添加内层主题色圆形 iconElements.push( <Ellipse x={iconCenterX - iconRadius} y={iconCenterY - iconRadius} width={iconRadius * 2} height={iconRadius * 2} fill={itemColor} data-element-type="shape" />, ); // 添加图标(如果数据项有图标) if (item.icon) { iconElements.push( <ItemIcon x={iconCenterX - iconSize / 2} y={iconCenterY - iconSize / 2} indexes={indexes} size={iconSize} fill="#fff" />, ); } // 判断Item应该显示在左侧还是右侧 const normalizedAngle = ((midAngle % 360) + 360) % 360; const isRightSide = normalizedAngle >= 270 || normalizedAngle <= 90; // 定义底部区域:75° - 105°(以90°为中心的±15°范围) const isBottomArea = normalizedAngle >= 75 && normalizedAngle <= 105; // 计算Item在弧形上的位置 let itemAngle: number; let positionH: 'normal' | 'flipped' | 'center' = 'normal'; let positionV: 'normal' | 'flipped' | 'center' = 'normal'; if (isBottomArea) { itemAngle = normalizedAngle; positionV = 'normal'; positionH = 'center'; } else if (isRightSide) { // 右侧:将角度映射到右侧弧形范围 (-60° 到 60°) if (normalizedAngle >= 270) { // 270° - 360° 映射到 -60° - 0° itemAngle = -60 + ((normalizedAngle - 270) / 90) * 60; } else { // 0° - 90° 映射到 0° - 60° itemAngle = (normalizedAngle / 90) * 60; } positionH = 'normal'; } else { // 左侧:将角度映射到左侧弧形范围 (120° 到 240°) // 90° - 270° 映射到 120° - 240° itemAngle = 120 + ((normalizedAngle - 90) / 180) * 120; positionH = 'flipped'; } // 转换为弧度并计算Item位置 const itemAngleRad = (itemAngle * Math.PI) / 180; const itemX = centerX + itemDistance * Math.cos(itemAngleRad) - itemBounds.width / 2; let itemY = centerY + itemDistance * Math.sin(itemAngleRad) - itemBounds.height / 2; if (isBottomArea) { itemY = centerY + outerRadius + itemBounds.height / 2; } itemElements.push( <Item indexes={indexes} datum={item} data={data} x={itemX} y={itemY} positionH={positionH} positionV={positionV} />, ); // 添加删除按钮(在Item外侧) const removeBtnDistance = itemDistance + 40; const removeBtnX = centerX + removeBtnDistance * Math.cos(itemAngleRad) - btnBounds.width / 2; const removeBtnY = centerY + removeBtnDistance * Math.sin(itemAngleRad) - btnBounds.height / 2; btnElements.push( <BtnRemove indexes={indexes} x={removeBtnX} y={removeBtnY} />, ); // 计算添加按钮位置(在相邻扇区之间的弧形上) const nextGapAngle = startAngle + arcAngle + gapAngle / 2; const nextGapAngleRad = (nextGapAngle * Math.PI) / 180; const { x: addBtnX, y: addBtnY } = computePosition({ centerX, outerRadius, angleRad: nextGapAngleRad, btnBounds, }); btnElements.push(<BtnAdd indexes={[index + 1]} x={addBtnX} y={addBtnY} />); }); // 添加第一个位置的添加按钮 const firstGapAngle = 270 - gapAngle / 2; const firstGapAngleRad = (firstGapAngle * Math.PI) / 180; const { x: firstAddBtnX, y: firstAddBtnY } = computePosition({ centerX, outerRadius, angleRad: firstGapAngleRad, btnBounds, }); btnElements.unshift( <BtnAdd indexes={[0]} x={firstAddBtnX} y={firstAddBtnY} />, ); return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" gap={70} > {titleContent} <Group> <Group>{arcElements}</Group> <Group>{iconElements}</Group> <ItemsGroup>{itemElements}</ItemsGroup> <BtnsGroup>{btnElements}</BtnsGroup> </Group> </FlexLayout> ); }; registerStructure('sequence-circular', { component: SequenceCircular, composites: ['title', 'item'], });