import tinycolor from 'tinycolor2';
import type { ComponentType, JSXElement } from '../../jsx';
import { Bounds, getElementBounds, Group, Text } from '../../jsx';
import {
BtnAdd,
BtnRemove,
BtnsGroup,
ItemsGroup,
ShapesGroup,
} from '../components';
import { FlexLayout } from '../layouts';
import { getPaletteColor, getThemeColors } from '../utils';
import { registerStructure } from './registry';
import type { BaseStructureProps } from './types';
function Mountain(props: { colorPrimary: string } & Bounds) {
const { width, height, colorPrimary } = props;
const leftBottomColor = colorPrimary;
const rightBottomColor = tinycolor
.mix(colorPrimary, '#000', 20)
.toHexString();
const leftTopColor = tinycolor.mix(colorPrimary, '#fff', 50).toHexString();
const rightTopColor = tinycolor.mix(leftTopColor, '#000', 15).toHexString();
function calculateMountainPoints(width: number, height: number) {
// 内置参数
const snowLine = 0.35; // 雪线位置(0-1)
const ripples = [
{ position: 0.25, offset: 0.1 }, // 左侧向下
{ position: 0.45, offset: -0.02 }, // 中线向上
{ position: 0.6, offset: 0.04 }, // 右侧第一个向下
{ position: 0.75, offset: -0.1 }, // 右侧第二个向上
];
// 1. 计算三角形的三个基本点
const leftBottom = { x: 0, y: height };
const rightBottom = { x: width, y: height };
const peak = { x: width / 2, y: 0 };
const centerBottom = { x: width / 2, y: height };
// 2. 计算雪线的 Y 坐标
const snowLineY = height * snowLine;
// 3. 计算雪线在左边线、中线、右边线上的交点
const t = snowLineY / height;
const leftEdge = {
x: peak.x * (1 - t) + leftBottom.x * t,
y: snowLineY,
};
const centerSnow = {
x: peak.x,
y: snowLineY,
};
const rightEdge = {
x: peak.x * (1 - t) + rightBottom.x * t,
y: snowLineY,
};
// 4. 计算起伏点
const allRipples = ripples.map((ripple) => {
const baseX = leftEdge.x + (rightEdge.x - leftEdge.x) * ripple.position;
const baseY = snowLineY;
const offsetY = height * ripple.offset;
return {
x: baseX,
y: baseY + offsetY,
position: ripple.position,
offset: ripple.offset,
};
});
// 按 position 排序
allRipples.sort((a, b) => a.position - b.position);
// 5. 分为左右两组(以 centerSnow 的 x 坐标为界)
const leftRipples = allRipples.filter((p) => p.x <= centerSnow.x);
const rightRipples = allRipples.filter((p) => p.x > centerSnow.x);
// 返回所有计算出的点
return {
// 基础三角形点
leftBottom,
rightBottom,
peak,
centerBottom,
// 雪线相关点
snowLineY,
leftEdge,
centerSnow,
rightEdge,
// 起伏点(分为左右两组)
leftRipples,
rightRipples,
};
}
// 测试
const {
leftRipples,
rightRipples,
leftBottom,
rightBottom,
peak,
centerBottom,
leftEdge,
centerSnow,
rightEdge,
} = calculateMountainPoints(width, height);
const leftTopShape = [peak, leftEdge, ...leftRipples, centerSnow];
const rightTopShape = [peak, centerSnow, ...rightRipples, rightEdge];
const leftBottomShape = [
leftEdge,
...leftRipples,
centerSnow,
centerBottom,
leftBottom,
];
const rightBottomShape = [
centerSnow,
...rightRipples,
rightEdge,
rightBottom,
centerBottom,
];
const toPointsString = (points: { x: number; y: number }[]) =>
points.map((p) => `${p.x},${p.y}`).join(' ');
return (
);
}
function Tree(size: 'tiny' | 'small' | 'medium' | 'large') {
const heightMap = {
tiny: 27,
small: 48,
medium: 54,
large: 72,
};
const height = heightMap[size] || 54;
const leftLeafColor = '#17C76F';
const rightLeafColor = '#139B57';
const trunkColor = '#737373';
const leafHeight = height * 0.7;
const trunkHeight = height - leafHeight;
const leafWidth = leafHeight * 0.8;
const width = leafWidth;
const trunkWidth = width / 6;
const trunkX = (width - trunkWidth) / 2;
const trunkY = leafHeight;
return (
{/* 左半椭圆(叶子左侧) */}
{/* 右半椭圆(叶子右侧) */}
{/* 树干 */}
);
}
function Sun(props: Bounds) {
const { width, height } = props;
// 基于尺寸计算各部分比例
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.28; // 圆半径约为尺寸的28%
const rayWidth = Math.min(width, height) * 0.14; // 光线宽度约为尺寸的14%
const rayHeight = Math.min(width, height) * 0.07; // 光线高度约为尺寸的7%
const rayX = 0;
const rayY = centerY - rayHeight / 2; // 光线垂直居中对齐
const cornerRadius = rayHeight * 0.4; // 圆角半径为光线高度的40%
const rayCount = 8;
// 生成光线数组
const rays = Array.from({ length: rayCount }, (_, i) => {
const angle = (360 / rayCount) * i;
return (
);
});
return (
{...rays}
);
}
function Cloud(props: { type: 'single' | 'double' } & Bounds) {
if (props.type === 'single') {
return (
);
}
return (
);
}
export interface SequenceMountainProps extends BaseStructureProps {
gap?: number;
minHeight?: number;
maxHeight?: number;
minWidth?: number;
maxWidth?: number;
}
export const SequenceMountain: ComponentType = (
props,
) => {
const {
Title,
Item,
data,
gap = 20,
minHeight = 100,
maxHeight = 200,
minWidth = 260,
maxWidth = 300,
options,
} = props;
const { title, desc, items = [] } = data;
const titleContent = Title ? : null;
const itemElements: JSXElement[] = [];
const btnElements: JSXElement[] = [];
const decorElements: JSXElement[] = [];
const labelElements: JSXElement[] = [];
const n = items.length;
const themeColors = getThemeColors(options.themeConfig);
const sunSize = 60;
const cloudSizes = {
single: { width: 54, height: 36 },
double: { width: 73, height: 40 },
};
const mountainWidths: number[] = [];
const mountainXPositions: number[] = [];
let totalWidth = 0;
let nextMountainX = 0;
items.forEach((datum, index) => {
const progress = n > 1 ? index / (n - 1) : 0;
const mountainHeight = minHeight + (maxHeight - minHeight) * progress;
const calculatedWidth = mountainHeight * 1.6;
const mountainWidth = Math.max(
minWidth,
Math.min(maxWidth, calculatedWidth),
);
mountainWidths.push(mountainWidth);
const mountainX = nextMountainX;
mountainXPositions.push(mountainX);
nextMountainX += mountainWidth / 2;
if (index === n - 1) {
totalWidth = mountainX + mountainWidth;
}
});
const firstMountainLeft = mountainXPositions[0];
const lastMountainRight = mountainXPositions[n - 1] + mountainWidths[n - 1];
const itemAreaWidth = lastMountainRight - firstMountainLeft;
const itemWidth = n > 1 ? (itemAreaWidth - gap * (n - 1)) / n : itemAreaWidth;
const itemBounds = getElementBounds(
,
);
const labelHeight = 32;
const labelYPos = maxHeight + gap;
const itemYPos = labelYPos + labelHeight + 10;
items.forEach((datum, index) => {
const indexes = [index];
const mountainHeight =
minHeight + (maxHeight - minHeight) * (n > 1 ? index / (n - 1) : 0);
const mountainWidth = mountainWidths[index];
const mountainX = mountainXPositions[index];
const mountainY = maxHeight - mountainHeight;
const color = getPaletteColor(options, [index]) || themeColors.colorPrimary;
decorElements.push(
,
);
const itemX = firstMountainLeft + index * (itemWidth + gap);
labelElements.push(
{String(index + 1).padStart(2, '0')}
,
);
itemElements.push(
,
);
});
const btnBounds = getElementBounds();
const btnY = itemYPos + itemBounds.height + 10;
items.forEach((datum, index) => {
const indexes = [index];
const itemX = firstMountainLeft + index * (itemWidth + gap);
btnElements.push(
,
);
if (index < items.length - 1) {
btnElements.push(
,
);
}
});
if (n > 0) {
const firstItemX = firstMountainLeft;
btnElements.unshift(
,
);
const lastItemX = firstMountainLeft + (n - 1) * (itemWidth + gap);
btnElements.push(
,
);
decorElements.push(
,
);
const treeSizes: Array<'tiny' | 'small' | 'medium' | 'large'> = [
'tiny',
'small',
'medium',
'large',
];
const treeHeightMap = { tiny: 27, small: 48, medium: 54, large: 72 };
const treeWidthMap = {
tiny: 14.85,
small: 26.4,
medium: 29.7,
large: 39.6,
};
const placedTrees: Array<{ x: number; width: number }> = [];
const isTreeOverlapping = (
treeX: number,
treeWidth: number,
padding: number = 5,
): boolean => {
return placedTrees.some((placed) => {
return !(
treeX + treeWidth + padding < placed.x ||
treeX > placed.x + placed.width + padding
);
});
};
items.forEach((datum, index) => {
const mountainX = mountainXPositions[index];
const mountainWidth = mountainWidths[index];
const isLastMountain = index === n - 1;
const treesOnThisMountain = isLastMountain ? 3 : index === 0 ? 1 : 2;
for (let t = 0; t < treesOnThisMountain; t++) {
const seed = index * 100 + t * 37;
const sizeIndex = (seed * 17) % treeSizes.length;
const treeSize = treeSizes[sizeIndex];
const treeHeight = treeHeightMap[treeSize];
const treeWidth = treeWidthMap[treeSize];
let attempts = 0;
let treeX = 0;
let validPosition = false;
while (attempts < 20 && !validPosition) {
const xOffset = ((seed * 13 + attempts * 19) % 100) / 100;
const minX = mountainX + mountainWidth * 0.15;
const maxX = mountainX + mountainWidth * 0.85 - treeWidth;
treeX = minX + (maxX - minX) * xOffset;
if (!isTreeOverlapping(treeX, treeWidth)) {
validPosition = true;
}
attempts++;
}
if (validPosition) {
placedTrees.push({ x: treeX, width: treeWidth });
const treeY = maxHeight - treeHeight;
decorElements.push(
{Tree(treeSize)}
,
);
}
}
});
const cloudCount = Math.max(1, Math.floor(n / 1.5));
for (let i = 0; i < cloudCount; i++) {
const seed = i * 11 + n * 5 + 1;
const cloudType = seed % 2 === 0 ? 'single' : 'double';
const cloudSize = cloudSizes[cloudType];
const cloudRange =
lastMountainRight - firstMountainLeft - cloudSize.width;
const xPos = firstMountainLeft + (((seed * 7) % 100) / 100) * cloudRange;
const yPos = (seed * 13) % 40;
decorElements.push(
,
);
}
}
return (
{titleContent}
{decorElements}
{labelElements}
{itemElements}
{btnElements}
);
};
registerStructure('sequence-mountain', {
component: SequenceMountain,
composites: ['title', 'item'],
});