[Vue组件]比例环形编辑

190 阅读4分钟

环形编辑组件简介

这是一个基于 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>