技术拆解-时间轴曲线组件

概述

这是一个基于 Stencil 框架的高级时间线组件,实现了蛇形布局和复杂曲线连接效果。组件使用 SVG 遮罩技术、多层渲染架构和渐变色系统,提供了专业级的可视化体验。

技术栈

  • 框架: Stencil (Web Components)
  • 语言: TypeScript
  • 样式: SCSS + UnoCSS
  • 图形: SVG 遮罩
  • 构建: Rollup

核心架构

1. 数据结构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 节点数据接口
export interface NodeItem {
time: string; // 时间显示
title: string; // 节点标题
description: string; // 节点描述
point: boolean; // 是否为关键节点
solid: boolean; // 连接线是否为实线
}

// 渲染项接口
export interface IRenderItem {
type:
| 'node'
| 'line'
| 'left-curve'
| 'right-curve'
| 'start-line'
| 'end-line';
title?: string;
description?: string;
solid?: boolean;
point?: boolean;
time?: string;
index: number;
bgColor?: string;
}

2. 蛇形布局算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
private processNodes() {
const data = Array.isArray(this.data) ? this.data : JSON.parse(this.data);
const rows: IRenderItem[][] = [];
const nodesCopy = [...data];
let index = 0;
let currentIndex = 0;
let rowIndex = 0;

while (currentIndex < nodesCopy.length) {
const row: IRenderItem[] = [];
const isEvenRow = rowIndex % 2 === 0;

if (currentIndex === 0) {
row.push({ type: 'start-line', index });
index++;
}

// 每行最多3个节点
for (let i = 0; i < 3 && currentIndex < nodesCopy.length; i++) {
const { solid } = nodesCopy[currentIndex];
row.push({
type: 'node',
index,
...nodesCopy[currentIndex],
});
index++;

if (i === 2 && currentIndex < nodesCopy.length - 1) {
row.push({ type: `${isEvenRow ? 'left' : 'right'}-curve`, solid, index });
index++;
}

if (i < 2 && currentIndex < nodesCopy.length - 1) {
row.push({ type: 'line', solid, index });
index++;
}

if (currentIndex === nodesCopy.length - 1) {
row.push({ type: `${!isEvenRow ? 'start-line' : 'end-line'}`, index });
index++;
}
currentIndex++;
}

// 偶数行正序,奇数行倒序
if (!isEvenRow) {
row.reverse();
}

rows.push(row);
rowIndex++;
}

// 应用渐变色
const colorList = ['#F564B9', '#733ED6', '#265FFC', '#26C6DA'];
const gradientColors = this.getMultiGradientColors(colorList, index + 1);
rows.forEach(row => {
row.forEach(item => {
item.bgColor = gradientColors[item.index];
})
});

this.processedNodes = rows;
}

曲线实现详解

1. SVG 遮罩技术

原理说明

SVG 遮罩技术是曲线实现的核心,通过 SVG <mask> 元素创建复杂的剪切形状,然后将其应用到 CSS 渐变背景上。这种技术的优势在于:

  1. 复杂形状支持: 可以创建传统 CSS 难以实现的曲线形状
  2. 渐变背景兼容: 遮罩可以完美配合 CSS 渐变背景
  3. 矢量图形: 矢量路径确保在任何缩放级别下都保持清晰
  4. 性能优化: GPU 加速的遮罩渲染

遮罩工作机制

SVG 遮罩的工作原理:

  • 白色区域 (fill=”white”): 完全可见,显示背景内容
  • 黑色区域 (fill=”black”): 完全透明,隐藏背景内容
  • 灰色区域: 部分透明,实现渐变效果

