🎯 用 Vue + SVG 实现一个「蛇形时间轴」组件,打造高颜值事件流程图
在数据可视化或大屏项目中,我们常常需要展示一系列的事件流程,比如飞行轨迹、操作日志、任务执行顺序等。本文将带你一步步实现一个基于 Vue + SVG 的 蛇形排列时间轴组件,支持动态数据渲染、自适应布局与美观样式。
📌 效果预览
先来看一下最终效果(简化描述):
- 每行最多显示 5 个节点;
- 偶数行从左往右排布,奇数行从右往左,形成"蛇形"布局;
- 节点之间用带箭头的线段连接;
- 每个节点包含时间和标签信息;
- 样式美观,适配深色背景大屏风格。

🧩 组件结构概览
这是一个标准的 Vue 单文件组件(SFC),由以下几个部分组成:
✅ <template>
部分
使用 SVG
渲染图形元素:
- 箭头定义(
<marker>
) - 连线(
<line>
) - 节点圆点(
<circle>
) - 时间文本(
<text>
) - 标签文本(
<text>
)
📊 <script>
部分
-
定义了原始事件数据
dataList
-
设置每行最大节点数
maxPerRow
-
使用计算属性动态生成:
- 节点坐标(蛇形排列)
- 连线路径(两端缩进避免重叠)
- SVG 宽高(根据数据长度自动调整)
🎨 <style scoped>
部分
- 使用背景图片和文字渐变效果打造科技感外观;
- 标题栏使用
-webkit-background-clip: text
技术实现渐变文字。
🔍 关键技术点详解
1️⃣ 蛇形布局算法
kotlin
深色版本
const row = Math.floor(idx / this.maxPerRow)
const col = idx % this.maxPerRow
if (row % 2 === 0) {
x = leftMargin + col * this.nodeGapX
} else {
x = leftMargin + (this.maxPerRow - 1 - col) * this.nodeGapX
}
通过判断当前是偶数行还是奇数行,控制节点的排列方向,实现蛇形布局。
2️⃣ 动态连线绘制
使用向量数学方法计算两点之间的连线,并在两端留出一定间隙,避免覆盖节点:
ini
深色版本
const dx = x2 - x1
const dy = y2 - y1
const len = Math.sqrt(dx * dx + dy * dy)
const ratioStart = gap / len
const ratioEnd = (len - gap) / len
3️⃣ SVG 自适应宽高
kotlin
深色版本
svgWidth() {
return this.maxPerRow * this.nodeGapX + 100
},
svgHeight() {
return Math.ceil(this.dataList.length / this.maxPerRow) * this.nodeGapY + 40
}
根据数据长度和每行节点数,自动计算 SVG 容器尺寸。
💡 可扩展性建议
虽然该组件已经能很好地满足基础需求,但还可以进一步增强功能和灵活性:
功能 | 实现方式 |
---|---|
✅ 支持点击事件 | 给 <circle> 添加 @click 事件 |
🎨 主题定制 | 将颜色提取为 props 或 CSS 变量 |
📱 响应式适配 | 使用百分比宽度或监听窗口变化 |
🎥 动画过渡 | 添加 SVG 动画或 Vue transition |
📦 如何复用这个组件?
你可以将它封装成一个通用组件,接收如下 props:
yaml
深色版本
props: {
dataList: { type: Array, required: true },
maxPerRow: { type: Number, default: 5 },
nodeGapX: { type: Number, default: 200 },
nodeGapY: { type: Number, default: 100 },
themeColor: { type: String, default: '#fff' }
}
这样就可以在多个页面中复用,只需传入不同的事件数据即可。
🧠 源码(示例)
> <div class="container">
<div class="svg-timeline">
<div class="title">
事件流程
</div>
<svg :width="svgWidth" :height="svgHeight">
<!-- 连线 -->
<line
v-for="(line, idx) in lines"
:key="'line' + idx"
:x1="line.x1"
:y1="line.y1"
:x2="line.x2"
:y2="line.y2"
stroke="#fff"
stroke-width="2"
marker-end="url(#arrow)"
/>
<!-- 箭头定义 -->
<defs>
<marker
id="arrow"
markerWidth="6"
markerHeight="6"
refX="6"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L6,3 L0,6" fill="#fff" />
</marker>
</defs>
<!-- 节点 -->
<circle
v-for="(node, idx) in nodes.slice(0, nodes.length - 1)"
:key="'circle' + idx"
:cx="node.x"
:cy="node.y"
r="4"
fill="#fff"
stroke="#fff"
/>
<text
v-for="(node, idx) in nodes"
:key="'time' + idx"
:x="node.x + 10"
:y="node.y + 30"
text-anchor="start"
fill="#fff"
font-size="14"
>
{{ node.time }}
</text>
<text
v-for="(node, idx) in nodes"
:key="'label' + idx"
:x="node.x + 10"
:y="node.y + 55"
text-anchor="start"
fill="#00eaff"
font-size="16"
font-weight="bold"
>
{{ node.label }}
</text>
</svg>
</div>
</div>
</template>
<script>
export default {
data() {
return {
dataList: [
{ time: '2025-07-08 14:20', label: '起飞' },
{ time: '2025-07-08 14:22', label: '转弯' },
{ time: '2025-07-08 14:25', label: '发现问题' },
{ time: '2025-07-08 14:27', label: '飞行' },
{ time: '2025-07-08 14:29', label: '飞行' },
{ time: '2025-07-08 14:31', label: '飞行' },
{ time: '2025-07-08 14:33', label: '转弯' },
{ time: '2025-07-08 14:35', label: '飞行' },
{ time: '2025-07-08 14:37', label: '降落' },
{ time: '2025-07-08 14:39', label: '降落' },
{ time: '2025-07-08 14:41', label: '返航' }
],
maxPerRow: 5,
nodeGapX: 200,
nodeGapY: 100
}
},
computed: {
nodes() {
// 计算每个节点的坐标(蛇形)
return this.dataList.map((item, idx) => {
const row = Math.floor(idx / this.maxPerRow)
const col = idx % this.maxPerRow
let x, y
const leftMargin = 50 // 你可以自定义这个值
if (row % 2 === 0) {
x = leftMargin + col * this.nodeGapX
} else {
x = leftMargin + (this.maxPerRow - 1 - col) * this.nodeGapX
}
// 节点纵坐标起始值
y = 60 + row * this.nodeGapY
return { ...item, x, y }
})
},
lines() {
const arr = []
const gap = 10 // 间隔长度
for (let i = 0; i < this.nodes.length - 1; i++) {
const x1 = this.nodes[i].x
const y1 = this.nodes[i].y
const x2 = this.nodes[i + 1].x
const y2 = this.nodes[i + 1].y
const dx = x2 - x1
const dy = y2 - y1
const len = Math.sqrt(dx * dx + dy * dy)
// 计算起点和终点都缩进 gap
const ratioStart = gap / len
const ratioEnd = (len - gap) / len
const sx = x1 + dx * ratioStart
const sy = y1 + dy * ratioStart
const tx = x1 + dx * ratioEnd
const ty = y1 + dy * ratioEnd
arr.push({
x1: sx,
y1: sy,
x2: tx,
y2: ty
})
}
return arr
},
svgWidth() {
return this.maxPerRow * this.nodeGapX + 100
},
svgHeight() {
// SVG高度
return Math.ceil(this.dataList.length / this.maxPerRow) * this.nodeGapY + 40
}
}
}
</script>
<style scoped>
.container {
width: 100%;
height: 100%;
background: url('~@/assets/images/chat/backs.png') no-repeat;
display: flex;
justify-content: center;
align-items: center;
}
.svg-timeline {
width: 843px;
background-size: 100% 100%;
position: relative;
.title {
position: absolute;
top: 0;
left: 32px;
width: 100%;
height: 100%;
font-family: YouSheBiaoTiHei;
font-size: 16px;
color: #ffffff;
line-height: 24px;
background: linear-gradient(90deg, #ffffff 0%, #79c2ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
height: 40px;
img {
width: 12px;
height: 24px;
}
}
}
</style>
📢 结语
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏并分享给更多需要的朋友。也欢迎关注我,后续将持续分享前端可视化、Vue 高阶组件、大屏设计等相关内容!