圆形菜单组件实现:从极坐标到SVG路径的实践
圆形菜单组件实现:从极坐标到SVG路径的实践
最近在做一个 AI 运维平台的项目,需要实现一个圆形菜单组件。这个组件要求菜单项围绕中心按钮呈扇形分布,并且要有流畅的悬停和点击交互效果。经过一番折腾,最终用 Vue + SVG 实现了这个效果,这里分享一下实现思路和核心代码。
核心思路
圆形菜单的本质是把菜单项均匀分布在圆周上,每个菜单项占据一个扇形区域。实现这个效果有几个关键点:
- 极坐标定位:菜单项的位置需要用极坐标系统来计算,然后转换成屏幕坐标
- SVG 路径绘制:每个菜单项是一个扇形,需要用 SVG 的 path 来绘制
- 分层渲染:背景、扇形、图标文字、中心按钮需要分层,确保正确的显示层级
- 交互优化:悬停时通过排序让当前项渲染在最上层,避免被遮挡
核心实现
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.05 倍
- 图标变化:图标容器背景色变化,图标尺寸增大
- 文字加粗:文字颜色变深,字重增加
- 其他项淡化:非悬停项透明度降低到 0.6
这些效果都通过 hoveredId 状态来控制,配合 CSS 的 transition 实现平滑过渡。
踩过的坑
- 角度计算:SVG 坐标系中 0 度指向右侧,需要减 90 度才能让 0 度指向顶部
- 跨 0 度处理:当起始角度是 300,结束角度是 420 时,实际上是跨过了 0 度,需要特殊处理中点角度
- 层级问题:最初直接用 z-index,但 SVG 内部的元素 z-index 不生效,改用排序的方式解决
- 点击区域:SVG 的 path 和绝对定位的图标文字都需要绑定点击事件,确保点击区域足够大
总结
这个组件的核心就是极坐标系统和 SVG 路径的配合使用。通过数学计算生成路径,再用 SVG 渲染出来,配合 Vue 的响应式系统实现动态交互。整个实现过程不算复杂,但需要理解一些基础的数学和 SVG 知识。
代码已经用在实际项目中了,效果还不错。如果大家有类似的需求,可以参考这个思路。如果有什么问题或者更好的实现方式,欢迎交流讨论。