左曲线遮罩详细实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 渲染左曲线组件
* @param solid - 连接线是否为实线
* @param bgColor - 渐变背景色
* @returns JSX.Element
*/
private renderLeftCurve({ solid, bgColor }: IRenderItem ) {
return (
// 曲线容器:应用渐变背景和 SVG 遮罩
<div class='left-curve' style={{
background: bgColor, // 渐变背景色
mask: 'url(#cutout-mask)' // 应用 SVG 遮罩
}}>
{/* SVG 定义区域:尺寸设为 0x0,不占用布局空间 */}
<svg width="0" height="0" style={{ position: 'absolute' }}>
<defs>
{/* 定义遮罩:创建曲线形状 */}
<mask id="cutout-mask">
{/*
* 基础白色背景:整个遮罩区域默认完全可见
* 作为遮罩的基础画布
*/}
<rect width="100%" height="100%" fill="white"/>

{/*
* 外层曲线路径:定义曲线的外边界轮廓
* 路径解析:从左上角开始,绘制 60px 半径的右弯弧线
* M 0,0: 移动到坐标 (0,0)
* L 72,0: 画直线到 (72,0)
* A 60,60 0 0 1 132,60: 画半径60px的弧线到 (132,60)
* L 132,105: 画直线到 (132,105)
* A 60,60 0 0 1 72,165: 画半径60px的弧线到 (72,165)
* L 0,165: 画直线到 (0,165)
* Z: 闭合路径
*/}
<path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z" fill="white"/>

{/*
* 内层曲线路径:创建镂空区域,形成 20px 宽度的曲线带
* 使用 40px 半径,与外层路径形成 20px 的间距
* fill="black": 黑色填充表示该区域被镂空
*/}
<path d="M 0,20 L 72,20 A 40,40 0 0 1 112,60 L 112,105 A 40,40 0 0 1 72,145 L 0,145 Z" fill="black"/>
</mask>
</defs>
</svg>

{/* 实线边框:当 solid=true 时显示 */}
<div class='left-curve-solid-top-line'></div>
<div class='left-curve-solid-bottom-line'></div>

{/* 曲线内容区域:包含内层曲线和虚线图标 */}
<div class='left-curve-content' style={{
border: solid ? '2px solid #000000' : '', // 实线模式时显示边框
borderLeft: 'none', // 移除左边框避免重叠
}}>
{/* 内层曲线:提供额外的视觉层次 */}
<div class='left-inner-curve'></div>

{/* 虚线图标:当 solid=false 时显示 */}
{!solid && <img class="left-timeline-stop" src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />}
</div>
</div>
);
}

右曲线遮罩详细实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 渲染右曲线组件
* @param solid - 连接线是否为实线
* @param bgColor - 渐变背景色
* @returns JSX.Element
*/
private renderRightCurve({ solid, bgColor }: IRenderItem ) {
return (
// 右曲线容器:镜像对称的左曲线
<div class='right-curve' style={{
background: bgColor, // 渐变背景色
mask: 'url(#cutout-mask-rotated)' // 应用旋转后的遮罩
}}>
{/* SVG 定义区域 */}
<svg width="0" height="0" style={{ position: 'absolute' }}>
<defs>
{/* 旋转遮罩:右曲线是左曲线的镜像 */}
<mask id="cutout-mask-rotated">
<rect width="100%" height="100%" fill="white"/>

{/*
* 外层曲线路径:左曲线的镜像版本
* 关键差异:sweep-flag 从 1 变为 0,实现逆时针弧线
* M 132,0: 从右上角开始
* L 60,0: 画直线到 (60,0)
* A 60,60 0 0 0 0,60: 逆时针画弧线到 (0,60)
* 其他坐标与左曲线对称
*/}
<path d="M 132,0 L 60,0 A 60,60 0 0 0 0,60 L 0,105 A 60,60 0 0 0 60,165 L 132,165 Z" fill="white"/>

{/*
* 内层曲线路径:同样采用镜像设计
* 形成与左曲线相同宽度的曲线带
*/}
<path d="M 132,20 L 60,20 A 40,40 0 0 0 20,60 L 20,105 A 40,40 0 0 0 60,145 L 132,145 Z" fill="black"/>
</mask>
</defs>
</svg>

{/* 实线边框:与左曲线对称的布局 */}
<div class='right-curve-solid-top-line'></div>
<div class='right-curve-solid-bottom-line'></div>

{/* 右曲线内容区域 */}
<div class='right-curve-content' style={{
border: solid ? '2px solid #000000' : '', // 实线边框
borderRight: 'none', // 移除右边框避免重叠
}}>
{/* 内层曲线:镜像设计 */}
<div class='right-inner-curve'></div>

{/* 虚线图标:旋转 180 度以适应右曲线方向 */}
{!solid && <img class="right-timeline-stop" src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />}
</div>
</div>
);
}

2. SVG 路径解析

左曲线路径

