被需求破防了,我用canvas画了蛇形时间轴(附源码)

Canvas实现蛇形时间轴动画

前言

大家好,我是大华。

今天给大家分享一个酷炫的蛇形时间轴动画组件

在日常开发中,我们经常需要展示时间线信息。比如:

  • 公司发展历程
  • 产品迭代历史
  • 项目里程碑
  • 个人成长轨迹

传统的静态时间轴就是一条直线加几个点,视觉上非常单调,用户很容易走神。

而今天分享的这个蛇形时间轴组件,具备以下三大优势:

视觉冲击力强:波浪形布局 + 自动播放动画
交互体验好:悬停显示详情,点击触发事件
高度可定制:支持亮暗主题、多种配置选项

效果图:

波浪幅度的调节

主题色的切换

数字和图标的切换


一、技术架构解析

先来看看整体的组件结构:

<template>
  <div class="snake-timeline-container">
    <canvas></canvas>               <!-- 主画布 -->
    <div class="tooltip"></div>     <!-- 提示框 -->
    <div class="control-panel"></div>  <!-- 控制按钮 -->
    <div class="config-panel"></div>   <!-- 配置面板 -->
    <div class="loading-overlay"></div> <!-- 加载动画 -->
  </div>
</template>

五大模块分工明确:

模块功能说明
canvas负责绘制时间轴图形(节点、连线、动画)
tooltip鼠标悬停时展示节点详细信息
control-panel提供播放/暂停、快进等控制按钮
config-panel调整参数,实时预览效果
loading-overlay加载状态提示,提升用户体验

二、核心技术实现

1. 双缓冲绘图优化性能

如果直接在主canvas上频繁重绘性能开销会比较大。所以我们采用双缓冲技术提升渲染效率。

// 初始化离屏 canvas
offScreenCanvas.value = document.createElement('canvas');
offScreenCanvas.value.width = width;
offScreenCanvas.value.height = height;
offScreenCtx.value = offScreenCanvas.value.getContext('2d');
绘制流程:
  1. 静态内容(如背景、固定节点)绘制到离屏 canvas
  2. 动态内容(如进度动画、高亮效果)在主 canvas 上叠加
  3. 每帧只重绘动态部分,大幅减少计算量

优势:避免重复绘制静态内容,显著提升帧率(可达 60fps)


2. 波浪形布局算法

时间轴不再是笔直的线,而是优雅的正弦波浪形

const y = height / 2 + Math.sin(index * amplitude / 100) * amplitude;
关键参数说明:
参数含义
amplitude波浪幅度,控制上下波动的强度
index当前节点的序号
height/2基准线位置(居中)

效果:节点沿波浪线分布,形成“蛇形”视觉动效


3. 动画进度控制

使用时间驱动实现无缝循环动画

const now = Date.now() * speed;
const activeIndex = Math.floor((now % 8000) / 8000 * nodes.length);
技巧点解析:
  • Date.now()获取当前时间戳
  • speed控制动画播放速度(可配置)
  • 8000表示完整动画周期为 8 秒
  • %实现循环播放,无缝衔接

用户看到的是一个自动“游动”的高亮节点,沿着时间轴前进


4. 贝塞尔曲线连接

节点之间使用三次贝塞尔曲线连接,比直线更柔和自然。

ctx.bezierCurveTo(
  cpX1, cpY1,  // 控制点1
  cpX2, cpY2,  // 控制点2
  nextNode.x, nextNode.y // 终点
);

效果:线条流畅,富有动感,增强整体视觉美感


三、深入技术细节

1. 响应式设计

适配不同屏幕尺寸,使用 ResizeObserver 监听容器变化:

resizeObserver.value = new ResizeObserver(entries => {
  initCanvas(); // 尺寸变化时重新初始化画布
});
移动端特别优化:
@media (max-width: 768px) {
  .snake-timeline-container {
    height: 400px;
  }
}

📱 保证在手机端也能清晰展示,交互友好


2. 主题切换

支持亮色 / 暗色双主题,通过计算属性动态切换:

const theme = computed(() => 
  isDarkMode.value ? darkTheme : lightTheme
);

所有颜色、描边、字体等均基于theme计算,切换主题只需修改isDarkMode状态。

适用于 PPT 演示、夜间模式等场景


3. 性能优化技巧

