圆形菜单组件实现:从极坐标到SVG路径的实践

2025年12月04日22 次阅读0 人喜欢
SVG组件开发极坐标前端实践vue

圆形菜单组件实现:从极坐标到SVG路径的实践

最近在做一个 AI 运维平台的项目,需要实现一个圆形菜单组件。这个组件要求菜单项围绕中心按钮呈扇形分布,并且要有流畅的悬停和点击交互效果。经过一番折腾,最终用 Vue + SVG 实现了这个效果,这里分享一下实现思路和核心代码。

核心思路

圆形菜单的本质是把菜单项均匀分布在圆周上,每个菜单项占据一个扇形区域。实现这个效果有几个关键点:

  1. 极坐标定位:菜单项的位置需要用极坐标系统来计算,然后转换成屏幕坐标
  2. SVG 路径绘制:每个菜单项是一个扇形,需要用 SVG 的 path 来绘制
  3. 分层渲染:背景、扇形、图标文字、中心按钮需要分层,确保正确的显示层级
  4. 交互优化:悬停时通过排序让当前项渲染在最上层,避免被遮挡

核心实现

1. 极坐标转笛卡尔坐标

这是整个组件的基础函数。菜单项的位置是用角度和半径定义的,但实际渲染需要转换成 x、y 坐标:

javascript 复制代码
/**
 * 将极坐标转换为笛卡尔坐标
 * @param {number} centerX - 中心点X坐标
 * @param {number} centerY - 中心点Y坐标
 * @param {number} radius - 半径
 * @param {number} angleInDegrees - 角度(度)
 * @returns {{x: number, y: number}} 笛卡尔坐标
 */
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
  const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0
  return {
    x: centerX + radius * Math.cos(angleInRadians),
    y: centerY + radius * Math.sin(angleInRadians),
  }
}

注意这里角度减了 90 度,是因为 SVG 坐标系中 0 度指向右侧,而我们通常希望 0 度指向顶部(12 点钟方向)。

2. SVG 扇形路径生成

这是最核心的部分。SVG 的 path 元素可以通过路径字符串来绘制任意形状,扇形实际上就是两条半径和一条圆弧组成的闭合路径:

javascript 复制代码
/**
 * 创建SVG扇形路径描述
 * @param {number} x - 中心点X坐标
 * @param {number} y - 中心点Y坐标
 * @param {number} radius - 半径
 * @param {number} startAngle - 起始角度
 * @param {number} endAngle - 结束角度
 * @returns {string} SVG路径字符串
 */
const describeSector = (x, y, radius, startAngle, endAngle) => {
  const start = polarToCartesian(x, y, radius, endAngle)
  const end = polarToCartesian(x, y, radius, startAngle)
  const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'

  return [
    'M', x, y,           // 移动到中心点
    'L', start.x, start.y, // 画线到起始点
    'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y, // 画圆弧到结束点
    'Z'                   // 闭合路径
  ].join(' ')
}

路径字符串的格式:

  • M x y:移动到指定点
  • L x y:画直线到指定点
  • A rx ry x-axis-rotation large-arc-flag sweep-flag x y:画椭圆弧
  • Z:闭合路径

largeArcFlag 用来判断是画大弧还是小弧,当角度差大于 180 度时需要画大弧。

3. 菜单项配置和计算

菜单项通过计算属性动态生成,每个菜单项包含:

  • 扇形路径(用于 SVG 绘制)
  • 图标和文字的位置(用于绝对定位)
javascript 复制代码
computed: {
  menuItems() {
    const center = this.svgSize / 2
    return [
      {
        id: 'fault',
        type: 'fault',
        label: '故障分析',
        icon: 'monitor',
        startAngle: 300,
        endAngle: 420,
        gradient: 'url(#grad-fault)',
      },
      {
        id: 'weakness',
        type: 'weakness',
        label: '薄弱分析',
        icon: 'chart',
        startAngle: 60,
        endAngle: 180,
        gradient: 'url(#grad-weakness)',
      },
      {
        id: 'inspection',
        type: 'inspection',
        label: '智能巡检',
        icon: 's-list',
        startAngle: 180,
        endAngle: 300,
        gradient: 'url(#grad-inspection)',
      },
    ].map(item => {
      // 计算中点角度,用于定位图标和文字
      let midAngle = (item.startAngle + item.endAngle) / 2
      if (item.startAngle === 300 && item.endAngle === 420) {
        midAngle = 360 // 处理跨 0 度的情况
      }

      return {
        ...item,
        path: describeSector(center, center, this.radius, item.startAngle, item.endAngle),
        position: polarToCartesian(center, center, this.contentRadius, midAngle),
      }
    })
  }
}

这里有两个半径:

  • radius:扇形的外半径,用于绘制扇形区域
  • contentRadius:内容半径,用于定位图标和文字,通常比外半径小一些

4. 悬停时的层级处理

这是一个比较巧妙的实现。当鼠标悬停在某个菜单项上时,我们希望它显示在最上层,避免被其他项遮挡。通过排序让悬停项最后渲染,自然就显示在最上层了:

javascript 复制代码
sortedItems() {
  return [...this.menuItems].sort((a, b) => {
    if (this.hoveredId === a.id) return 1   // 悬停项排到最后
    if (this.hoveredId === b.id) return -1
    return 0
  })
}

在模板中使用 sortedItems 而不是 menuItems,这样悬停项就会最后渲染,显示在最上层。

5. 模板结构

模板采用分层结构:

vue 复制代码
<template>
  <!-- 背景光晕层 -->
  <div class="glow-background" />
  
  <!-- SVG 扇形层 -->
  <svg>
    <g v-for="item in sortedItems">
      <path :d="item.path" />
    </g>
  </svg>
  
  <!-- 图标和文字层 -->
  <div class="absolute inset-0">
    <div v-for="item in menuItems" :style="{ left, top }">
      <!-- 图标和文字 -->
    </div>
  </div>
  
  <!-- 中心按钮层 -->
  <div class="center-button-wrapper">
    <!-- 中心按钮 -->
  </div>
</template>

每层都有独立的 z-index,确保正确的显示顺序。

交互效果

悬停效果主要通过 CSS 过渡和动态样式实现:

  1. 扇形缩放:悬停时扇形放大 1.05 倍
  2. 图标变化:图标容器背景色变化,图标尺寸增大
  3. 文字加粗:文字颜色变深,字重增加
  4. 其他项淡化:非悬停项透明度降低到 0.6

这些效果都通过 hoveredId 状态来控制,配合 CSS 的 transition 实现平滑过渡。

踩过的坑

  1. 角度计算:SVG 坐标系中 0 度指向右侧,需要减 90 度才能让 0 度指向顶部
  2. 跨 0 度处理:当起始角度是 300,结束角度是 420 时,实际上是跨过了 0 度,需要特殊处理中点角度
  3. 层级问题:最初直接用 z-index,但 SVG 内部的元素 z-index 不生效,改用排序的方式解决
  4. 点击区域:SVG 的 path 和绝对定位的图标文字都需要绑定点击事件,确保点击区域足够大

总结

这个组件的核心就是极坐标系统和 SVG 路径的配合使用。通过数学计算生成路径,再用 SVG 渲染出来,配合 Vue 的响应式系统实现动态交互。整个实现过程不算复杂,但需要理解一些基础的数学和 SVG 知识。

代码已经用在实际项目中了,效果还不错。如果大家有类似的需求,可以参考这个思路。如果有什么问题或者更好的实现方式,欢迎交流讨论。

圆形菜单组件实现:从极坐标到SVG路径的实践 | 博客