1
2
3
4
5
<!-- 外层曲线路径 -->
<path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z" fill="white"/>

<!-- 内层曲线路径 -->
<path d="M 0,20 L 72,20 A 40,40 0 0 1 112,60 L 112,105 A 40,40 0 0 1 72,145 L 0,145 Z" fill="black"/>

右曲线路径

1
2
3
4
5
<!-- 外层曲线路径 -->
<path d="M 132,0 L 60,0 A 60,60 0 0 0 0,60 L 0,105 A 60,60 0 0 0 60,165 L 132,165 Z" fill="white"/>

<!-- 内层曲线路径 -->
<path d="M 132,20 L 60,20 A 40,40 0 0 0 20,60 L 20,105 A 40,40 0 0 0 60,145 L 132,145 Z" fill="black"/>

SVG 弧线参数解析:

  • A rx,ry x-axis-rotation large-arc-flag,sweep-flag x,y
  • rx,ry: 椭圆半径
  • large-arc-flag: 大弧标志 (0=小弧, 1=大弧)
  • sweep-flag: 弧线方向 (0=逆时针, 1=顺时针)

3. 多层渲染架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 左曲线样式
.left-curve {
position: absolute;
display: flex;
align-items: center;
right: 0px;
top: 0px;
height: 165px;
width: 132px;
border-radius: 0 60px 60px 0;

// 实线边框 - z-index: 5
.left-curve-solid-top-line {
top: 10px;
position: absolute;
width: 35px;
height: 1px;
border-top: var(--viz-timeline-line-color) solid 2px;
z-index: 5;
}

.left-curve-solid-bottom-line {
bottom: 10px;
position: absolute;
width: 35px;
height: 1px;
border-bottom: var(--viz-timeline-line-color) solid 2px;
z-index: 5;
}

// 内容区域 - z-index: 9-10
.left-curve-content {
position: relative;
display: flex;
align-items: center;
width: 122px;
border-radius: 0 50px 50px 0;
height: 145px;

.left-inner-curve {
z-index: 9;
height: 125px;
width: 112px;
border-radius: 0 40px 40px 0;
border-left: none;
}

.left-timeline-stop {
position: absolute;
z-index: 10;
height: 145px;
width: 118px;
left: 12px;
}
}
}

渐变色系统

1. 多色渐变算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private getMultiGradientColors(colors: string[], steps: number): string[] {
// 颜色转换函数
const hexToRgb = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
};

