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(
,
);
const btnBounds = getElementBounds();
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 = (
);
const btnNodes: JSXElement[] = [
,
,
];
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[] = [{defsElements}];
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(
{g.cylinderNodes}
{g.itemNode}
,
);
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(
,
);
}
return (
{titleContent}
{itemElements}
{btnElements}
);
};
registerStructure('sequence-cylinders-3d', {
component: sequenceCylinders3d,
composites: ['title', 'item'],
});