Skip to content

Commit b2febdc

Browse files
feat: 添加 sequence-funnel 结构及其模板 (#139)
* feat: 添加 sequence-funnel 结构及其模板 * 修复 gemin 在 review 中提到的问题 * refactor(designs): optimize code * refactor: add template --------- Co-authored-by: Aaron <antvaaron@gmail.com>
1 parent d966bf1 commit b2febdc

6 files changed

Lines changed: 274 additions & 0 deletions

File tree

.skills/infographic-creator/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ interface ItemDatum {
152152
- sequence-cylinders-3d-simple
153153
- sequence-color-snake-steps-horizontal-icon-line
154154
- sequence-pyramid-simple
155+
- sequence-funnel-simple
155156
- sequence-roadmap-vertical-simple
156157
- sequence-roadmap-vertical-plain-text
157158
- sequence-zigzag-pucks-3d-simple

.skills/infographic-syntax-creator/references/prompt.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
- sequence-cylinders-3d-simple
6262
- sequence-color-snake-steps-horizontal-icon-line
6363
- sequence-pyramid-simple
64+
- sequence-funnel-simple
6465
- sequence-roadmap-vertical-simple
6566
- sequence-roadmap-vertical-plain-text
6667
- sequence-zigzag-pucks-3d-simple

site/src/components/AIPlayground/Prompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ theme
5252
- sequence-cylinders-3d-simple
5353
- sequence-color-snake-steps-horizontal-icon-line
5454
- sequence-pyramid-simple
55+
- sequence-funnel-simple
5556
- sequence-roadmap-vertical-simple
5657
- sequence-roadmap-vertical-plain-text
5758
- sequence-zigzag-pucks-3d-simple

src/designs/structures/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export * from './sequence-circular';
2828
export * from './sequence-color-snake-steps';
2929
export * from './sequence-cylinders-3d';
3030
export * from './sequence-filter-mesh';
31+
export * from './sequence-funnel';
3132
export * from './sequence-horizontal-zigzag';
3233
export * from './sequence-mountain';
3334
export * from './sequence-pyramid';
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* 序列漏斗结构(SequenceFunnel)
3+
* 用途:
4+
* - 在左侧渲染分层漏斗形状(倒置梯形堆叠),右侧渲染对应的 item 卡片与图标
5+
* - 形状上宽下窄,底部平滑(梯形),卡片背景插入漏斗下方
6+
*/
7+
import roundPolygon, { getSegments } from 'round-polygon';
8+
import tinycolor from 'tinycolor2';
9+
import type { ComponentType } from '../../jsx';
10+
import { Defs, Group, Point, Polygon, Rect } from '../../jsx';
11+
import {
12+
BtnAdd,
13+
BtnRemove,
14+
BtnsGroup,
15+
ItemIcon,
16+
ItemsGroup,
17+
} from '../components';
18+
import { FlexLayout } from '../layouts';
19+
import { getPaletteColor, getThemeColors } from '../utils';
20+
import { registerStructure } from './registry';
21+
import type { BaseStructureProps } from './types';
22+
23+
// Constants
24+
const FUNNEL_CORNER_RADIUS = 6;
25+
const ICON_SIZE = 32;
26+
const FUNNEL_LAYER_HEIGHT_RATIO = 1.25;
27+
const OVERLAP_DIST = 25;
28+
const TEXT_GAP = 15;
29+
30+
// SequenceFunnel 的可配置属性
31+
export interface SequenceFunnelProps extends BaseStructureProps {
32+
gap?: number;
33+
width?: number;
34+
funnelWidth?: number;
35+
itemHeight?: number;
36+
// 新增:底部宽度比例(0~1),控制漏斗底部的收窄程度,避免变成尖角
37+
// 默认为 0.25 (即底部宽度是顶部的 25%)
38+
minBottomRatio?: number;
39+
}
40+
41+
export const SequenceFunnel: ComponentType<SequenceFunnelProps> = (props) => {
42+
const {
43+
Title,
44+
Item,
45+
data,
46+
gap = 10,
47+
width = 700,
48+
funnelWidth,
49+
itemHeight = 60,
50+
minBottomRatio = 0.25, // 默认底部保留 25% 的宽度,形成梯形
51+
options,
52+
} = props;
53+
54+
const { title, desc, items = [] } = data;
55+
56+
const titleContent = Title ? <Title title={title} desc={desc} /> : null;
57+
58+
if (items.length === 0) {
59+
return (
60+
<FlexLayout
61+
id="infographic-container"
62+
flexDirection="column"
63+
justifyContent="center"
64+
alignItems="center"
65+
>
66+
{titleContent}
67+
</FlexLayout>
68+
);
69+
}
70+
71+
const themeColors = getThemeColors(options.themeConfig);
72+
73+
// 计算各区域尺寸
74+
const actualFunnelWidth = funnelWidth ?? width * 0.55; // 稍微调窄一点漏斗,给右侧留更多空间
75+
const itemAreaWidth = width - actualFunnelWidth;
76+
77+
// 漏斗层高度
78+
const funnelLayerHeight = itemHeight * FUNNEL_LAYER_HEIGHT_RATIO;
79+
const totalHeight =
80+
items.length * funnelLayerHeight + (items.length - 1) * gap;
81+
82+
// 计算底部的最小像素宽度
83+
const minFunnelPixelWidth = actualFunnelWidth * minBottomRatio;
84+
85+
const elements = items.map((item, index) => {
86+
const indexes = [index];
87+
// 获取颜色
88+
const color = getPaletteColor(options, [index]) || themeColors.colorPrimary;
89+
90+
// 1. 计算当前层的梯形形状
91+
// 使用线性插值,从 actualFunnelWidth 收缩到 minFunnelPixelWidth
92+
const { points, topWidth } = calculateTrapezoidSegment(
93+
actualFunnelWidth,
94+
minFunnelPixelWidth,
95+
funnelLayerHeight,
96+
gap,
97+
totalHeight,
98+
index,
99+
);
100+
101+
// 圆角处理
102+
const rounded = roundPolygon(points, FUNNEL_CORNER_RADIUS);
103+
const segments = getSegments(rounded, 'AMOUNT', 10);
104+
105+
// 坐标计算
106+
const funnelCenterX = actualFunnelWidth / 2;
107+
const funnelY = index * (funnelLayerHeight + gap);
108+
109+
// 2. 背景与 Item 的位置计算
110+
// 在漏斗(倒梯形)中,顶边(topWidth)总是比底边(bottomWidth)宽
111+
// 所以右侧边缘的最外点是 topWidth 的一半
112+
const rightTopX = funnelCenterX + topWidth / 2;
113+
114+
// 背景卡片:
115+
// X 轴起点:从漏斗最宽处向左回缩 overlapDist,形成“插入”效果
116+
const backgroundX = rightTopX - OVERLAP_DIST;
117+
// 宽度:填满剩余空间,但要补上左侧回缩的距离
118+
const backgroundWidth = itemAreaWidth + OVERLAP_DIST - 10; // -10 用于右侧留白
119+
const backgroundYOffset = (funnelLayerHeight - itemHeight) / 2;
120+
const backgroundY = funnelY + backgroundYOffset;
121+
122+
// 文本内容 (Item):
123+
// X 轴起点:不应该跟着背景向左缩,而应该在漏斗边缘右侧,避免被漏斗遮挡
124+
const itemX = rightTopX + TEXT_GAP;
125+
const itemWidth = backgroundWidth - OVERLAP_DIST - TEXT_GAP;
126+
const itemY = backgroundY;
127+
128+
// 图标位置
129+
const iconX = funnelCenterX - ICON_SIZE / 2;
130+
const iconY = funnelY + funnelLayerHeight / 2 - ICON_SIZE / 2;
131+
132+
const funnelColorId = `${color.replace('#', '')}-funnel-${index}`;
133+
134+
return {
135+
background: (
136+
<Rect
137+
x={backgroundX}
138+
y={backgroundY}
139+
width={backgroundWidth}
140+
height={itemHeight}
141+
ry="8" // 背景圆角稍微大一点,显得柔和
142+
fill={tinycolor(color).setAlpha(0.1).toRgbString()} // 使用当前主题色的浅色背景
143+
data-element-type="shape"
144+
/>
145+
),
146+
funnel: [
147+
<Defs>
148+
<linearGradient id={funnelColorId} x1="0%" y1="0%" x2="100%" y2="0%">
149+
<stop
150+
offset="0%"
151+
stopColor={tinycolor(color).lighten(10).toString()}
152+
/>
153+
<stop offset="100%" stopColor={color} />
154+
</linearGradient>
155+
</Defs>,
156+
<Polygon
157+
points={segments}
158+
fill={`url(#${funnelColorId})`}
159+
y={funnelY}
160+
data-element-type="shape"
161+
// 添加轻微阴影效果增加层次感(可选,依赖环境支持 filter)
162+
style={{ filter: 'drop-shadow(0px 2px 3px rgba(0,0,0,0.15))' }}
163+
/>,
164+
],
165+
icon: (
166+
<ItemIcon
167+
indexes={indexes}
168+
x={iconX}
169+
y={iconY}
170+
size={ICON_SIZE}
171+
fill="#fff"
172+
/>
173+
),
174+
item: (
175+
<Item
176+
indexes={indexes}
177+
datum={item}
178+
data={data}
179+
x={itemX}
180+
y={itemY}
181+
width={itemWidth}
182+
height={itemHeight}
183+
positionV="middle"
184+
/>
185+
),
186+
btnRemove: (
187+
<BtnRemove
188+
indexes={indexes}
189+
x={backgroundX + backgroundWidth}
190+
y={backgroundY}
191+
/>
192+
),
193+
};
194+
});
195+
196+
const btnAdd = (
197+
<BtnAdd indexes={[items.length]} x={width / 2} y={totalHeight + 10} />
198+
);
199+
200+
return (
201+
<FlexLayout
202+
id="infographic-container"
203+
flexDirection="column"
204+
justifyContent="center"
205+
alignItems="center"
206+
>
207+
{titleContent}
208+
<Group width={width} height={totalHeight + 40}>
209+
{/* 背景最先渲染,位于底部 */}
210+
<Group>{elements.map((element) => element.background)}</Group>
211+
{/* 漏斗覆盖在背景之上 */}
212+
<Group>{elements.flatMap((element) => element.funnel)}</Group>
213+
{/* 图标和文字在最上层 */}
214+
<Group>{elements.map((element) => element.icon)}</Group>
215+
<ItemsGroup>{elements.map((element) => element.item)}</ItemsGroup>
216+
<BtnsGroup>
217+
{elements.map((element) => element.btnRemove)}
218+
{btnAdd}
219+
</BtnsGroup>
220+
</Group>
221+
</FlexLayout>
222+
);
223+
};
224+
225+
// 计算梯形分段逻辑
226+
function calculateTrapezoidSegment(
227+
maxWidth: number,
228+
minWidth: number,
229+
layerHeight: number,
230+
gap: number,
231+
totalHeight: number,
232+
index: number,
233+
) {
234+
const centerX = maxWidth / 2;
235+
236+
// 当前层顶部和底部的 Y 坐标(相对于总高度)
237+
const currentTopY = index * (layerHeight + gap);
238+
const currentBottomY = currentTopY + layerHeight;
239+
240+
// 线性插值计算宽度
241+
// Width = MaxWidth - (MaxWidth - MinWidth) * (Y / TotalHeight)
242+
const widthDiff = maxWidth - minWidth;
243+
244+
const topWidth = maxWidth - widthDiff * (currentTopY / totalHeight);
245+
const bottomWidth = maxWidth - widthDiff * (currentBottomY / totalHeight);
246+
247+
// 生成四个顶点 (梯形)
248+
const p1: Point = { x: centerX - topWidth / 2, y: 0 }; // 左上
249+
const p2: Point = { x: centerX + topWidth / 2, y: 0 }; // 右上
250+
const p3: Point = { x: centerX + bottomWidth / 2, y: layerHeight }; // 右下
251+
const p4: Point = { x: centerX - bottomWidth / 2, y: layerHeight }; // 左下
252+
253+
return { points: [p1, p2, p3, p4], topWidth, bottomWidth };
254+
}
255+
256+
// 注册
257+
registerStructure('sequence-funnel', {
258+
component: SequenceFunnel,
259+
composites: ['title', 'item'],
260+
});

src/templates/built-in.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,16 @@ const BUILT_IN_TEMPLATES: Record<string, TemplateOptions> = {
310310
colorPrimary: '#1677ff',
311311
},
312312
},
313+
'sequence-funnel-simple': {
314+
design: {
315+
title: 'default',
316+
structure: { type: 'sequence-funnel'},
317+
items: [{ type: 'simple', showIcon: false, usePaletteColor: true}],
318+
},
319+
themeConfig: {
320+
palette: '#1677ff',
321+
},
322+
},
313323
'list-row-horizontal-icon-line': {
314324
design: {
315325
title: 'default',

0 commit comments

Comments
 (0)