const rgbToHex = (r: number, g: number, b: number, theme: 'light' | 'dark') => {
return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}${theme === 'light' ? '1A' : '3A'}`;
};

// 计算每个颜色段的步数
const segments = colors.length - 1;
const segmentSteps = Math.floor(steps / segments);
const remainder = steps % segments;

let decomposedColors: string[] = [];
for (let i = 0; i < segments; i++) {
const currentSteps = i < remainder ? segmentSteps + 1 : segmentSteps;
const startRgb = hexToRgb(colors[i]);
const endRgb = hexToRgb(colors[i + 1]);

for (let j = 0; j <= currentSteps; j++) {
const ratio = j / currentSteps;
const r = Math.round(startRgb[0] + (endRgb[0] - startRgb[0]) * ratio);
const g = Math.round(startRgb[1] + (endRgb[1] - startRgb[1]) * ratio);
const b = Math.round(startRgb[2] + (endRgb[2] - startRgb[2]) * ratio);
decomposedColors.push(rgbToHex(r, g, b, this.theme));
}
}

// 生成渐变色
let gradientColors: string[] = [];
for (let i = 0; i < steps; i++) {
const startColor = decomposedColors[i];
const endColor = decomposedColors[i + 1];
gradientColors.push(`linear-gradient(90deg, ${startColor}, ${endColor})`);
}

return gradientColors;
}

2. 渐变色应用

1
2
3
4
5
6
7
8
// 在 processNodes 中应用渐变色
const colorList = ['#F564B9', '#733ED6', '#265FFC', '#26C6DA'];
const gradientColors = this.getMultiGradientColors(colorList, index + 1);
rows.forEach((row) => {
row.forEach((item) => {
item.bgColor = gradientColors[item.index];
});
});

主题系统

1. CSS 变量定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 亮色主题
:root {
--viz-timeline-dot-color: #000;
--viz-timeline-line-color: #000;
--viz-timeline-node-title-color: #000;
--viz-timeline-node-time-bg-color: rgba(0, 0, 0, 0.05);
--viz-timeline-curve-cover-color: #fff;
}

// 暗色主题
.dark-theme {
--viz-timeline-dot-color: #fff;
--viz-timeline-line-color: #fff;
--viz-timeline-node-title-color: #fff;
--viz-timeline-node-time-bg-color: rgba(255, 255, 255, 0.1);
--viz-timeline-curve-cover-color: rgba(255, 255, 255, 0.1);
}

2. 主题切换

1
2
3
4
5
6
7
8
// 组件属性
@Prop() theme: 'light' | 'dark' = 'light';

// 在渲染中使用主题
<img
class="left-timeline-stop"
src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop}
/>

关键技术特点

1. 蛇形布局

  • 偶数行从左到右排列
  • 奇数行从右到左排列
  • 每行最多 3 个节点
  • 自动计算连接元素

2. SVG 遮罩技术

  • 使用 SVG mask 创建复杂形状
  • 外层路径定义轮廓
  • 内层路径实现镂空效果
  • 支持 CSS 渐变背景

3. 多层渲染架构

  • z-index 层级管理
  • 绝对定位精确定位
  • 实线/虚线状态切换
  • 镜像对称实现

4. 渐变色系统

  • 多色渐变算法
  • 自动颜色插值
  • 透明度主题适配
  • 连续渐变效果

5. 响应式文本

  • 默认显示 3 行描述
  • 超过行数自动折叠
  • 点击展开完整内容
  • 省略号交互设计

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 数据格式
const timelineData = [
{
time: '2024-01',
title: '项目启动',
description: '项目正式启动\n团队组建完成\n需求分析开始',
point: true,
solid: true,
},
{
time: '2024-02',
title: '设计阶段',
description: 'UI/UX设计\n技术架构设计\n数据库设计',
point: false,
solid: false,
},
// ... 更多节点
];

// 在模板中使用
<story-line data={timelineData} theme="light" />;

性能优化

1. Shadow DOM 封装

  • 避免样式污染
  • 提高组件隔离性

2. 状态管理

  • 使用 @State 实现响应式更新
  • 避免不必要的重新渲染

3. 资源优化

  • SVG 图标预加载
  • 条件渲染减少 DOM 节点
  • CSS 变量减少样式计算

4. 内存优化

  • 组件卸载时清理状态
  • 避免循环引用
  • 合理使用缓存

浏览器兼容性

  • Chrome 60+
  • Firefox 55+
  • Safari 12+
  • Edge 79+

注意: 需要 CSS mask 属性支持,现代浏览器都支持此特性。

扩展性考虑

1. 自定义主题

  • 可以扩展支持更多主题
  • 支持自定义颜色配置

2. 动画效果

  • 可以添加渐变动画
  • 支持节点交互动画

3. 响应式设计

  • 可以适配不同屏幕尺寸
  • 支持移动端交互

4. 数据绑定

  • 支持动态数据更新
  • 可以集成状态管理

总结

这个时间线组件展示了现代前端开发的多个高级特性:

  • Web Components 封装
  • SVG 图形处理 技术
  • CSS 高级特性 应用
  • TypeScript 类型系统
  • 响应式设计 理念

通过综合运用这些技术,实现了一个功能完善、视觉效果优秀的时间线组件,为复杂的时间序列数据可视化提供了专业的解决方案。


自适应和缩放解决方案

当前方案的问题分析

原始实现存在以下自适应问题:

  1. 固定尺寸限制

    1
    2
    3
    4
    5
    .left-curve {
    height: 165px; // 固定高度
    width: 132px; // 固定宽度
    border-radius: 0 60px 60px 0; // 固定圆角
    }
  2. SVG 路径硬编码

    1
    <path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z"/>
  3. 像素级定位

    1
    2
    3
    4
    .left-curve-solid-top-line {
    top: 10px;
    width: 35px;
    }

解决方案一:CSS Transform 缩放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 基础尺寸配置 - 作为缩放的基准
*/
const CURVE_BASE_SIZE = {
width: 132, // 基准宽度
height: 165, // 基准高度
cornerRadius: 60, // 基准圆角半径
strokeWidth: 20 // 基准线条宽度
};

/**
* 动态计算缩放比例
* 基于容器宽度和节点间距计算合适的缩放比例
*/
private calculateScale(): number {
const containerWidth = this.el.clientWidth;
const nodeSpacing = containerWidth / 3; // 每行3个节点
return Math.min(1, nodeSpacing / 200); // 基于容器宽度缩放
}

/**
* 应用缩放样式到曲线元素
* @param scale - 计算得出的缩放比例
* @returns CSSStyleDeclaration
*/
private getCurveStyle(scale: number) {
return {
transform: `scale(${scale})`, // 应用缩放
transformOrigin: 'top left', // 设置缩放原点
width: `${CURVE_BASE_SIZE.width}px`, // 保持基准宽度
height: `${CURVE_BASE_SIZE.height}px` // 保持基准高度
};
}

解决方案二:SVG 路径动态生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/**
* 曲线配置接口
*/
interface CurveConfig {
width: number; // 曲线宽度
height: number; // 曲线高度
outerRadius: number; // 外层圆弧半径
innerRadius: number; // 内层圆弧半径
strokeWidth: number; // 线条宽度
}

/**
* 动态生成 SVG 路径
* @param config - 曲线配置参数
* @param isLeft - 是否为左曲线
* @returns 外层路径和内层路径
*/
private generateSVGPath(config: CurveConfig, isLeft: boolean = true): {outerPath: string, innerPath: string} {
const { width, height, outerRadius, innerRadius, strokeWidth } = config;

// 计算圆心位置
const centerY = height / 2;

// 计算起始和结束角度
const startAngle = isLeft ? -Math.PI/2 : Math.PI/2;
const endAngle = isLeft ? Math.PI/2 : -Math.PI/2;

// 外弧路径计算
const outerStartX = isLeft ? width - outerRadius : outerRadius;
const outerStartY = centerY - outerRadius;
const outerEndX = isLeft ? width - outerRadius : outerRadius;
const outerEndY = centerY + outerRadius;

// 生成外弧路径字符串
const outerPath = `M ${isLeft ? width : 0},${centerY - outerRadius}
A ${outerRadius},${outerRadius} 0 0,${isLeft ? 1 : 0} ${outerEndX},${outerEndY}`;

// 内弧路径计算
const innerStartX = isLeft ? width - innerRadius : innerRadius;
const innerStartY = centerY - innerRadius;
const innerEndX = isLeft ? width - innerRadius : innerRadius;
const innerEndY = centerY + innerRadius;

// 生成内弧路径字符串
const innerPath = `M ${isLeft ? width : 0},${centerY - innerRadius}
A ${innerRadius},${innerRadius} 0 0,${isLeft ? 1 : 0} ${innerEndX},${innerEndY}`;

return { outerPath, innerPath };
}

/**
* 渲染动态曲线组件
* @param config - 曲线配置
* @param bgColor - 背景色
* @param solid - 是否为实线
* @param isLeft - 是否为左曲线
* @returns JSX.Element
*/
private renderDynamicCurve(config: CurveConfig, bgColor: string, solid: boolean, isLeft: boolean): JSX.Element {
const { outerPath, innerPath } = this.generateSVGPath(config, isLeft);
const maskId = isLeft ? 'dynamic-cutout-mask' : 'dynamic-cutout-mask-right';

return (
<div class={`${isLeft ? 'left' : 'right'}-curve dynamic-curve`} style={{
background: bgColor,
mask: `url(#${maskId})`,
width: `${config.width}px`,
height: `${config.height}px`
}}>
{/* 动态 SVG 遮罩定义 */}
<svg width="0" height="0" style={{ position: 'absolute' }}>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white"/>
<path d={outerPath} fill="white"/>
<path d={innerPath} fill="black"/>
</mask>
</defs>
</svg>