确保动画流畅运行在 60fps,关键优化手段:

  1. 静态内容缓存到离屏 canvas
  2. 使用 requestAnimationFrame 控制帧率
  3. 减少不必要的重绘区域
  4. 利用 CSS 硬件加速(如 transform: translateZ(0)

4. 精准交互检测

鼠标悬停检测使用距离计算法

const distance = Math.sqrt(
  Math.pow(canvasX - node.x, 2) + 
  Math.pow(canvasY - node.y, 2)
);
node.isHovered = distance <= node.radius * hoverScale;

实现节点悬停识别,避免误触


四、配置项详解

组件提供丰富可配置参数,支持实时调整:

const config = ref({
  nodeRadius: 14,           // 节点半径
  lineWidth: 4,            // 连线宽度
  animationSpeed: 0.8,     // 动画速度(倍率)
  waveAmplitude: 120,      // 波浪幅度
  hoverScale: 1.3,         // 悬停放大比例
  showProgressDots: true   // 是否显示进度点
});

实时调节面板示例:

<div class="config-panel">
  <label>
    <input type="range" v-model="config.waveAmplitude" min="50" max="200">
    波浪幅度: {{ config.waveAmplitude }}
  </label>
</div>

🛠️ 开发者或运营人员可自由调整样式,无需修改代码


五、完整源代码

<template>
  <div class="snake-timeline-container" ref="containerRef">
    <canvas 
      ref="canvasRef" 
      @mousemove="handleMouseMove"
      @click="handleClick"
      @mouseleave="handleMouseLeave"
    ></canvas>
    
    <!-- 提示框 -->
    <div 
      v-if="activeTooltip && showTooltip" 
      class="tooltip"
      :style="tooltipStyle"
    >
      <div class="tooltip-header">
        <span class="year-badge">{{ activeTooltip.year }}</span>
        <h3>{{ activeTooltip.event }}</h3>
      </div>
      <p class="tooltip-desc">{{ activeTooltip.description }}</p>
    </div>

    <!-- 控制面板 -->
    <div class="control-panel">
      <button @click="toggleAnimation" class="control-btn" :title="isPlaying ? '暂停' : '播放'">
        {{ isPlaying ? '⏸️' : '▶️' }}
      </button>
      <button @click="toggleTheme" class="control-btn" :title="isDarkMode ? '亮色模式' : '暗色模式'">
        {{ isDarkMode ? '☀️' : '🌙' }}
      </button>
      <button @click="toggleIcons" class="control-btn" :title="showIcons ? '隐藏图标' : '显示图标'">
        {{ showIcons ? '🔡' : '🎨' }}
      </button>
      <button @click="resetAnimation" class="control-btn" title="重置动画">🔄</button>
    </div>

    <!-- 配置面板 -->
    <div class="config-panel">
      <label>
        <input type="range" v-model="config.waveAmplitude" min="50" max="200" step="10">
        波浪幅度: {{ config.waveAmplitude }}
      </label>
      <label>
        <input type="range" v-model="config.animationSpeed" min="0.1" max="2" step="0.1">
        动画速度: {{ config.animationSpeed }}
      </label>
    </div>

    <!-- 加载状态 -->
    <div v-if="isLoading" class="loading-overlay">
      <div class="loading-spinner"></div>
      <p>加载时间轴中...</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'

// 响应式数据
const containerRef = ref(null)
const canvasRef = ref(null)
const ctx = ref(null)
const animationFrame = ref(null)
const activeTooltip = ref(null)
const showTooltip = ref(false)
const isLoading = ref(true)
const isPlaying = ref(true)
const isDarkMode = ref(false)
const showIcons = ref(true) // 控制是否显示图标
const mouseX = ref(0)
const mouseY = ref(0)

// 配置参数
const config = ref({
  nodeRadius: 14,
  lineWidth: 4,
  animationSpeed: 0.8,
  waveAmplitude: 120,
  nodeSpacing: 0.85,
  hoverScale: 1.3,
  showProgressDots: true // 是否显示进度点
})

// 主题配置
const theme = computed(() => isDarkMode.value ? {
  background: '#1a1a1a',
  text: '#ffffff',
  nodeInactive: '#4a4a4a',
  lineInactive: '#3a3a3a',
  tooltipBg: '#2d2d2d',
  tooltipBorder: '#404040',
  nodeBorder: '#666666'
} : {
  background: '#f8f9fa',
  text: '#333333',
  nodeInactive: '#9E9E9E',
  lineInactive: '#E0E0E0',
  tooltipBg: '#ffffff',
  tooltipBorder: '#e0e0e0',
  nodeBorder: '#cccccc'
})

// 节点数据
const milestones = ref([
  { 
    year: '2018', 
    event: '梦想起航', 
    description: '初创团队5人,在北京中关村开始了创业之旅',
    color: '#FF6B6B',
    icon: '🚀'
  },
  { 
    year: '2019', 
    event: '首轮融资', 
    description: '获得千万级天使投资,产品研发加速',
    color: '#4ECDC4',
    icon: '💸'
  },
  { 
    year: '2020', 
    event: '产品发布', 
    description: '首款产品正式上线,获得市场积极反馈',
    color: '#45B7D1',
    icon: '🎯'
  },
  { 
    year: '2021', 
    event: '用户增长', 
    description: '用户数突破百万,团队扩张至50人',
    color: '#FFBE0B',
    icon: '📈'
  },
  { 
    year: '2022', 
    event: '国际化', 
    description: '服务拓展至全球15个国家和地区',
    color: '#FF9F1C',
    icon: '🌍'
  },
  { 
    year: '2023', 
    event: '技术突破', 
    description: 'AI大模型集成,产品智能化升级',
    color: '#E71D36',
    icon: '🤖'
  },
  { 
    year: '2024', 
    event: '生态建设', 
    description: '构建完整的产品生态系统,服务千万用户',
    color: '#2EC4B6',
    icon: '🏆'
  }
])

// 节点类
class TimelineNode {
  constructor(x, y, data, index, total) {
    this.x = x
    this.y = y
    this.data = data
    this.index = index
    this.total = total
    this.radius = config.value.nodeRadius
    this.isActive = false
    this.isHovered = false
    this.progress = 0
    this.scale = 1
  }
}

const nodes = ref([])
const offScreenCanvas = ref(null)
const offScreenCtx = ref(null)
const resizeObserver = ref(null)

// 计算属性
const tooltipStyle = computed(() => ({
  left: mouseX.value + 20 + 'px',
  top: mouseY.value - 40 + 'px',
  opacity: showTooltip.value ? 1 : 0,
  transform: `translateY(${showTooltip.value ? 0 : '10px'})`
}))

// 初始化画布
const initCanvas = async () => {
  if (!canvasRef.value || !containerRef.value) return
  
  isLoading.value = true
  
  // 设置画布尺寸
  const { width, height } = containerRef.value.getBoundingClientRect()
  canvasRef.value.width = width
  canvasRef.value.height = height - 2
  
  ctx.value = canvasRef.value.getContext('2d', { alpha: false })
  
  // 初始化离屏canvas
  offScreenCanvas.value = document.createElement('canvas')
  offScreenCanvas.value.width = width
  offScreenCanvas.value.height = height
  offScreenCtx.value = offScreenCanvas.value.getContext('2d', { alpha: false })
  
  createNodes()
  cacheStaticElements()
  
  await new Promise(resolve => setTimeout(resolve, 300))
  isLoading.value = false
  
  if (isPlaying.value) {
    startAnimation()
  }
}

// 创建节点
const createNodes = () => {
  const { width, height } = canvasRef.value
  nodes.value = milestones.value.map((milestone, index) => {
    const x = 100 + (index * (width - 200) / (milestones.value.length - 1))
    const y = height / 2 + Math.sin(index * config.value.waveAmplitude / 100) * config.value.waveAmplitude
    
    return new TimelineNode(x, y, milestone, index, milestones.value.length)
  })
}

// 缓存静态元素
const cacheStaticElements = () => {
  if (!offScreenCtx.value) return
  
  const { width, height } = offScreenCanvas.value
  
  // 绘制背景
  offScreenCtx.value.fillStyle = theme.value.background
  offScreenCtx.value.fillRect(0, 0, width, height)
  
  // 绘制连接线(静态部分)
  offScreenCtx.value.strokeStyle = theme.value.lineInactive
  offScreenCtx.value.lineWidth = config.value.lineWidth
  offScreenCtx.value.lineCap = 'round'
  
  for (let i = 0; i < nodes.value.length - 1; i++) {
    const currentNode = nodes.value[i]
    const nextNode = nodes.value[i + 1]
    
    offScreenCtx.value.beginPath()
    offScreenCtx.value.moveTo(currentNode.x, currentNode.y)
    
    const cpX1 = currentNode.x + (nextNode.x - currentNode.x) * 0.5
    const cpY1 = currentNode.y
    const cpX2 = currentNode.x + (nextNode.x - currentNode.x) * 0.5
    const cpY2 = nextNode.y
    
    offScreenCtx.value.bezierCurveTo(cpX1, cpY1, cpX2, cpY2, nextNode.x, nextNode.y)
    offScreenCtx.value.stroke()
  }
  
  // 绘制节点背景(保持与线条样式一致)
  nodes.value.forEach(node => {
    offScreenCtx.value.beginPath()
    offScreenCtx.value.arc(node.x, node.y, node.radius + 2, 0, Math.PI * 2)
    offScreenCtx.value.fillStyle = theme.value.background
    offScreenCtx.value.fill()
    
    // 节点边框与线条颜色一致
    offScreenCtx.value.strokeStyle = theme.value.lineInactive
    offScreenCtx.value.lineWidth = 2
    offScreenCtx.value.stroke()
  })
}

// 绘制动态内容
const drawDynamicContent = () => {
  if (!ctx.value) return
  
  const { width, height } = canvasRef.value
  
  // 清空画布并绘制缓存内容
  ctx.value.clearRect(0, 0, width, height)
  ctx.value.drawImage(offScreenCanvas.value, 0, 0)
  
  // 绘制进度线条
  drawProgressLines()
  
  // 绘制激活的连接线
  drawActiveConnections()
  
  // 绘制节点(最后绘制,确保在最上层)
  drawNodes()
}

// 绘制进度线条
const drawProgressLines = () => {
  ctx.value.lineWidth = config.value.lineWidth
  ctx.value.lineCap = 'round'
  
  nodes.value.forEach((node, index) => {
    if (node.progress > 0 && index < nodes.value.length - 1) {
      const nextNode = nodes.value[index + 1]
      
      ctx.value.beginPath()
      ctx.value.moveTo(node.x, node.y)
      
      const cpX1 = node.x + (nextNode.x - node.x) * 0.5
      const cpY1 = node.y
      const cpX2 = node.x + (nextNode.x - node.x) * 0.5
      const cpY2 = nextNode.y
      
      ctx.value.bezierCurveTo(cpX1, cpY1, cpX2, cpY2, nextNode.x, nextNode.y)
      ctx.value.strokeStyle = node.data.color
      ctx.value.stroke()
      
      // 绘制进度点(可选)
      if (config.value.showProgressDots) {
        const progressX = node.x + (nextNode.x - node.x) * node.progress
        const progressY = node.y + (nextNode.y - node.y) * node.progress
        
        ctx.value.beginPath()
        ctx.value.arc(progressX, progressY, 5, 0, Math.PI * 2)
        ctx.value.fillStyle = node.data.color
        ctx.value.fill()
        
        // 进度点边框与线条一致
        ctx.value.strokeStyle = theme.value.background
        ctx.value.lineWidth = 1
        ctx.value.stroke()
      }
    }
  })
}

// 绘制节点
const drawNodes = () => {
  ctx.value.textAlign = 'center'
  ctx.value.textBaseline = 'middle'
  
  nodes.value.forEach(node => {
    const scale = node.isHovered ? config.value.hoverScale : 1
    
    // 绘制节点外圈(与线条样式保持一致)
    ctx.value.save()
    ctx.value.translate(node.x, node.y)
    ctx.value.scale(scale, scale)
    
    ctx.value.beginPath()
    ctx.value.arc(0, 0, node.radius, 0, Math.PI * 2)
    
    // 节点填充色
    ctx.value.fillStyle = node.isActive ? node.data.color : theme.value.nodeInactive
    ctx.value.fill()
    
    // 节点边框(与线条颜色一致)
    ctx.value.strokeStyle = node.isActive ? node.data.color : theme.value.lineInactive
    ctx.value.lineWidth = 2
    ctx.value.stroke()
    
    // 绘制图标或文字
    if (showIcons.value && node.data.icon) {
      ctx.value.fillStyle = '#ffffff'
      ctx.value.font = '16px Arial'
      ctx.value.fillText(node.data.icon, 0, 0)
    } else {
      // 不显示图标时显示年份缩写
      ctx.value.fillStyle = '#ffffff'
      ctx.value.font = 'bold 12px Arial'
      ctx.value.fillText(node.data.year.slice(2), 0, 0) // 显示后两位年份
    }
    
    ctx.value.restore()
    
    // 绘制文字标签
    if (!node.isHovered) {
      ctx.value.fillStyle = theme.value.text
      ctx.value.font = 'bold 14px Arial'
      ctx.value.fillText(node.data.year, node.x, node.y - 28)
      
      ctx.value.font = '12px Arial'
      ctx.value.fillText(node.data.event, node.x, node.y + 28)
    }
  })
}

// 绘制激活的连接线
const drawActiveConnections = () => {
  ctx.value.strokeStyle = '#4CAF50'
  ctx.value.lineWidth = config.value.lineWidth
  ctx.value.lineCap = 'round'
  
  for (let i = 0; i < nodes.value.length - 1; i++) {
    if (nodes.value[i].isActive) {
      const currentNode = nodes.value[i]
      const nextNode = nodes.value[i + 1]
      
      ctx.value.beginPath()
      ctx.value.moveTo(currentNode.x, currentNode.y)
      
      const cpX1 = currentNode.x + (nextNode.x - currentNode.x) * 0.5
      const cpY1 = currentNode.y
      const cpX2 = currentNode.x + (nextNode.x - currentNode.x) * 0.5
      const cpY2 = nextNode.y
      
      ctx.value.bezierCurveTo(cpX1, cpY1, cpX2, cpY2, nextNode.x, nextNode.y)
      ctx.value.stroke()
    }
  }
}

// 动画循环
const animate = () => {
  updateAnimation()
  drawDynamicContent()
  if (isPlaying.value) {
    animationFrame.value = requestAnimationFrame(animate)
  }
}

// 更新动画状态
const updateAnimation = () => {
  const now = Date.now() * config.value.animationSpeed
  
  nodes.value.forEach((node, index) => {
    const activeIndex = Math.floor((now % 8000) / 8000 * nodes.value.length)
    node.isActive = index <= activeIndex
    
    if (index < nodes.value.length - 1) {
      const globalProgress = (now % 8000) / 8000
      const segmentProgress = (globalProgress * nodes.value.length) - index
      node.progress = Math.max(0, Math.min(1, segmentProgress))
    }
  })
}

// 鼠标交互处理
const handleMouseMove = (event) => {
  if (!canvasRef.value) return
  
  const rect = canvasRef.value.getBoundingClientRect()
  mouseX.value = event.clientX
  mouseY.value = event.clientY
  const canvasX = event.clientX - rect.left
  const canvasY = event.clientY - rect.top
  
  let hoveredNode = null
  
  nodes.value.forEach(node => {
    const distance = Math.sqrt(Math.pow(canvasX - node.x, 2) + Math.pow(canvasY - node.y, 2))
    node.isHovered = distance <= node.radius * config.value.hoverScale
    
    if (node.isHovered) {
      hoveredNode = node
    }
  })
  
  if (hoveredNode) {
    activeTooltip.value = hoveredNode.data
    showTooltip.value = true
    canvasRef.value.style.cursor = 'pointer'
  } else {
    showTooltip.value = false
    canvasRef.value.style.cursor = 'default'
  }
}

const handleMouseLeave = () => {
  showTooltip.value = false
  nodes.value.forEach(node => {
    node.isHovered = false
  })
}

const handleClick = (event) => {
  if (!canvasRef.value) return
  
  const rect = canvasRef.value.getBoundingClientRect()
  const canvasX = event.clientX - rect.left
  const canvasY = event.clientY - rect.top
  
  const clickedNode = nodes.value.find(node => {
    const distance = Math.sqrt(Math.pow(canvasX - node.x, 2) + Math.pow(canvasY - node.y, 2))
    return distance <= node.radius * config.value.hoverScale
  })
  
  if (clickedNode) {
    console.log('节点点击:', clickedNode.data)
    emit('nodeClick', clickedNode.data)
  }
}

// 控制函数
const toggleAnimation = () => {
  isPlaying.value = !isPlaying.value
  if (isPlaying.value) {
    startAnimation()
  }
}

const toggleTheme = () => {
  isDarkMode.value = !isDarkMode.value
  cacheStaticElements()
  drawDynamicContent()
}

const toggleIcons = () => {
  showIcons.value = !showIcons.value
  drawDynamicContent()
}

const resetAnimation = () => {
  nodes.value.forEach(node => {
    node.isActive = false
    node.progress = 0
  })
  if (isPlaying.value) {
    startAnimation()
  }
}

const startAnimation = () => {
  if (animationFrame.value) {
    cancelAnimationFrame(animationFrame.value)
  }
  animate()
}

// 响应式处理
const setupResizeObserver = () => {
  resizeObserver.value = new ResizeObserver(entries => {
    if (!entries[0]) return
    initCanvas()
  })
  
  if (containerRef.value) {
    resizeObserver.value.observe(containerRef.value)
  }
}

// 生命周期
onMounted(() => {
  setupResizeObserver()
  initCanvas()
})

onUnmounted(() => {
  if (animationFrame.value) {
    cancelAnimationFrame(animationFrame.value)
  }
  if (resizeObserver.value) {
    resizeObserver.value.disconnect()
  }
})

// 监听变化
watch(isDarkMode, () => {
  cacheStaticElements()
  drawDynamicContent()
})

watch(milestones, () => {
  createNodes()
  cacheStaticElements()
  drawDynamicContent()
})

watch([() => config.value.waveAmplitude, () => config.value.animationSpeed], () => {
  createNodes()
  cacheStaticElements()
  if (isPlaying.value) {
    startAnimation()
  }
})

// 定义事件
const emit = defineEmits(['nodeClick'])
</script>

<style scoped>
.snake-timeline-container {
  position: relative;
  width: 100%;
  height: 500px;
  background: var(--background, #f8f9fa);
  border-radius: 16px;
  overflow: hidden;
  transition: background-color 0.3s ease;
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 16px;
}

.tooltip {
  position: fixed;
  background: v-bind('theme.tooltipBg');
  border: 1px solid v-bind('theme.tooltipBorder');
  padding: 16px;
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
  backdrop-filter: blur(10px);
  z-index: 1000;
  max-width: 280px;
  transition: all 0.3s ease;
  pointer-events: none;
}

.tooltip-header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.year-badge {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 4px 8px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: bold;
}

.tooltip-header h3 {
  margin: 0;
  color: v-bind('theme.text');
  font-size: 14px;
  font-weight: 600;
}

.tooltip-desc {
  margin: 0;
  color: v-bind('theme.text');
  font-size: 12px;
  line-height: 1.5;
  opacity: 0.8;
}

.control-panel {
  position: absolute;
  top: 16px;
  right: 16px;
  display: flex;
  gap: 8px;
  z-index: 10;
}

.control-btn {
  width: 40px;
  height: 40px;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(10px);
  cursor: pointer;
  font-size: 16px;
  transition: all 0.2s ease;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.control-btn:hover {
  transform: scale(1.1);
  background: rgba(255, 255, 255, 1);
}

.config-panel {
  position: absolute;
  bottom: 16px;
  left: 16px;
  background: rgba(255, 255, 255, 0.9);
  padding: 12px;
  border-radius: 8px;
  backdrop-filter: blur(10px);
  z-index: 10;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 200px;
}

.config-panel label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
  color: #666;
}

.config-panel input[type="range"] {
  width: 100%;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  color: white;
  z-index: 20;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid rgba(255, 255, 255, 0.3);
  border-top: 3px solid white;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

/* 响应式设计 */
@media (max-width: 768px) {
  .snake-timeline-container {
    height: 400px;
    border-radius: 12px;
  }
  
  .tooltip {
    max-width: 200px;
    font-size: 11px;
  }
  
  .control-panel {
    top: 8px;
    right: 8px;
  }
  
  .control-btn {
    width: 36px;
    height: 36px;
    font-size: 14px;
  }
  
  .config-panel {
    bottom: 8px;
    left: 8px;
    right: 8px;
    min-width: auto;
  }
}
</style>

六、扩展与优化方向

1. 功能扩展

  • 支持垂直布局(竖向时间轴)
  • 添加缩放功能(手势/滚轮控制)
  • 集成图表联动(点击节点联动数据图表)

2. 性能优化

  • 使用 WebGL 替代 Canvas,提升复杂动画性能
  • 虚拟列表优化:仅渲染可视区域节点
  • 离屏渲染进一步拆分图层,减少重绘

3. 设计优化

  • 支持自定义节点图标/图片
  • 更多动画效果:如“蛇头游动”、“光效扫描”
  • 响应式布局增强,适配平板、折叠屏设备

总结

  1. 视觉效果突出:波浪布局 + 流畅动画,吸引用户注意力
  2. 交互体验流畅:悬停、点击、控制面板响应及时
  3. 高度可定制:主题、动画速度、波浪幅度等均可配置

如果你也需要展示时间线信息,不妨试试这个方案!代码已结构化,可直接集成或二次开发。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IT刘大华

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值