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( <Item indexes={[0]} data={data} datum={items[0]} width={itemWidth} />, ); 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( <Mountain colorPrimary={color} x={mountainX} y={mountainY} width={mountainWidth} height={mountainHeight} />, ); const itemX = firstMountainLeft + index * (itemWidth + gap); labelElements.push( <Text x={itemX} y={labelYPos} width={itemWidth} height={labelHeight} fontSize={16} fontWeight="bold" alignHorizontal="center" alignVertical="middle" fill={color} backgroundColor={color} backgroundOpacity={0.5} backgroundRadius={4} > {String(index + 1).padStart(2, '0')} </Text>, ); itemElements.push( <Item indexes={indexes} datum={datum} data={data} x={itemX} y={itemYPos} width={itemWidth} />, ); }); const btnBounds = getElementBounds(<BtnAdd indexes={[0]} />); const btnY = itemYPos + itemBounds.height + 10; items.forEach((datum, index) => { const indexes = [index]; const itemX = firstMountainLeft + index * (itemWidth + gap); btnElements.push( <BtnRemove indexes={indexes} x={itemX + itemWidth / 2 - btnBounds.width / 2} y={btnY} />, ); if (index < items.length - 1) { btnElements.push( <BtnAdd indexes={[index + 1]} x={itemX + itemWidth + gap / 2 - btnBounds.width / 2} y={btnY} />, ); } }); if (n > 0) { const firstItemX = firstMountainLeft; btnElements.unshift( <BtnAdd indexes={[0]} x={firstItemX - gap / 2 - btnBounds.width / 2} y={btnY} />, ); const lastItemX = firstMountainLeft + (n - 1) * (itemWidth + gap); btnElements.push( <BtnAdd indexes={[n]} x={lastItemX + itemWidth + gap / 2 - btnBounds.width / 2} y={btnY} />, ); decorElements.push( <Sun x={totalWidth - sunSize - 20} y={-35} width={sunSize} height={sunSize} />, ); 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( <Group x={treeX} y={treeY}> {Tree(treeSize)} </Group>, ); } } }); 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( <Cloud type={cloudType} x={xPos} y={yPos} width={cloudSize.width} height={cloudSize.height} />, ); } } return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} <Group> <Group>{decorElements}</Group> <Group>{labelElements}</Group> <ItemsGroup>{itemElements}</ItemsGroup> <BtnsGroup>{btnElements}</BtnsGroup> </Group> </FlexLayout> ); }; registerStructure('sequence-mountain', { component: SequenceMountain, composites: ['title', 'item'], });