{/* 实线边框元素 */}
<div class={`${isLeft ? 'left' : 'right'}-curve-solid-top-line`}></div>
<div class={`${isLeft ? 'left' : 'right'}-curve-solid-bottom-line`}></div>

{/* 曲线内容区域 */}
<div class={`${isLeft ? 'left' : 'right'}-curve-content`} style={{
border: solid ? '2px solid #000000' : '',
[isLeft ? 'borderLeft' : 'borderRight']: 'none',
}}>
<div class={`${isLeft ? 'left' : 'right'}-inner-curve`}></div>
{!solid && (
<img class={`${isLeft ? 'left' : 'right'}-timeline-stop`}
src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />
)}
</div>
</div>
);
}

解决方案三:CSS 变量动态计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/**
* 使用 CSS 变量实现动态尺寸和响应式曲线
* 通过 CSS 变量计算实际尺寸,避免硬编码
*/

:root {
/* 基础尺寸变量 */
--curve-width: 132px;
--curve-height: 165px;
--curve-outer-radius: 60px;
--curve-inner-radius: 40px;
--curve-stroke-width: 20px;
--curve-spacing: 10px;
}

/* 响应式曲线容器 */
.timeline-curve-container {
/* 缩放比例变量,由 JavaScript 动态设置 */
--curve-scale: 1;

/* 实际尺寸计算 */
--actual-width: calc(var(--curve-width) * var(--curve-scale));
--actual-height: calc(var(--curve-height) * var(--curve-scale));
--actual-outer-radius: calc(var(--curve-outer-radius) * var(--curve-scale));
--actual-inner-radius: calc(var(--curve-inner-radius) * var(--curve-scale));
--actual-stroke-width: calc(var(--curve-stroke-width) * var(--curve-scale));

/* 应用计算后的尺寸 */
width: var(--actual-width);
height: var(--actual-height);
}

