环形编辑组件简介
这是一个基于 Vue.js 的交互式环形编辑组件(YsCircularEdit),用于可视化和动态调整比例分配。核心功能包括:
- 动态扇形渲染:根据传入的比例数组(默认[0.375, 0.125, 0.5])和颜色数组,生成可定制的环形扇形图。
- 交互式拖拽:用户可拖动分隔线调整扇形比例,最小比例限制为5%,确保操作有效。
- 精确比例管理:自动校正比例总和为1,保留两位小数,解决浮点精度问题。
- 视觉反馈:拖拽时显示辅助圆圈,分隔线支持悬停(绿色高亮、阴影效果)和拖拽(加粗、增强阴影)状态。
- 响应式布局:自适应容器尺寸,保持1:1宽高比,禁用文本选择优化交互。
- 事件通知:比例调整后通过
ratios-change事件向父组件传递新比例数据。
适合用于数据分配、资源管理等需要直观比例调整的场景。
- 组件效果
<template>
<div class="ys-circular-edit" :class="isDragging && 'dragging'">
<div ref="container" class="container">
<svg :width="size" :height="size" viewBox="0 0 200 200" @mousemove="handleMouseMove" @mouseup="handleMouseUp" @mouseleave="handleMouseUp">
<!-- 动态渲染扇形区域 -->
<path
v-for="(segment, index) in computedSegments"
:key="`path_${index}`"
:d="createArcPath(segment.startAngle, segment.endAngle)"
:fill="segment.color"
/>
<!-- 分隔线 -->
<line
v-for="(angle, index) in separatorAngles"
:key="`line_${index}`"
class="separator-line"
:class="{ dragging: draggingIndex === index }"
:x1="getSeparatorLine(angle).startX"
:y1="getSeparatorLine(angle).startY"
:x2="getSeparatorLine(angle).endX"
:y2="getSeparatorLine(angle).endY"
stroke="#F2F6FC"
stroke-width="4"
@mousedown="handleMouseDown($event, index)"
/>
<!-- 拖拽时的辅助圆圈 -->
<circle v-if="isDragging" :cx="dragPreview.x" :cy="dragPreview.y" r="4" fill="#FF6B6B" opacity="0.8" />
</svg>
</div>
</div>
</template>
<script>
const centerX = 100
const centerY = 100
const innerRadius = 55
const outerRadius = 80
export default {
name: 'YsCircularEdit',
props: {
// 比例数组,三个小数加起来应该等于1
ratios: {
type: Array,
default: () => [0.375, 0.125, 0.5] // 对应原来的 135/360, 45/360, 180/360
},
// 颜色数组
colors: {
type: Array,
default: () => ['#eab170', '#6290ea', '#ea5e5e']
}
},
data() {
return {
size: 300,
isDragging: false,
draggingIndex: -1,
currentRatios: [...this.ratios], // 内部维护的比例状态
dragPreview: { x: 0, y: 0 }
}
},
computed: {
// 根据比例计算每个扇形的角度范围
computedSegments() {
let currentAngle = 0
const segments = []
this.currentRatios.forEach((ratio, index) => {
const startAngle = currentAngle
const angleSpan = ratio * 360
const endAngle = currentAngle + angleSpan
segments.push({
startAngle: startAngle,
endAngle: endAngle,
color: this.colors[index] || '#CCCCCC'
})
currentAngle = endAngle
})
return segments
},
// 计算分隔线角度
separatorAngles() {
const angles = []
let currentAngle = 0
// 添加每个扇形结束的角度作为分隔线(不包括最后一个360度)
this.currentRatios.forEach((ratio, index) => {
if (index < this.currentRatios.length - 1) {
// 不包括最后一个分隔线
currentAngle += ratio * 360
angles.push(currentAngle)
}
})
return angles
}
},
watch: {
ratios: {
handler(newRatios) {
this.currentRatios = [...newRatios]
},
immediate: true
}
},
mounted() {
this.init()
},
methods: {
init() {
const dom = this.$refs.container
const rect = dom.getBoundingClientRect()
this.size = rect.width
},
// 创建弧形路径
createArcPath(startDeg, endDeg) {
const startAngle = ((startDeg - 90) * Math.PI) / 180
const endAngle = ((endDeg - 90) * Math.PI) / 180
const outerStartX = centerX + outerRadius * Math.cos(startAngle)
const outerStartY = centerY + outerRadius * Math.sin(startAngle)
const outerEndX = centerX + outerRadius * Math.cos(endAngle)
const outerEndY = centerY + outerRadius * Math.sin(endAngle)
const innerStartX = centerX + innerRadius * Math.cos(startAngle)
const innerStartY = centerY + innerRadius * Math.sin(startAngle)
const innerEndX = centerX + innerRadius * Math.cos(endAngle)
const innerEndY = centerY + innerRadius * Math.sin(endAngle)
const largeArcFlag = endDeg - startDeg > 180 ? 1 : 0
return [
`M ${outerStartX} ${outerStartY}`,
`A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEndX} ${outerEndY}`,
`L ${innerEndX} ${innerEndY}`,
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStartX} ${innerStartY}`,
'Z'
].join(' ')
},
// 获取分隔线坐标
getSeparatorLine(angleDeg) {
const angleRad = ((angleDeg - 90) * Math.PI) / 180
return {
startX: centerX + Math.cos(angleRad) * innerRadius,
startY: centerY + Math.sin(angleRad) * innerRadius,
endX: centerX + Math.cos(angleRad) * outerRadius,
endY: centerY + Math.sin(angleRad) * outerRadius
}
},
// 鼠标按下开始拖拽
handleMouseDown(event, index) {
event.preventDefault()
this.isDragging = true
this.draggingIndex = index
},
// 鼠标移动时更新角度
handleMouseMove(event) {
if (!this.isDragging) return
const svgRect = event.currentTarget.getBoundingClientRect()
const svgX = ((event.clientX - svgRect.left) / svgRect.width) * 200
const svgY = ((event.clientY - svgRect.top) / svgRect.height) * 200
// 更新拖拽预览位置
this.dragPreview.x = svgX
this.dragPreview.y = svgY
// 计算鼠标相对于圆心的角度
const angle = this.calculateAngle(svgX, svgY)
// 更新比例
this.updateRatios(angle)
},
// 鼠标松开结束拖拽
handleMouseUp() {
if (this.isDragging) {
this.isDragging = false
this.draggingIndex = -1
const result = this.formatRatiosToSum1(this.currentRatios)
// 触发比例变化事件
this.$emit('ratios-change', result)
}
},
// 计算鼠标位置相对于圆心的角度
calculateAngle(x, y) {
const dx = x - centerX
const dy = y - centerY
let angle = (Math.atan2(dy, dx) * 180) / Math.PI
// 调整角度,让0度在顶部,顺时针增加
angle = (angle + 90 + 360) % 360
return angle
},
// 根据新角度更新比例
updateRatios(newAngle) {
const newRatios = [...this.currentRatios]
if (this.draggingIndex === 0) {
// 拖拽第一条分隔线,影响第一个和第二个扇形
const ratio1 = newAngle / 360
const ratio2 = this.currentRatios[1] + this.currentRatios[0] - ratio1
// 确保比例有效
if (ratio1 > 0.05 && ratio2 > 0.05) {
// 最小5%的限制
newRatios[0] = ratio1
newRatios[1] = ratio2
}
} else if (this.draggingIndex === 1) {
// 拖拽第二条分隔线,影响第二个和第三个扇形
const totalRatio12 = newAngle / 360
const ratio2 = totalRatio12 - this.currentRatios[0]
const ratio3 = 1 - totalRatio12
// 确保比例有效
if (ratio2 > 0.05 && ratio3 > 0.05) {
newRatios[1] = ratio2
newRatios[2] = ratio3
}
}
this.currentRatios = newRatios
},
// 格式化比例数组,确保总和为1且保留两位小数
formatRatiosToSum1(ratios) {
// 先将所有比例四舍五入到两位小数
const formattedRatios = ratios.map(ratio => Math.round(ratio * 100) / 100)
// 计算当前总和
let currentSum = formattedRatios.reduce((sum, ratio) => sum + ratio, 0)
currentSum = Math.round(currentSum * 100) / 100 // 避免浮点精度问题
// 如果总和不是1,需要调整
if (currentSum !== 1) {
const difference = Math.round((1 - currentSum) * 100) / 100
// 找到可以调整的索引(不是最小值的那个)
let adjustIndex = 0
let maxValue = formattedRatios[0]
// 找到数值最大的项来承担差值
for (let i = 1; i < formattedRatios.length; i++) {
if (formattedRatios[i] > maxValue) {
maxValue = formattedRatios[i]
adjustIndex = i
}
}
// 调整最大值项
formattedRatios[adjustIndex] = Math.round((formattedRatios[adjustIndex] + difference) * 100) / 100
// 确保调整后的值不小于0.01
if (formattedRatios[adjustIndex] < 0.01) {
formattedRatios[adjustIndex] = 0.01
// 重新分配剩余的比例
const remaining = Math.round((1 - 0.01) * 100) / 100
const otherIndices = formattedRatios.map((_, index) => index).filter(i => i !== adjustIndex)
const otherSum = otherIndices.reduce((sum, i) => sum + formattedRatios[i], 0)
if (otherSum > 0) {
const scale = remaining / otherSum
otherIndices.forEach(i => {
formattedRatios[i] = Math.round(formattedRatios[i] * scale * 100) / 100
})
}
}
}
// 最终验证并微调(处理可能的舍入误差)
let finalSum = formattedRatios.reduce((sum, ratio) => sum + ratio, 0)
finalSum = Math.round(finalSum * 100) / 100
if (finalSum !== 1) {
const finalDiff = Math.round((1 - finalSum) * 100) / 100
// 将最终差值加到最后一个元素上
formattedRatios[formattedRatios.length - 1] = Math.round((formattedRatios[formattedRatios.length - 1] + finalDiff) * 100) / 100
}
return formattedRatios
}
}
}
</script>
<style scoped>
/* 禁用文本选择 */
.ys-circular-edit {
position: relative;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.ys-circular-edit.dragging {
cursor: none !important;
}
.container {
margin: auto;
max-height: 100%;
aspect-ratio: 1 / 1;
}
.container > svg {
width: 100%;
height: 100%;
}
/* 分隔线样式 */
.separator-line {
cursor: grab;
transition: all 0.3s ease;
stroke-linecap: round;
}
.separator-line:hover {
stroke: #67c23a !important;
stroke-width: 4;
filter: drop-shadow(0 0 4px rgba(103, 194, 58, 0.6));
}
.separator-line.dragging {
cursor: grabbing;
stroke: #67c23a !important;
stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(103, 194, 58, 0.8));
}
</style>