阅读视图

发现新文章,点击刷新页面。

🎯 用 Vue + SVG 实现一个「蛇形时间轴」组件,打造高颜值事件流程图

🎯 用 Vue + SVG 实现一个「蛇形时间轴」组件,打造高颜值事件流程图

在数据可视化或大屏项目中,我们常常需要展示一系列的事件流程,比如飞行轨迹、操作日志、任务执行顺序等。本文将带你一步步实现一个基于 Vue + SVG蛇形排列时间轴组件,支持动态数据渲染、自适应布局与美观样式。

📌 效果预览

先来看一下最终效果(简化描述):

  • 每行最多显示 5 个节点;
  • 偶数行从左往右排布,奇数行从右往左,形成“蛇形”布局;
  • 节点之间用带箭头的线段连接;
  • 每个节点包含时间和标签信息;
  • 样式美观,适配深色背景大屏风格。

image.png


🧩 组件结构概览

这是一个标准的 Vue 单文件组件(SFC),由以下几个部分组成:

✅ <template> 部分

使用 SVG 渲染图形元素:

  • 箭头定义(<marker>
  • 连线(<line>
  • 节点圆点(<circle>
  • 时间文本(<text>
  • 标签文本(<text>

📊 <script> 部分

  • 定义了原始事件数据 dataList

  • 设置每行最大节点数 maxPerRow

  • 使用计算属性动态生成:

    • 节点坐标(蛇形排列)
    • 连线路径(两端缩进避免重叠)
    • SVG 宽高(根据数据长度自动调整)

🎨 <style scoped> 部分

  • 使用背景图片和文字渐变效果打造科技感外观;
  • 标题栏使用 -webkit-background-clip: text 技术实现渐变文字。

🔍 关键技术点详解

1️⃣ 蛇形布局算法

深色版本
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️⃣ 动态连线绘制

使用向量数学方法计算两点之间的连线,并在两端留出一定间隙,避免覆盖节点:

深色版本
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 自适应宽高

深色版本
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:

深色版本
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 高阶组件、大屏设计等相关内容!

❌