/* 左曲线动态样式 */
.left-curve {
border-radius: 0 var(--actual-outer-radius) var(--actual-outer-radius) 0;

.left-curve-content {
/* 减去边距后的内容区域尺寸 */
width: calc(var(--actual-width) - 10px * var(--curve-scale));
height: calc(var(--actual-height) - 20px * var(--curve-scale));
border-radius: 0 calc(
var(--actual-outer-radius) - 10px * var(--curve-scale)
) calc(var(--actual-outer-radius) - 10px * var(--curve-scale)) 0;

.left-inner-curve {
/* 内层曲线尺寸 */
width: calc(var(--actual-width) - 20px * var(--curve-scale));
height: calc(var(--actual-height) - 40px * var(--curve-scale));
border-radius: 0 calc(
var(--actual-inner-radius) - 10px * var(--curve-scale)
) calc(var(--actual-inner-radius) - 10px * var(--curve-scale)) 0;
}
}
}

/* 右曲线动态样式 */
.right-curve {
border-radius: var(--actual-outer-radius) 0 0 var(--actual-outer-radius);

.right-curve-content {
width: calc(var(--actual-width) - 10px * var(--curve-scale));
height: calc(var(--actual-height) - 20px * var(--curve-scale));
border-radius: calc(var(--actual-outer-radius) - 10px * var(--curve-scale)) 0
0 calc(var(--actual-outer-radius) - 10px * var(--curve-scale));

.right-inner-curve {
width: calc(var(--actual-width) - 20px * var(--curve-scale));
height: calc(var(--actual-height) - 40px * var(--curve-scale));
border-radius: calc(
var(--actual-inner-radius) - 10px * var(--curve-scale)
) 0 0 calc(var(--actual-inner-radius) - 10px * var(--curve-scale));
}
}
}

解决方案四:响应式布局系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/**
* 响应式断点配置
*/
const BREAKPOINTS = {
mobile: 480, // 移动端断点
tablet: 768, // 平板端断点
desktop: 1024, // 桌面端断点
large: 1440, // 大屏幕断点
};

/**
* 响应式配置对象
*/
const RESPONSIVE_CONFIG = {
mobile: {
nodesPerRow: 1, // 每行1个节点
curveWidth: 80, // 曲线宽度
curveHeight: 100, // 曲线高度
fontSize: 12, // 字体大小
spacing: 15, // 间距
},
tablet: {
nodesPerRow: 2, // 每行2个节点
curveWidth: 100, // 曲线宽度
curveHeight: 120, // 曲线高度
fontSize: 14, // 字体大小
spacing: 20, // 间距
},
desktop: {
nodesPerRow: 3, // 每行3个节点
curveWidth: 132, // 曲线宽度
curveHeight: 165, // 曲线高度
fontSize: 16, // 字体大小
spacing: 25, // 间距
},
};

