import tinycolor from 'tinycolor2'; import type { ComponentType, JSXElement } from '../../jsx'; import { Defs, getElementBounds, Group, Path, Rect, Text } from '../../jsx'; import { BtnAdd, BtnRemove, BtnsGroup, ItemsGroup } from '../components'; import { FlexLayout } from '../layouts'; import { getColorPrimary, getPaletteColor } from '../utils'; import { registerStructure } from './registry'; import type { BaseStructureProps } from './types'; export interface sequenceCylinders3dProps extends BaseStructureProps { cylinderRx?: number; cylinderRy?: number; baseHeight?: number; heightIncrement?: number; horizontalSpacing?: number; depthSpacing?: number; itemVerticalAlign?: 'top' | 'center' | 'bottom'; itemVerticalOffset?: number; firstDecorationWidth?: number; } interface CylinderPosition { x: number; y: number; height: number; bottomY: number; topY: number; } interface ItemPosition { x: number; y: number; } interface LayoutMetrics { canvasHeight: number; startY: number; leftItemAlignedX: number; rightItemAlignedX: number; cylinderAreaStartX: number; } const calculateDepthOffset = ( pairIndex: number, cylinderRx: number, ): number => { if (pairIndex === 0) return 0; if (pairIndex === 1) return cylinderRx / 2; return cylinderRx / 2 + (pairIndex - 1) * ((cylinderRx / 2) * 3); }; const calculateLateralOffset = ( isLeft: boolean, pairIndex: number, cylinderRx: number, ): number => { if (isLeft) return 0; const firstPairGap = 2; const normalGap = cylinderRx; const gap = pairIndex === 0 ? firstPairGap : normalGap; return cylinderRx * 2 + gap; }; const calculateLayoutMetrics = ( itemsCount: number, itemBounds: { width: number; height: number }, cylinderRx: number, baseHeight: number, heightIncrement: number, depthSpacing: number, firstDecorationWidth: number, gapFromCylinder: number, ): LayoutMetrics => { const planeStepY = Math.max(6, depthSpacing * 0.15); const lastCylinderHeight = baseHeight + (itemsCount - 1) * heightIncrement; const totalPlaneOffset = itemsCount * planeStepY; const bottomMargin = 100; const topMargin = 50; const canvasHeight = lastCylinderHeight + totalPlaneOffset + bottomMargin + topMargin; const startY = canvasHeight - bottomMargin; let leftItemAlignedX = 0; let rightItemAlignedX = 0; let cylindersCenterX = 0; let cylinderAreaStartX = 0; if (itemsCount > 0) { const tempCylinderStart = 0; let minCylinderX = Infinity; let maxCylinderX = -Infinity; for (let index = 0; index < itemsCount; index++) { const isLeft = index % 2 === 0; const pairIndex = Math.floor(index / 2); const depthOffset = calculateDepthOffset(pairIndex, cylinderRx); const lateralOffset = calculateLateralOffset( isLeft, pairIndex, cylinderRx, ); const x = tempCylinderStart + lateralOffset + depthOffset; minCylinderX = Math.min(minCylinderX, x - cylinderRx); maxCylinderX = Math.max(maxCylinderX, x + cylinderRx); } const relativeCylindersCenterX = (minCylinderX + maxCylinderX) / 2; const firstCylinderRelativeX = tempCylinderStart; leftItemAlignedX = 0; const leftLineEndX = leftItemAlignedX + itemBounds.width + gapFromCylinder; const firstCylinderLeftEdge = leftLineEndX + firstDecorationWidth; const requiredFirstCylinderX = firstCylinderLeftEdge + cylinderRx; cylinderAreaStartX = requiredFirstCylinderX; cylindersCenterX = cylinderAreaStartX - firstCylinderRelativeX + relativeCylindersCenterX; const firstItemCenterX = leftItemAlignedX + itemBounds.width / 2; const distanceToCenter = cylindersCenterX - firstItemCenterX; const rightItemCenterX = cylindersCenterX + distanceToCenter; rightItemAlignedX = rightItemCenterX - itemBounds.width / 2; } return { canvasHeight, startY, leftItemAlignedX, rightItemAlignedX, cylinderAreaStartX, }; }; const calculateCylinderPosition = ( index: number, cylinderRx: number, baseHeight: number, heightIncrement: number, planeStepY: number, startY: number, cylinderAreaStartX: number, ): CylinderPosition => { const isLeft = index % 2 === 0; const pairIndex = Math.floor(index / 2); const depthOffset = calculateDepthOffset(pairIndex, cylinderRx); const lateralOffset = calculateLateralOffset(isLeft, pairIndex, cylinderRx); const x = cylinderAreaStartX + lateralOffset + depthOffset; const bottomY = startY - index * planeStepY; const height = baseHeight + index * heightIncrement; const topY = bottomY - height; return { x, y: topY, height, bottomY, topY }; }; const calculateItemPosition = ( index: number, cylinderPos: CylinderPosition, itemBounds: { width: number; height: number }, cylinderRx: number, leftItemAlignedX: number, rightItemAlignedX: number, itemVerticalAlign: 'top' | 'center' | 'bottom', itemVerticalOffset: number, gapFromCylinder: number, ): { itemPos: ItemPosition; lineStartX: number; lineEndX: number; lineY: number; } => { const isLeft = index % 2 === 0; const lineY = cylinderPos.topY + cylinderPos.height * 0.05; let itemX: number; let lineEndX: number; if (isLeft) { itemX = leftItemAlignedX; lineEndX = itemX + itemBounds.width + gapFromCylinder; } else { itemX = rightItemAlignedX; lineEndX = itemX - gapFromCylinder; } let itemY: number; if (itemVerticalAlign === 'top') { itemY = lineY; } else if (itemVerticalAlign === 'bottom') { itemY = lineY - itemBounds.height; } else { itemY = lineY - itemBounds.height / 2; } itemY += itemVerticalOffset; const cylinderEdgeX = isLeft ? cylinderPos.x - cylinderRx : cylinderPos.x + cylinderRx; const lineStartX = isLeft ? cylinderEdgeX - gapFromCylinder : cylinderEdgeX + gapFromCylinder; return { itemPos: { x: itemX, y: itemY }, lineStartX, lineEndX, lineY }; }; const createGradientDefs = (index: number, color: string): JSXElement[] => { const baseColor = tinycolor(color); const defs: JSXElement[] = []; defs.push( , ); defs.push( , ); defs.push( , ); defs.push( , ); defs.push( , ); return defs; }; const createCylinderElements = ( index: number, cylinderPos: CylinderPosition, cylinderRx: number, cylinderRy: number, ): JSXElement[] => { const { x, topY, bottomY } = cylinderPos; const elements: JSXElement[] = []; elements.push( , ); elements.push( , ); elements.push( , ); elements.push( , ); const numberX = x - 10; const numberY = topY - 15; const scaleY = 0.6; const skewX = -0.6; const transformValue = `translate(${numberX}, ${numberY}) matrix(1, 0, ${skewX}, ${scaleY}, 0, 0)`; elements.push( {index + 1} , ); return elements; }; const createDecorationElements = ( index: number, lineStartX: number, lineEndX: number, lineY: number, color: string, ): JSXElement[] => { const dotRadius = 2; const elements: JSXElement[] = []; elements.push( , ); elements.push( , ); elements.push( , ); return elements; }; const createBasePlate = ( itemsCount: number, cylinderRx: number, cylinderAreaStartX: number, startY: number, planeStepY: number, ): JSXElement | null => { if (itemsCount === 0) return null; const positions: Array<{ x: number; y: number }> = []; for (let index = 0; index < itemsCount; index++) { const isLeft = index % 2 === 0; const pairIndex = Math.floor(index / 2); const depthOffset = calculateDepthOffset(pairIndex, cylinderRx); const lateralOffset = calculateLateralOffset(isLeft, pairIndex, cylinderRx); const x = cylinderAreaStartX + lateralOffset + depthOffset; const bottomY = startY - index * planeStepY; positions.push({ x, y: bottomY }); } const leftmostPos = positions[0]; const rightmostPos = positions[positions.length - 1]; // 底板的厚度 const plateThickness = 6; const plateMargin = itemsCount > 5 ? itemsCount * 16 : 100; const frontLeftX = leftmostPos.x - cylinderRx - plateMargin; const frontRightX = leftmostPos.x + cylinderRx + plateMargin; const frontY = leftmostPos.y + plateThickness + plateMargin / 6; const backLeftX = rightmostPos.x - cylinderRx - plateMargin; const backRightX = rightmostPos.x + cylinderRx + plateMargin; const backY = rightmostPos.y + plateThickness - plateMargin / 6; const platePath = ` M ${frontLeftX} ${frontY} L ${frontRightX} ${frontY} L ${backRightX} ${backY} L ${backLeftX} ${backY} Z `; const sidePath = ` M ${frontRightX} ${frontY} L ${frontRightX} ${frontY + plateThickness} L ${backRightX} ${backY + plateThickness} L ${backRightX} ${backY} Z `; const frontPath = ` M ${frontLeftX} ${frontY} L ${frontRightX} ${frontY} L ${frontRightX} ${frontY + plateThickness} L ${frontLeftX} ${frontY + plateThickness} Z `; const plateGradients = ( <> {/* 顶面渐变 */} {/* 前面渐变 */} {/* 侧面渐变 */} ); return ( {plateGradients} ); }; export const sequenceCylinders3d: ComponentType = ( props, ) => { const { Title, Item, data, options, cylinderRx = 28, cylinderRy = 18, baseHeight = 120, heightIncrement = 40, depthSpacing = 60, itemVerticalAlign = 'top', itemVerticalOffset = -12, firstDecorationWidth = 90, } = props; const { title, desc, items = [] } = data; const titleContent = Title ? : null; const itemBounds = getElementBounds( <Item indexes={[0]} data={data} datum={items[0]} positionH="center" />, ); const btnBounds = getElementBounds(<BtnAdd indexes={[0]} />); const colorPrimary = getColorPrimary(options); const gapFromCylinder = 10; const planeStepY = Math.max(6, depthSpacing * 0.15); const layoutMetrics = calculateLayoutMetrics( items.length, itemBounds, cylinderRx, baseHeight, heightIncrement, depthSpacing, firstDecorationWidth, gapFromCylinder, ); const { startY, leftItemAlignedX, rightItemAlignedX, cylinderAreaStartX } = layoutMetrics; const defsElements: JSXElement[] = []; const perItemGroups: Array<{ cylinderNodes: JSXElement[]; itemNode: JSXElement; btnNodes: JSXElement[]; itemX: number; itemY: number; }> = []; items.forEach((item, index) => { const color = getPaletteColor(options, [index]) || colorPrimary; defsElements.push(...createGradientDefs(index, color)); const cylinderPos = calculateCylinderPosition( index, cylinderRx, baseHeight, heightIncrement, planeStepY, startY, cylinderAreaStartX, ); const cylinderNodes = createCylinderElements( index, cylinderPos, cylinderRx, cylinderRy, ); const { itemPos, lineStartX, lineEndX, lineY } = calculateItemPosition( index, cylinderPos, itemBounds, cylinderRx, leftItemAlignedX, rightItemAlignedX, itemVerticalAlign, itemVerticalOffset, gapFromCylinder, ); const decorationNodes = createDecorationElements( index, lineStartX, lineEndX, lineY, color, ); cylinderNodes.push(...decorationNodes); const itemNode = ( <Item indexes={[index]} datum={item} data={data} x={itemPos.x} y={itemPos.y} positionH={index % 2 === 0 ? 'flipped' : 'normal'} /> ); const btnNodes: JSXElement[] = [ <BtnRemove indexes={[index]} x={itemPos.x + itemBounds.width / 2 - btnBounds.width / 2} y={itemPos.y + itemBounds.height + 10} />, <BtnAdd indexes={[index]} x={itemPos.x + itemBounds.width / 2 - btnBounds.width / 2} y={itemPos.y - btnBounds.height - 10} />, ]; perItemGroups[index] = { cylinderNodes, itemNode, btnNodes, itemX: itemPos.x, itemY: itemPos.y, }; }); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; perItemGroups.forEach((group) => { const { itemX, itemY } = group; minX = Math.min(minX, itemX); minY = Math.min(minY, itemY); maxX = Math.max(maxX, itemX + itemBounds.width); maxY = Math.max(maxY, itemY + itemBounds.height); }); const itemsBoundsWidth = maxX - minX; const itemsBoundsHeight = maxY - minY; const itemElements: JSXElement[] = [<Defs>{defsElements}</Defs>]; const btnElements: JSXElement[] = []; // 添加底板 const basePlate = createBasePlate( items.length, cylinderRx, cylinderAreaStartX, startY, planeStepY, ); if (basePlate) { itemElements.push(basePlate); } for (let i = items.length - 1; i >= 0; i--) { const g = perItemGroups[i]; if (!g) continue; itemElements.push( <Group> <Group>{g.cylinderNodes}</Group> {g.itemNode} </Group>, ); btnElements.push(...g.btnNodes); } if (items.length > 0) { const isNextLeft = items.length % 2 === 0; const nextPairIndex = Math.floor(items.length / 2); const nextDepthOffset = calculateDepthOffset(nextPairIndex, cylinderRx); const nextLateralOffset = calculateLateralOffset( isNextLeft, nextPairIndex, cylinderRx, ); const nextX = cylinderAreaStartX + nextLateralOffset + nextDepthOffset; const nextY = startY - items.length * planeStepY; btnElements.push( <BtnAdd indexes={[items.length]} x={nextX} y={nextY - 100} />, ); } return ( <FlexLayout flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} <Group width={itemsBoundsWidth} height={itemsBoundsHeight}> <ItemsGroup>{itemElements}</ItemsGroup> <BtnsGroup>{btnElements}</BtnsGroup> </Group> </FlexLayout> ); }; registerStructure('sequence-cylinders-3d', { component: sequenceCylinders3d, composites: ['title', 'item'], });