技术拆解-时间轴曲线组件
概述
这是一个基于 Stencil 框架的高级时间线组件,实现了蛇形布局和复杂曲线连接效果。组件使用 SVG 遮罩技术、多层渲染架构和渐变色系统,提供了专业级的可视化体验。
技术栈
- 框架: Stencil (Web Components)
- 语言: TypeScript
- 样式: SCSS + UnoCSS
- 图形: SVG 遮罩
- 构建: Rollup
核心架构
1. 数据结构设计
// 节点数据接口
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. 蛇形布局算法
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 渐变背景上。这种技术的优势在于:
- 复杂形状支持: 可以创建传统 CSS 难以实现的曲线形状
- 渐变背景兼容: 遮罩可以完美配合 CSS 渐变背景
- 矢量图形: 矢量路径确保在任何缩放级别下都保持清晰
- 性能优化: GPU 加速的遮罩渲染
遮罩工作机制
SVG 遮罩的工作原理:
- 白色区域 (fill=“white”): 完全可见,显示背景内容
- 黑色区域 (fill=“black”): 完全透明,隐藏背景内容
- 灰色区域: 部分透明,实现渐变效果
左曲线遮罩详细实现
/**
* 渲染左曲线组件
* @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>
);
}
右曲线遮罩详细实现
/**
* 渲染右曲线组件
* @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 路径解析
左曲线路径
<!-- 外层曲线路径 -->
<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"/>
右曲线路径
<!-- 外层曲线路径 -->
<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,yrx,ry: 椭圆半径large-arc-flag: 大弧标志 (0=小弧, 1=大弧)sweep-flag: 弧线方向 (0=逆时针, 1=顺时针)
3. 多层渲染架构
// 左曲线样式
.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. 多色渐变算法
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. 渐变色应用
// 在 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 变量定义
// 亮色主题
: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. 主题切换
// 组件属性
@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 行描述
- 超过行数自动折叠
- 点击展开完整内容
- 省略号交互设计
使用示例
// 数据格式
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 类型系统
- 响应式设计 理念
通过综合运用这些技术,实现了一个功能完善、视觉效果优秀的时间线组件,为复杂的时间序列数据可视化提供了专业的解决方案。
自适应和缩放解决方案
当前方案的问题分析
原始实现存在以下自适应问题:
-
固定尺寸限制
.left-curve { height: 165px; // 固定高度 width: 132px; // 固定宽度 border-radius: 0 60px 60px 0; // 固定圆角 } -
SVG 路径硬编码
<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"/> -
像素级定位
.left-curve-solid-top-line { top: 10px; width: 35px; }
解决方案一:CSS Transform 缩放
/**
* 基础尺寸配置 - 作为缩放的基准
*/
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 路径动态生成
/**
* 曲线配置接口
*/
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 变量动态计算
/**
* 使用 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));
}
}
}
解决方案四:响应式布局系统
/**
* 响应式断点配置
*/
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 动态生成
/**
* 推荐的综合解决方案
* 结合 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 布局方案
/**
* 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. 防抖处理
* 避免频繁的 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 应用
- 移动端适配
- 大屏可视化
- 动态数据展示
通过这些解决方案,可以显著提升曲线组件的自适应能力,为用户提供更好的跨设备体验。