/**
* 响应式时间线类
* 管理断点检测和布局更新
*/
class ResponsiveTimeline {
private currentBreakpoint: string;
private resizeObserver: ResizeObserver;

constructor(private element: HTMLElement) {
this.setupResizeObserver();
this.updateLayout();
}

/**
* 设置 ResizeObserver 监听容器尺寸变化
*/
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver((entries) => {
this.updateLayout();
});

this.resizeObserver.observe(this.element);
}

/**
* 获取当前断点
* @returns 当前断点名称
*/
private getCurrentBreakpoint(): string {
const width = this.element.clientWidth;

if (width <= BREAKPOINTS.mobile) return 'mobile';
if (width <= BREAKPOINTS.tablet) return 'tablet';
if (width <= BREAKPOINTS.desktop) return 'desktop';
return 'large';
}

/**
* 更新布局配置
*/
private updateLayout() {
const newBreakpoint = this.getCurrentBreakpoint();

if (newBreakpoint !== this.currentBreakpoint) {
this.currentBreakpoint = newBreakpoint;
this.applyLayout();
}
}

/**
* 应用布局配置
*/
private applyLayout() {
const config = RESPONSIVE_CONFIG[this.currentBreakpoint];

// 更新CSS变量
this.element.style.setProperty('--curve-width', `${config.curveWidth}px`);
this.element.style.setProperty('--curve-height', `${config.curveHeight}px`);
this.element.style.setProperty('--font-size', `${config.fontSize}px`);
this.element.style.setProperty('--spacing', `${config.spacing}px`);
this.element.style.setProperty(
'--nodes-per-row',
config.nodesPerRow.toString()
);

// 触发重新渲染
this.processNodes(config.nodesPerRow);
}

/**
* 根据新的节点数重新处理布局
* @param nodesPerRow - 每行节点数
*/
private processNodes(nodesPerRow: number) {
// 重新计算节点布局逻辑
// ...
}

/**
* 清理资源
*/
destroy() {
this.resizeObserver.disconnect();
}
}

综合解决方案:CSS Grid + SVG 动态生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* 推荐的综合解决方案
* 结合 CSS Grid 布局和 SVG 动态生成的优势
*/
@Component({
tag: 'responsive-story-line',
styleUrl: 'responsive-story-line.scss',
shadow: true,
})
export class ResponsiveStoryLine {
@Prop() data!: NodeItem[] | string;
@Prop() theme: 'light' | 'dark' = 'light';

@Element() el: HTMLElement;
@State() processedNodes: IRenderItem[][] = [];
@State() layoutConfig: LayoutConfig;

private resizeObserver: ResizeObserver;

componentDidLoad() {
this.setupResizeObserver();
this.updateLayout();
}

disconnectedCallback() {
this.resizeObserver?.disconnect();
}

/**
* 设置 ResizeObserver 监听容器尺寸变化
*/
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.updateLayout();
});

this.resizeObserver.observe(this.el);
}

/**
* 更新布局配置
*/
private updateLayout() {
const width = this.el.clientWidth;
this.layoutConfig = this.calculateLayoutConfig(width);
this.processNodes();
}

/**
* 计算布局配置
* @param width - 容器宽度
* @returns 布局配置对象
*/
private calculateLayoutConfig(width: number): LayoutConfig {
const nodesPerRow = width >= 1024 ? 3 : width >= 768 ? 2 : 1;
const scale = Math.min(1, width / (nodesPerRow * 200));

return {
nodesPerRow,
scale,
curveWidth: 132 * scale,
curveHeight: 165 * scale,
nodeSpacing: (width - 100) / nodesPerRow,
};
}

/**
* 动态生成 SVG 路径
* @param config - 布局配置
* @param isLeft - 是否为左曲线
* @returns SVG 路径字符串
*/
private generateSVGPath(config: LayoutConfig, isLeft: boolean): string {
const { curveWidth, curveHeight } = config;
const radius = curveHeight / 2;

if (isLeft) {
return `M ${curveWidth - radius},0 A ${radius},${radius} 0 0,1 ${
curveWidth - radius
},${curveHeight}`;
} else {
return `M ${radius},0 A ${radius},${radius} 0 0,0 ${radius},${curveHeight}`;
}
}

