[Vue组件]比例环形编辑
环形编辑组件简介
这是一个基于 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"><!-- 动态渲染扇形区域 --><pathv-for="(segment, index) in computedSegments":key="`path_${index}`":d="createArcPath(segment.startAngle, segment.endAngle)":fill="segment.color"/><!-- 分隔线 --><linev-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 = 80export default {name: 'YsCircularEdit',props: {// 比例数组,三个小数加起来应该等于1ratios: {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 = 0const segments = []this.currentRatios.forEach((ratio, index) => {const startAngle = currentAngleconst angleSpan = ratio * 360const endAngle = currentAngle + angleSpansegments.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 * 360angles.push(currentAngle)}})return angles}},watch: {ratios: {handler(newRatios) {this.currentRatios = [...newRatios]},immediate: true}},mounted() {this.init()},methods: {init() {const dom = this.$refs.containerconst rect = dom.getBoundingClientRect()this.size = rect.width},// 创建弧形路径createArcPath(startDeg, endDeg) {const startAngle = ((startDeg - 90) * Math.PI) / 180const endAngle = ((endDeg - 90) * Math.PI) / 180const 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 : 0return [`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) / 180return {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 = truethis.draggingIndex = index},// 鼠标移动时更新角度handleMouseMove(event) {if (!this.isDragging) returnconst svgRect = event.currentTarget.getBoundingClientRect()const svgX = ((event.clientX - svgRect.left) / svgRect.width) * 200const svgY = ((event.clientY - svgRect.top) / svgRect.height) * 200// 更新拖拽预览位置this.dragPreview.x = svgXthis.dragPreview.y = svgY// 计算鼠标相对于圆心的角度const angle = this.calculateAngle(svgX, svgY)// 更新比例this.updateRatios(angle)},// 鼠标松开结束拖拽handleMouseUp() {if (this.isDragging) {this.isDragging = falsethis.draggingIndex = -1const result = this.formatRatiosToSum1(this.currentRatios)// 触发比例变化事件this.$emit('ratios-change', result)}},// 计算鼠标位置相对于圆心的角度calculateAngle(x, y) {const dx = x - centerXconst dy = y - centerYlet angle = (Math.atan2(dy, dx) * 180) / Math.PI// 调整角度,让0度在顶部,顺时针增加angle = (angle + 90 + 360) % 360return angle},// 根据新角度更新比例updateRatios(newAngle) {const newRatios = [...this.currentRatios]if (this.draggingIndex === 0) {// 拖拽第一条分隔线,影响第一个和第二个扇形const ratio1 = newAngle / 360const ratio2 = this.currentRatios[1] + this.currentRatios[0] - ratio1// 确保比例有效if (ratio1 > 0.05 && ratio2 > 0.05) {// 最小5%的限制newRatios[0] = ratio1newRatios[1] = ratio2}} else if (this.draggingIndex === 1) {// 拖拽第二条分隔线,影响第二个和第三个扇形const totalRatio12 = newAngle / 360const ratio2 = totalRatio12 - this.currentRatios[0]const ratio3 = 1 - totalRatio12// 确保比例有效if (ratio2 > 0.05 && ratio3 > 0.05) {newRatios[1] = ratio2newRatios[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 = 0let 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.01if (formattedRatios[adjustIndex] < 0.01) {formattedRatios[adjustIndex] = 0.01// 重新分配剩余的比例const remaining = Math.round((1 - 0.01) * 100) / 100const 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 / otherSumotherIndices.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) / 100if (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>