/**
* 渲染曲线组件
* @param config - 布局配置
* @param isLeft - 是否为左曲线
* @returns JSX.Element
*/
private renderCurve(config: LayoutConfig, isLeft: boolean): JSX.Element {
const maskId = isLeft ? 'curve-mask-left' : 'curve-mask-right';
const path = this.generateSVGPath(config, isLeft);

return (
<div
class={`curve ${isLeft ? 'left' : 'right'}`}
style={{
width: `${config.curveWidth}px`,
height: `${config.curveHeight}px`,
transform: `scale(${config.scale})`,
transformOrigin: isLeft ? 'top right' : 'top left',
}}
>
<svg width="0" height="0">
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<path d={path} fill="white" stroke="black" strokeWidth="20" />
</mask>
</defs>
</svg>
</div>
);
}

render() {
return (
<div
class="responsive-timeline"
style={{
'--curve-scale': this.layoutConfig.scale,
'--nodes-per-row': this.layoutConfig.nodesPerRow,
}}
>
{this.processedNodes.map((row, rowIndex) => (
<div class="timeline-row" key={rowIndex}>
{row.map((item, itemIndex) => {
if (item.type === 'left-curve') {
return this.renderCurve(this.layoutConfig, true);
} else if (item.type === 'right-curve') {
return this.renderCurve(this.layoutConfig, false);
}
// 其他元素渲染...
})}
</div>
))}
</div>
);
}
}

CSS Grid 布局方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
* CSS Grid 响应式布局方案
* 使用 Grid 布局实现真正的自适应
*/
.responsive-timeline {
display: grid;
gap: 20px;
width: 100%;

.timeline-row {
display: grid;
grid-template-columns: repeat(var(--nodes-per-row), 1fr);
gap: 20px;
align-items: center;
position: relative;

/* 时间线基线 */
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background: var(--viz-timeline-line-color);
z-index: 1;
}
}

.timeline-node {
position: relative;
z-index: 2;
background: var(--viz-card-bg-color);
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

.node-point {
width: 12px;
height: 12px;
background: var(--viz-timeline-dot-color);
border-radius: 50%;
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
}
}

.curve {
position: absolute;
top: 100%;
z-index: 3;

&.left {
right: 0;
transform-origin: top right;
}

&.right {
left: 0;
transform-origin: top left;
}
}
}

/* 响应式媒体查询 */
@media (max-width: 768px) {
.responsive-timeline {
.timeline-row {
grid-template-columns: 1fr;
}
}
}

@media (max-width: 480px) {
.responsive-timeline {
gap: 15px;

.timeline-row {
gap: 15px;
}

.timeline-node {
padding: 12px;
font-size: 14px;
}
}
}

性能优化建议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 1. 防抖处理
* 避免频繁的 resize 事件触发重绘
*/
private resizeObserver = new ResizeObserver(
debounce(() => this.updateLayout(), 100)
);

/**
* 2. 路径缓存机制
* 避免重复计算相同的 SVG 路径
*/
private pathCache = new Map<string, string>();

private generateSVGPath(config: LayoutConfig, isLeft: boolean): string {
const cacheKey = `${config.curveWidth}-${config.curveHeight}-${isLeft}`;

if (this.pathCache.has(cacheKey)) {
return this.pathCache.get(cacheKey)!;
}

const path = this.calculatePath(config, isLeft);
this.pathCache.set(cacheKey, path);
return path;
}

/**
* 3. 虚拟化渲染
* 只渲染可见区域的节点,提升大数据量性能
*/
private getVisibleNodes(): IRenderItem[][] {
const scrollTop = this.el.scrollTop;
const clientHeight = this.el.clientHeight;

return this.processedNodes.filter((row, index) => {
const rowTop = index * 200; // 估算每行高度
return rowTop + 200 >= scrollTop && rowTop <= scrollTop + clientHeight;
});
}

推荐方案总结

最佳实践: CSS Grid + SVG 动态生成 的综合方案

优势:

  • ✅ 完美的自适应能力
  • ✅ 保持原有的视觉效果
  • ✅ 优秀的性能表现
  • ✅ 良好的代码可维护性
  • ✅ 支持各种屏幕尺寸

适用场景:

  • 响应式 Web 应用
  • 移动端适配
  • 大屏可视化
  • 动态数据展示

通过这些解决方案,可以显著提升曲线组件的自适应能力,为用户提供更好的跨设备体验。