首页 > 其他分享 >vue-canvas-创建矩形框对指定区域的点数据进行坐标变换

vue-canvas-创建矩形框对指定区域的点数据进行坐标变换

时间:2024-12-19 16:52:42浏览次数:3  
标签:canvas vue const selectedRect value padding 矩形框 矩形 rect

demo简介

  1. 读取两个csv文件(geo数据和drawing数据)
  2. 绘制散点图
  3. 使用矩形框选中范围内的数据(只选中drawing数据)
  4. 拖动矩形框 或 reshape矩形框,同时,矩形框内的数据点坐标也相应变换

核心代码介绍

1 template

  • 设置了工具栏和画布作为两个核心组件
    • 工具栏包含”绘制矩形框”,“删除矩形框”,“还原初始状态”和“导出数据”四个功能
    • canvas包含四个鼠标事件,鼠标按下,鼠标移动,鼠标松开和鼠标离开画布
<template>
  <div class="match-container">
    <div class="toolbar">
      <button @click="startDrawingRect">绘制矩形框</button>
      <button v-if="selectedRect" @click="deleteSelectedRect">删除选中的矩形框</button>
      <button @click="resetToInitialState">还原初始状态</button>
      <button @click="exportToCSV">导出数据</button>
    </div>
    <div class="canvas-container">
      <canvas ref="canvas" width="850" height="650" 
        @mousedown="onMouseDown" 
        @mousemove="onMouseMove" 
        @mouseup="onMouseUp" 
        @mouseleave="onMouseLeave">
      </canvas>
    </div>
  </div>
</template>

2 定义变量

  • 创建Rectangle类型时,我们要定义一个数据来记录包含在矩形内的所有数据点的索引
    • 记录索引能使我们在移动矩形框的位置后,其影响的数据点还是原始位置的那些数据点,不会影响到移动后的位置上的数据点
// ================ 类型定义 ================
interface Rectangle {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  selectedPointIndices: number[]; // 存储矩形内点的索引
}

// ================ 状态变量 ================
// 数据相关
const geoData = ref([]);                    // 地理数据点
const drawingData = ref([]);                // 绘制数据点
const initialDrawingData = ref([]);         // 初始状态的数据
const rectangles = ref<Rectangle[]>([]);    // 矩形框数组

// 坐标系相关
const geoOffsetX = ref(0);                  // 地理数据X偏移
const geoOffsetY = ref(0);                  // 地理数据Y偏移
const width = ref(0);                       // 画布宽度
const height = ref(0);                      // 画布高度
const padding = ref(20);                    // 画布内边距
const scaleX = ref(0);                      // X轴缩放比例
const scaleY = ref(0);                      // Y轴缩放比例
const minX = ref(0);                        // X轴最小值
const minY = ref(0);                        // Y轴最小值
const maxX = ref(0);                        // X轴最大值
const maxY = ref(0);                        // Y轴最大值

// 交互状态
const CORNER_SIZE = 10;                     // 角落判定范围大小
const isDrawing = ref(false);               // 是否正在绘制矩形
const selectedRect = ref(null);             // 当前选中的矩形
const isDragging = ref(false);              // 是否正在拖拽矩形
const isResizing = ref(false);              // 是否正在调整大小
const resizeCorner = ref('');               // 正在调整的角落
const dragStartX = ref(0);                  // 拖拽起始X坐标
const dragStartY = ref(0);                  // 拖拽起始Y坐标

// 数据源路径
const geoDataUrl = './datasets/match/g_data.csv';
const drawingDataUrl = './datasets/match/d_data.csv';

3 数据加载与处理

  • 加载数据时,增加一个状态标记,方便后续进行矩形内的数据点选择
  • 使用initialDrawingData来记录初始状态,方便还原
  • 对考虑到原始数据有一个偏移值,不便于绘制,就先进行偏移,使数据靠近原点(0,0)坐标
// ================ 数据处理函数 ================
/**
 * 加载CSV数据
 * @param url - CSV文件路径
 * @returns 解析后的数据数组
 */
 const loadCSV = async (url) => {
    const response = await fetch(url);
    const text = await response.text();
  
    const parsedData = text.split('\n').slice(1).map(row => {
      const columns = row.split(' ');
      return [
        ...columns.map((column, index) => {
          if (index === 0 || index === 1) {
            return Number(column.trim());
          }
          return column.trim();
        }),
        false  // 添加选中状态标记,默认为 false
      ];
    });
    
    // 深拷贝保存初始状态
    initialDrawingData.value = JSON.parse(JSON.stringify(parsedData));
    
    return parsedData;
  };

/**
 * 对齐两组数据的坐标系
 */
 const offsetTwoData = (geoData, drawingData) => {
    const minGeoX = Math.min(...geoData.value.map(row => row[0]));
    const minGeoY = Math.min(...geoData.value.map(row => row[1]));
    const minDrawingX = Math.min(...drawingData.value.map(row => row[0]));
    const minDrawingY = Math.min(...drawingData.value.map(row => row[1]));
    const minX = Math.min(minGeoX, minDrawingX);
    const minY = Math.min(minGeoY, minDrawingY);
    
    geoData.value.forEach(row => {
      row[0] = Number(row[0]) - minX;
      row[1] = Number(row[1]) - minY;
    });
    drawingData.value.forEach(row => {
      row[0] = Number(row[0]) - minX;
      row[1] = Number(row[1]) - minY;
    });
    
    geoOffsetX.value = minX;
    geoOffsetY.value = minY;
  };

4 矩形框相关操作

  • isPointInRect函数和getPointsInRect函数能够获取所绘制的矩形框内的所有数据点的索引
  • updateSelectedPoints函数和updatePointsOnResize函数分别对矩形拖动矩形缩放两种情况进行数据点坐标的更新
// ================ 矩形操作函数 ================
/**
 * 判断点是否在矩形内
 */
 const isPointInRect = (point, rect) => {
    const [x, y] = point;
    const minX = Math.min(rect.x1, rect.x2);
    const maxX = Math.max(rect.x1, rect.x2);
    const minY = Math.min(rect.y1, rect.y2);
    const maxY = Math.max(rect.y1, rect.y2);
    
    return x > minX && x < maxX && y > minY && y < maxY;
  };

/**
 * 获取矩形内的所有点索引
 */
 const getPointsInRect = (rect: Rectangle) => {
    const indices: number[] = [];
    
    drawingData.value.forEach((point, index) => {
      // 将数据坐标转换为画布坐标
      const canvasX = (point[0] - minX.value) * scaleX.value + padding.value;
      const canvasY = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      if (isPointInRect([canvasX, canvasY], rect)) {
        indices.push(index);
        // 设置点的选中状态
        point[3] = true;
      }
    });
    
    return indices;
  };

/**
 * 更新矩形内点的位置(拖动时)
 */
 const updateSelectedPoints = (dx: number, dy: number, rect: Rectangle) => {
    // 将画布位移转换为数据位移
    const dataDX = dx / scaleX.value;
    const dataDY = -dy / scaleY.value; // 注意Y轴方向相反
    
    // 只更新矩形内的点
    rect.selectedPointIndices.forEach(index => {
      const point = drawingData.value[index];
      point[0] += dataDX;
      point[1] += dataDY;
    });
  };

/**
 * 更新矩形内点的位置(缩放时)
 */
 const updatePointsOnResize = (rect: Rectangle, oldRect: Rectangle) => {
    rect.selectedPointIndices.forEach(index => {
      const point = drawingData.value[index];
      
      // 将数据坐标转换为画布坐标
      const canvasX = (point[0] - minX.value) * scaleX.value + padding.value;
      const canvasY = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      // 计算点在原矩形中的相对位置(0-1之间)
      const oldWidth = oldRect.x2 - oldRect.x1;
      const oldHeight = oldRect.y2 - oldRect.y1;
      const relativeX = (canvasX - oldRect.x1) / oldWidth;
      const relativeY = (canvasY - oldRect.y1) / oldHeight;
      
      // 计算点在新矩形中的位置
      const newWidth = rect.x2 - rect.x1;
      const newHeight = rect.y2 - rect.y1;
      const newCanvasX = rect.x1 + (newWidth * relativeX);
      const newCanvasY = rect.y1 + (newHeight * relativeY);
      
      // 将新的画布坐标转换回数据坐标
      point[0] = (newCanvasX - padding.value) / scaleX.value + minX.value;
      point[1] = (height.value - newCanvasY + padding.value) / scaleY.value + minY.value;
    });
  };

5 事件处理相关操作

  • 当按下鼠标时,有三种情况
    • 开始绘制矩形
    • 选择了某个矩形,准备进行拖动或者缩放操作
    • 没选中矩形
  • 鼠标移动事件有两种触发情况
    • 拖拽矩形的角点,来改变矩形的尺寸
    • 拖动矩形,改变矩形的位置
  • 鼠标释放事件
    • 绘制状态:结束绘制,生成一个矩形
  • 鼠标离开画布事件
    • 取消绘制状态
// ================ 事件处理函数 ================
/**
 * 鼠标按下事件处理
 */
 const onm ouseDown = (event) => {
    const x = event.offsetX;
    const y = event.offsetY;
  
    if (isDrawing.value) {
      // 开始绘制新矩形
      selectedRect.value = { 
        x1: x, 
        y1: y, 
        x2: x, 
        y2: y, 
        selectedPointIndices: [] 
      };
    } else {
      // 查找点击的是否在某个已有矩形内
      const clickedRect = rectangles.value.find(rect => {
        // 先检查是否点击在角落
        if (rect === selectedRect.value) {
          const corner = getClickedCorner(x, y, rect);
          if (corner) {
            isResizing.value = true;
            resizeCorner.value = corner;
            return true;
          }
        }
        // 再检查是否点击在矩形内
        return isInsideRect(x, y, rect);
      });
      
      if (clickedRect) {
        selectedRect.value = clickedRect;
        if (!isResizing.value) {
          isDragging.value = true;
        }
        dragStartX.value = x;
        dragStartY.value = y;
      } else {
        selectedRect.value = null;
      }
    }
  };

  // 鼠标按下事件:判断是否点击在角落
  const getClickedCorner = (x, y, rect) => {
    const { x1, y1, x2, y2 } = rect;
    
    // 检查左上角
    if (Math.abs(x - x1) <= CORNER_SIZE && Math.abs(y - y1) <= CORNER_SIZE) {
      return 'topLeft';
    }
    // 检查右下角
    if (Math.abs(x - x2) <= CORNER_SIZE && Math.abs(y - y2) <= CORNER_SIZE) {
      return 'bottomRight';
    }
    return '';
  };

  // 鼠标按下事件:检查点是否在矩形内
  const isInsideRect = (x, y, rect) => {
    return x >= rect.x1 && x <= rect.x2 && y >= rect.y1 && y <= rect.y2;
  };

/**
 * 鼠标移动事件处理
 */
 const onm ouseMove = (event) => {
    const x = event.offsetX;
    const y = event.offsetY;
  
    if (isDrawing.value && selectedRect.value) {
      selectedRect.value.x2 = x;
      selectedRect.value.y2 = y;
    } else if (isResizing.value && selectedRect.value) {
      // 保存调整前的矩形状态
      const oldRect = { ...selectedRect.value };
  
      if (resizeCorner.value === 'topLeft') {
        selectedRect.value.x1 = x;
        selectedRect.value.y1 = y;
      } else if (resizeCorner.value === 'bottomRight') {
        selectedRect.value.x2 = x;
        selectedRect.value.y2 = y;
      }
  
      // 更新点位置
      updatePointsOnResize(selectedRect.value, oldRect);
    } else if (isDragging.value && selectedRect.value) {
      // 计算移动距离
      const dx = x - dragStartX.value;
      const dy = y - dragStartY.value;
      
      // 移动矩形
      selectedRect.value.x1 += dx;
      selectedRect.value.y1 += dy;
      selectedRect.value.x2 += dx;
      selectedRect.value.y2 += dy;
      
      // 只更新选中的点
      updateSelectedPoints(dx, dy, selectedRect.value);
      
      // 更新拖动起始位置
      dragStartX.value = x;
      dragStartY.value = y;
    }
    
    drawCanvas();
  };

/**
 * 鼠标释放事件处理
 */
 const onm ouseUp = () => {
    if (isDrawing.value && selectedRect.value) {
      // 获取矩形内的点的索引
      const selectedIndices = getPointsInRect(selectedRect.value);
      
      // 创建新矩形,包含选中点的索引
      const newRect: Rectangle = {
        ...selectedRect.value,
        selectedPointIndices: selectedIndices
      };
      
      rectangles.value.push(newRect);
      selectedRect.value = null;
      isDrawing.value = false;
    }
    
    // 重置所有状态
    isDragging.value = false;
    isResizing.value = false;
    resizeCorner.value = '';
    
    drawCanvas();
  };

/**
 * 鼠标离开画布事件处理
 */
 const onm ouseLeave = () => {
    if (isDrawing.value) {
      // 取消绘制矩形的状态
      selectedRect.value = null;
      isDrawing.value = false;
      drawCanvas();
    }
  };

6 工具栏相关操作

  • 开始绘制函数
  • 删除矩形函数:当删除矩形后,矩形内的drawingData的数据点的位置被固定(除非重新绘制一个矩形)
  • 还原到初始状态:防止矩形的reshape等操作出问题,提供还原功能
  • 导出到csv:将reshape的drawingData数据导出,实战时应该传回后端,重新匹配
// ================ 功能操作函数 ================
/**
 * 开始绘制矩形
 */
const startDrawingRect = () => {
  isDrawing.value = true;
};

/**
 * 删除选中的矩形
 */
 const deleteSelectedRect = () => {
    if (selectedRect.value) {
      // 找到选中矩形的索引
      const index = rectangles.value.findIndex(rect => rect === selectedRect.value);
      if (index > -1) {
        // 恢复该矩形内点的未选中状态
        selectedRect.value.selectedPointIndices.forEach(pointIndex => {
          drawingData.value[pointIndex][3] = false;
        });
        
        // 从数组中移除该矩形
        rectangles.value.splice(index, 1);
        selectedRect.value = null;
        
        // 重绘画布
        drawCanvas();
      }
    }
  };

/**
 * 还原到初始状态
 */
 const resetToInitialState = () => {
    // 确认对话框
    if (rectangles.value.length > 0 && !confirm('确定要还原到初始状态吗?这将清除所有矩形框。')) {
      return;
    }
    
    // 还原数据到初始状态
    drawingData.value = JSON.parse(JSON.stringify(initialDrawingData.value));
    
    // 清除所有矩形
    rectangles.value = [];
    selectedRect.value = null;
    
    // 重置所有状态
    isDrawing.value = false;
    isDragging.value = false;
    isResizing.value = false;
    resizeCorner.value = '';
    
    // 重绘画布
    drawCanvas();
  };

/**
 * 导出数据到CSV
 */
 const exportToCSV = () => {
    try {
      // 准备数据
      const exportData = drawingData.value.map(point => {
        // 还原偏移,转换回原始坐标
        const originalX = point[0] + geoOffsetX.value;
        const originalY = point[1] + geoOffsetY.value;
        
        // 返回转换后的数据(只包含坐标和标签,不包含选中状态)
        return [
          originalX.toFixed(6), // 保留6位小数
          originalY.toFixed(6),
          point[2] // 标签
        ].join(' '); // 使用空格分隔
      });
      
      // 添加CSV头部
      const header = 'x y label'; // CSV文件的头部
      const csvContent = [header, ...exportData].join('\n');
      
      // 创建Blob对象
      const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
      
      // 创建下载链接
      const link = document.createElement('a');
      const url = URL.createObjectURL(blob);
      
      // 设置下载属性
      link.setAttribute('href', url);
      link.setAttribute('download', `drawing_data_${getFormattedDateTime()}.csv`);
      
      // 添加到文档并触发下载
      document.body.appendChild(link);
      link.click();
      
      // 清理
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
      
      // 提示成功
      alert('数据导出成功!');
    } catch (error) {
      console.error('导出失败:', error);
      alert('导出失败,请查看控制台了解详情。');
    }
  };

  // 导出功能:获取格式化的日期时间字符串
  const getFormattedDateTime = () => {
    const now = new Date();
    return `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${
      now.getDate().toString().padStart(2, '0')}_${
      now.getHours().toString().padStart(2, '0')}${
      now.getMinutes().toString().padStart(2, '0')}${
      now.getSeconds().toString().padStart(2, '0')}`;
  };

7 画布渲染

 const drawCanvas = () => {
    const canvas = document.querySelector('canvas');
    const ctx = canvas?.getContext('2d');
    
    if (!ctx) return;
    
    // 获取数据的最大最小值
    const allData = [...geoData.value, ...drawingData.value];
    const xValues = allData.map(row => row[0]);
    const yValues = allData.map(row => row[1]);
    minX.value = Math.min(...xValues);
    maxX.value = Math.max(...xValues);
    minY.value = Math.min(...yValues);
    maxY.value = Math.max(...yValues);
    
    // 设置坐标轴范围
    padding.value = 20;
    width.value = canvas.width - 2 * padding.value;
    height.value = canvas.height - 2 * padding.value;
    
    // 计算比例尺
    scaleX.value = width.value / (maxX.value - minX.value);
    scaleY.value = height.value / (maxY.value - minY.value);
  
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 绘制坐标轴
    ctx.beginPath();
    ctx.moveTo(padding.value, padding.value);
    ctx.lineTo(padding.value, height.value + padding.value);
    ctx.lineTo(width.value + padding.value, height.value + padding.value);
    ctx.stroke();
    
    // 绘制 x 轴标签
    ctx.fillText(minX.value.toFixed(2), padding.value, height.value + padding.value + 15);
    ctx.fillText(maxX.value.toFixed(2), width.value + padding.value, height.value + padding.value + 15);
    
    // 绘制 y 轴标签
    ctx.fillText(maxY.value.toFixed(2), padding.value - 15, padding.value);
    ctx.fillText(minY.value.toFixed(2), padding.value - 15, height.value + padding.value);
    
    // 绘制 geoData 数据点
    ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
    geoData.value.forEach(row => {
      const x = (row[0] - minX.value) * scaleX.value + padding.value;
      const y = height.value - (row[1] - minY.value) * scaleY.value + padding.value;
      ctx.beginPath();
      ctx.arc(x, y, 2, 0, Math.PI * 2);
      ctx.fill();
    });
    
    // 绘制 drawingData 数据点
    drawingData.value.forEach(point => {
      const x = (point[0] - minX.value) * scaleX.value + padding.value;
      const y = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      // 根据选中状态设置颜色
      ctx.fillStyle = point[3] ? 'green' : 'red';
      
      ctx.beginPath();
      ctx.arc(x, y, 2, 0, Math.PI * 2);
      ctx.fill();
    });
    
    // 绘制所有矩形
    rectangles.value.forEach(rect => {
      // 设置矩形样式
      ctx.strokeStyle = rect === selectedRect.value ? 'red' : 'blue';
      ctx.lineWidth = 2;
      
      // 绘制矩形
      ctx.strokeRect(
        rect.x1, 
        rect.y1, 
        rect.x2 - rect.x1, 
        rect.y2 - rect.y1
      );
      
      // 如果是选中的矩形,绘制调整大小的角落标记
      if (rect === selectedRect.value) {
        ctx.fillStyle = 'blue';
        // 左上角
        ctx.fillRect(
          rect.x1 - CORNER_SIZE/2, 
          rect.y1 - CORNER_SIZE/2, 
          CORNER_SIZE, 
          CORNER_SIZE
        );
        // 右下角
        ctx.fillRect(
          rect.x2 - CORNER_SIZE/2, 
          rect.y2 - CORNER_SIZE/2, 
          CORNER_SIZE, 
          CORNER_SIZE
        );
      }
    });
    
    // 如果正在绘制新矩形,也绘制它
    if (isDrawing.value && selectedRect.value) {
      ctx.strokeStyle = 'red';
      ctx.lineWidth = 2;
      ctx.strokeRect(
        selectedRect.value.x1,
        selectedRect.value.y1,
        selectedRect.value.x2 - selectedRect.value.x1,
        selectedRect.value.y2 - selectedRect.value.y1
      );
    }
  };

8 其余操作

// ================ 生命周期钩子 ================
onMounted(async () => {
  // 加载数据
  geoData.value = await loadCSV(geoDataUrl);
  drawingData.value = await loadCSV(drawingDataUrl);
  
  // 处理数据
  offsetTwoData(geoData, drawingData);
  initialDrawingData.value = JSON.parse(JSON.stringify(drawingData.value));
  
  // 初始化画布
  drawCanvas();
});
</script>

<style scoped>
.match-container {
  height: calc(92vh - 60px);
  border: 1px solid black;
}

.canvas-container {
  width: 50%;
  height: 100%;
  border: 1px solid black;
  margin: auto;
}

.toolbar {
  padding: 10px;
  display: flex;
  gap: 10px;
}

.toolbar button {
  padding: 5px 10px;
  cursor: pointer;
}

.toolbar button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

</style>

完整代码

<template>
  <div class="match-container">
    <div class="toolbar">
      <button @click="startDrawingRect">绘制矩形框</button>
      <button v-if="selectedRect" @click="deleteSelectedRect">删除选中的矩形框</button>
      <button @click="resetToInitialState">还原初始状态</button>
      <button @click="exportToCSV">导出数据</button>
    </div>
    <div class="canvas-container">
      <canvas ref="canvas" width="850" height="650" 
        @mousedown="onMouseDown" 
        @mousemove="onMouseMove" 
        @mouseup="onMouseUp" 
        @mouseleave="onMouseLeave">
      </canvas>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';

// ================ 类型定义 ================
interface Rectangle {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  selectedPointIndices: number[]; // 存储矩形内点的索引
}

// ================ 状态变量 ================
// 数据相关
const geoData = ref([]);                    // 地理数据点
const drawingData = ref([]);                // 绘制数据点
const initialDrawingData = ref([]);         // 初始状态的数据
const rectangles = ref<Rectangle[]>([]);    // 矩形框数组

// 坐标系相关
const geoOffsetX = ref(0);                  // 地理数据X偏移
const geoOffsetY = ref(0);                  // 地理数据Y偏移
const width = ref(0);                       // 画布宽度
const height = ref(0);                      // 画布高度
const padding = ref(20);                    // 画布内边距
const scaleX = ref(0);                      // X轴缩放比例
const scaleY = ref(0);                      // Y轴缩放比例
const minX = ref(0);                        // X轴最小值
const minY = ref(0);                        // Y轴最小值
const maxX = ref(0);                        // X轴最大值
const maxY = ref(0);                        // Y轴最大值

// 交互状态
const CORNER_SIZE = 10;                     // 角落判定范围大小
const isDrawing = ref(false);               // 是否正在绘制矩形
const selectedRect = ref(null);             // 当前选中的矩形
const isDragging = ref(false);              // 是否正在拖拽矩形
const isResizing = ref(false);              // 是否正在调整大小
const resizeCorner = ref('');               // 正在调整的角落
const dragStartX = ref(0);                  // 拖拽起始X坐标
const dragStartY = ref(0);                  // 拖拽起始Y坐标

// 数据源路径
const geoDataUrl = './datasets/match/g_data.csv';
const drawingDataUrl = './datasets/match/d_data.csv';

// ================ 数据处理函数 ================
/**
 * 加载CSV数据
 * @param url - CSV文件路径
 * @returns 解析后的数据数组
 */
 const loadCSV = async (url) => {
    const response = await fetch(url);
    const text = await response.text();
  
    const parsedData = text.split('\n').slice(1).map(row => {
      const columns = row.split(' ');
      return [
        ...columns.map((column, index) => {
          if (index === 0 || index === 1) {
            return Number(column.trim());
          }
          return column.trim();
        }),
        false  // 添加选中状态标记,默认为 false
      ];
    });
    
    // 深拷贝保存初始状态
    initialDrawingData.value = JSON.parse(JSON.stringify(parsedData));
    
    return parsedData;
  };

/**
 * 对齐两组数据的坐标系
 */
 const offsetTwoData = (geoData, drawingData) => {
    const minGeoX = Math.min(...geoData.value.map(row => row[0]));
    const minGeoY = Math.min(...geoData.value.map(row => row[1]));
    const minDrawingX = Math.min(...drawingData.value.map(row => row[0]));
    const minDrawingY = Math.min(...drawingData.value.map(row => row[1]));
    const minX = Math.min(minGeoX, minDrawingX);
    const minY = Math.min(minGeoY, minDrawingY);
    
    geoData.value.forEach(row => {
      row[0] = Number(row[0]) - minX;
      row[1] = Number(row[1]) - minY;
    });
    drawingData.value.forEach(row => {
      row[0] = Number(row[0]) - minX;
      row[1] = Number(row[1]) - minY;
    });
    
    geoOffsetX.value = minX;
    geoOffsetY.value = minY;
  };

// ================ 矩形操作函数 ================
/**
 * 判断点是否在矩形内
 */
 const isPointInRect = (point, rect) => {
    const [x, y] = point;
    const minX = Math.min(rect.x1, rect.x2);
    const maxX = Math.max(rect.x1, rect.x2);
    const minY = Math.min(rect.y1, rect.y2);
    const maxY = Math.max(rect.y1, rect.y2);
    
    return x > minX && x < maxX && y > minY && y < maxY;
  };

/**
 * 获取矩形内的所有点索引
 */
 const getPointsInRect = (rect: Rectangle) => {
    const indices: number[] = [];
    
    drawingData.value.forEach((point, index) => {
      // 将数据坐标转换为画布坐标
      const canvasX = (point[0] - minX.value) * scaleX.value + padding.value;
      const canvasY = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      if (isPointInRect([canvasX, canvasY], rect)) {
        indices.push(index);
        // 设置点的选中状态
        point[3] = true;
      }
    });
    
    return indices;
  };

/**
 * 更新矩形内点的位置(拖动时)
 */
 const updateSelectedPoints = (dx: number, dy: number, rect: Rectangle) => {
    // 将画布位移转换为数据位移
    const dataDX = dx / scaleX.value;
    const dataDY = -dy / scaleY.value; // 注意Y轴方向相反
    
    // 只更新矩形内的点
    rect.selectedPointIndices.forEach(index => {
      const point = drawingData.value[index];
      point[0] += dataDX;
      point[1] += dataDY;
    });
  };

/**
 * 更新矩形内点的位置(缩放时)
 */
 const updatePointsOnResize = (rect: Rectangle, oldRect: Rectangle) => {
    rect.selectedPointIndices.forEach(index => {
      const point = drawingData.value[index];
      
      // 将数据坐标转换为画布坐标
      const canvasX = (point[0] - minX.value) * scaleX.value + padding.value;
      const canvasY = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      // 计算点在原矩形中的相对位置(0-1之间)
      const oldWidth = oldRect.x2 - oldRect.x1;
      const oldHeight = oldRect.y2 - oldRect.y1;
      const relativeX = (canvasX - oldRect.x1) / oldWidth;
      const relativeY = (canvasY - oldRect.y1) / oldHeight;
      
      // 计算点在新矩形中的位置
      const newWidth = rect.x2 - rect.x1;
      const newHeight = rect.y2 - rect.y1;
      const newCanvasX = rect.x1 + (newWidth * relativeX);
      const newCanvasY = rect.y1 + (newHeight * relativeY);
      
      // 将新的画布坐标转换回数据坐标
      point[0] = (newCanvasX - padding.value) / scaleX.value + minX.value;
      point[1] = (height.value - newCanvasY + padding.value) / scaleY.value + minY.value;
    });
  };

// ================ 事件处理函数 ================
/**
 * 鼠标按下事件处理
 */
 const onm ouseDown = (event) => {
    const x = event.offsetX;
    const y = event.offsetY;
  
    if (isDrawing.value) {
      // 开始绘制新矩形
      selectedRect.value = { 
        x1: x, 
        y1: y, 
        x2: x, 
        y2: y, 
        selectedPointIndices: [] 
      };
    } else {
      // 查找点击的是否在某个已有矩形内
      const clickedRect = rectangles.value.find(rect => {
        // 先检查是否点击在角落
        if (rect === selectedRect.value) {
          const corner = getClickedCorner(x, y, rect);
          if (corner) {
            isResizing.value = true;
            resizeCorner.value = corner;
            return true;
          }
        }
        // 再检查是否点击在矩形内
        return isInsideRect(x, y, rect);
      });
      
      if (clickedRect) {
        selectedRect.value = clickedRect;
        if (!isResizing.value) {
          isDragging.value = true;
        }
        dragStartX.value = x;
        dragStartY.value = y;
      } else {
        selectedRect.value = null;
      }
    }
  };

  // 鼠标按下事件:判断是否点击在角落
  const getClickedCorner = (x, y, rect) => {
    const { x1, y1, x2, y2 } = rect;
    
    // 检查左上角
    if (Math.abs(x - x1) <= CORNER_SIZE && Math.abs(y - y1) <= CORNER_SIZE) {
      return 'topLeft';
    }
    // 检查右下角
    if (Math.abs(x - x2) <= CORNER_SIZE && Math.abs(y - y2) <= CORNER_SIZE) {
      return 'bottomRight';
    }
    return '';
  };

  // 鼠标按下事件:检查点是否在矩形内
  const isInsideRect = (x, y, rect) => {
    return x >= rect.x1 && x <= rect.x2 && y >= rect.y1 && y <= rect.y2;
  };

/**
 * 鼠标移动事件处理
 */
 const onm ouseMove = (event) => {
    const x = event.offsetX;
    const y = event.offsetY;
  
    if (isDrawing.value && selectedRect.value) {
      selectedRect.value.x2 = x;
      selectedRect.value.y2 = y;
    } else if (isResizing.value && selectedRect.value) {
      // 保存调整前的矩形状态
      const oldRect = { ...selectedRect.value };
  
      if (resizeCorner.value === 'topLeft') {
        selectedRect.value.x1 = x;
        selectedRect.value.y1 = y;
      } else if (resizeCorner.value === 'bottomRight') {
        selectedRect.value.x2 = x;
        selectedRect.value.y2 = y;
      }
  
      // 更新点位置
      updatePointsOnResize(selectedRect.value, oldRect);
    } else if (isDragging.value && selectedRect.value) {
      // 计算移动距离
      const dx = x - dragStartX.value;
      const dy = y - dragStartY.value;
      
      // 移动矩形
      selectedRect.value.x1 += dx;
      selectedRect.value.y1 += dy;
      selectedRect.value.x2 += dx;
      selectedRect.value.y2 += dy;
      
      // 只更新选中的点
      updateSelectedPoints(dx, dy, selectedRect.value);
      
      // 更新拖动起始位置
      dragStartX.value = x;
      dragStartY.value = y;
    }
    
    drawCanvas();
  };

/**
 * 鼠标释放事件处理
 */
 const onm ouseUp = () => {
    if (isDrawing.value && selectedRect.value) {
      // 获取矩形内的点的索引
      const selectedIndices = getPointsInRect(selectedRect.value);
      
      // 创建新矩形,包含选中点的索引
      const newRect: Rectangle = {
        ...selectedRect.value,
        selectedPointIndices: selectedIndices
      };
      
      rectangles.value.push(newRect);
      selectedRect.value = null;
      isDrawing.value = false;
    }
    
    // 重置所有状态
    isDragging.value = false;
    isResizing.value = false;
    resizeCorner.value = '';
    
    drawCanvas();
  };

/**
 * 鼠标离开画布事件处理
 */
 const onm ouseLeave = () => {
    if (isDrawing.value) {
      // 取消绘制矩形的状态
      selectedRect.value = null;
      isDrawing.value = false;
      drawCanvas();
    }
  };

// ================ 功能操作函数 ================
/**
 * 开始绘制矩形
 */
const startDrawingRect = () => {
  isDrawing.value = true;
};

/**
 * 删除选中的矩形
 */
 const deleteSelectedRect = () => {
    if (selectedRect.value) {
      // 找到选中矩形的索引
      const index = rectangles.value.findIndex(rect => rect === selectedRect.value);
      if (index > -1) {
        // 恢复该矩形内点的未选中状态
        selectedRect.value.selectedPointIndices.forEach(pointIndex => {
          drawingData.value[pointIndex][3] = false;
        });
        
        // 从数组中移除该矩形
        rectangles.value.splice(index, 1);
        selectedRect.value = null;
        
        // 重绘画布
        drawCanvas();
      }
    }
  };

/**
 * 还原到初始状态
 */
 const resetToInitialState = () => {
    // 确认对话框
    if (rectangles.value.length > 0 && !confirm('确定要还原到初始状态吗?这将清除所有矩形框。')) {
      return;
    }
    
    // 还原数据到初始状态
    drawingData.value = JSON.parse(JSON.stringify(initialDrawingData.value));
    
    // 清除所有矩形
    rectangles.value = [];
    selectedRect.value = null;
    
    // 重置所有状态
    isDrawing.value = false;
    isDragging.value = false;
    isResizing.value = false;
    resizeCorner.value = '';
    
    // 重绘画布
    drawCanvas();
  };

/**
 * 导出数据到CSV
 */
 const exportToCSV = () => {
    try {
      // 准备数据
      const exportData = drawingData.value.map(point => {
        // 还原偏移,转换回原始坐标
        const originalX = point[0] + geoOffsetX.value;
        const originalY = point[1] + geoOffsetY.value;
        
        // 返回转换后的数据(只包含坐标和标签,不包含选中状态)
        return [
          originalX.toFixed(6), // 保留6位小数
          originalY.toFixed(6),
          point[2] // 标签
        ].join(' '); // 使用空格分隔
      });
      
      // 添加CSV头部
      const header = 'x y label'; // CSV文件的头部
      const csvContent = [header, ...exportData].join('\n');
      
      // 创建Blob对象
      const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
      
      // 创建下载链接
      const link = document.createElement('a');
      const url = URL.createObjectURL(blob);
      
      // 设置下载属性
      link.setAttribute('href', url);
      link.setAttribute('download', `drawing_data_${getFormattedDateTime()}.csv`);
      
      // 添加到文档并触发下载
      document.body.appendChild(link);
      link.click();
      
      // 清理
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
      
      // 提示成功
      alert('数据导出成功!');
    } catch (error) {
      console.error('导出失败:', error);
      alert('导出失败,请查看控制台了解详情。');
    }
  };

  // 导出功能:获取格式化的日期时间字符串
  const getFormattedDateTime = () => {
    const now = new Date();
    return `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${
      now.getDate().toString().padStart(2, '0')}_${
      now.getHours().toString().padStart(2, '0')}${
      now.getMinutes().toString().padStart(2, '0')}${
      now.getSeconds().toString().padStart(2, '0')}`;
  };

// ================ 画布渲染函数 ================
/**
 * 绘制画布内容
 */
 const drawCanvas = () => {
    const canvas = document.querySelector('canvas');
    const ctx = canvas?.getContext('2d');
    
    if (!ctx) return;
    
    // 获取数据的最大最小值
    const allData = [...geoData.value, ...drawingData.value];
    const xValues = allData.map(row => row[0]);
    const yValues = allData.map(row => row[1]);
    minX.value = Math.min(...xValues);
    maxX.value = Math.max(...xValues);
    minY.value = Math.min(...yValues);
    maxY.value = Math.max(...yValues);
    
    // 设置坐标轴范围
    padding.value = 20;
    width.value = canvas.width - 2 * padding.value;
    height.value = canvas.height - 2 * padding.value;
    
    // 计算比例尺
    scaleX.value = width.value / (maxX.value - minX.value);
    scaleY.value = height.value / (maxY.value - minY.value);
  
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 绘制坐标轴
    ctx.beginPath();
    ctx.moveTo(padding.value, padding.value);
    ctx.lineTo(padding.value, height.value + padding.value);
    ctx.lineTo(width.value + padding.value, height.value + padding.value);
    ctx.stroke();
    
    // 绘制 x 轴标签
    ctx.fillText(minX.value.toFixed(2), padding.value, height.value + padding.value + 15);
    ctx.fillText(maxX.value.toFixed(2), width.value + padding.value, height.value + padding.value + 15);
    
    // 绘制 y 轴标签
    ctx.fillText(maxY.value.toFixed(2), padding.value - 15, padding.value);
    ctx.fillText(minY.value.toFixed(2), padding.value - 15, height.value + padding.value);
    
    // 绘制 geoData 数据点
    ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
    geoData.value.forEach(row => {
      const x = (row[0] - minX.value) * scaleX.value + padding.value;
      const y = height.value - (row[1] - minY.value) * scaleY.value + padding.value;
      ctx.beginPath();
      ctx.arc(x, y, 2, 0, Math.PI * 2);
      ctx.fill();
    });
    
    // 绘制 drawingData 数据点
    drawingData.value.forEach(point => {
      const x = (point[0] - minX.value) * scaleX.value + padding.value;
      const y = height.value - (point[1] - minY.value) * scaleY.value + padding.value;
      
      // 根据选中状态设置颜色
      ctx.fillStyle = point[3] ? 'green' : 'red';
      
      ctx.beginPath();
      ctx.arc(x, y, 2, 0, Math.PI * 2);
      ctx.fill();
    });
    
    // 绘制所有矩形
    rectangles.value.forEach(rect => {
      // 设置矩形样式
      ctx.strokeStyle = rect === selectedRect.value ? 'red' : 'blue';
      ctx.lineWidth = 2;
      
      // 绘制矩形
      ctx.strokeRect(
        rect.x1, 
        rect.y1, 
        rect.x2 - rect.x1, 
        rect.y2 - rect.y1
      );
      
      // 如果是选中的矩形,绘制调整大小的角落标记
      if (rect === selectedRect.value) {
        ctx.fillStyle = 'blue';
        // 左上角
        ctx.fillRect(
          rect.x1 - CORNER_SIZE/2, 
          rect.y1 - CORNER_SIZE/2, 
          CORNER_SIZE, 
          CORNER_SIZE
        );
        // 右下角
        ctx.fillRect(
          rect.x2 - CORNER_SIZE/2, 
          rect.y2 - CORNER_SIZE/2, 
          CORNER_SIZE, 
          CORNER_SIZE
        );
      }
    });
    
    // 如果正在绘制新矩形,也绘制它
    if (isDrawing.value && selectedRect.value) {
      ctx.strokeStyle = 'red';
      ctx.lineWidth = 2;
      ctx.strokeRect(
        selectedRect.value.x1,
        selectedRect.value.y1,
        selectedRect.value.x2 - selectedRect.value.x1,
        selectedRect.value.y2 - selectedRect.value.y1
      );
    }
  };

// ================ 生命周期钩子 ================
onMounted(async () => {
  // 加载数据
  geoData.value = await loadCSV(geoDataUrl);
  drawingData.value = await loadCSV(drawingDataUrl);
  
  // 处理数据
  offsetTwoData(geoData, drawingData);
  initialDrawingData.value = JSON.parse(JSON.stringify(drawingData.value));
  
  // 初始化画布
  drawCanvas();
});
</script>

<style scoped>
.match-container {
  height: calc(92vh - 60px);
  border: 1px solid black;
}

.canvas-container {
  width: 50%;
  height: 100%;
  border: 1px solid black;
  margin: auto;
}

.toolbar {
  padding: 10px;
  display: flex;
  gap: 10px;
}

.toolbar button {
  padding: 5px 10px;
  cursor: pointer;
}

.toolbar button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

</style>

标签:canvas,vue,const,selectedRect,value,padding,矩形框,矩形,rect
From: https://www.cnblogs.com/Frey-Li/p/18617563

相关文章

  • 掌握pnpm Workspace秘诀:轻松管理多个Vue项目,告别混乱!
    个人使用背景:公司多个后台系统想共用同一套ui框架和组件共用,方便使用组件库二次封装的内容,已经一些共用的api,所以使用workspace官方文档:pnpm内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持。你可以创建一个工作空间以将多个项目合并到一个仓库中。......
  • Vue零基础教程|从前端框架到GIS开发系列课程(六)组合式API
    前文指路:Vue零基础教程|从前端框架到GIS开发系列课程(五)组件式开发Vue零基础教程|从前端框架到GIS开发系列课程(四)计算属性与侦听器Vue零基础教程|从前端框架到GIS开发系列课程(三)模板语法Vue零基础教程|从前端框架到GIS开发系列课程(二)0帧起手《Vue零基础教程》,从前端框架到G......
  • vue-进行分组----将轮播图数据进行分组
    效果展示第一步将数据进行分组处理例如:数据是这样的处理方法一:进行两次for循环处理方法二:进行一次for循环......
  • 网页直播/点播播放器EasyPlayer.js如何在Vue3中使用?
    近来很多用户对何为Vue3产生了疑问,其实Vue3就是一个用于构建用户界面的渐进式JavaScript框架。Vue3于2020年发布带来了全新的CompositionAPI、改进的性能、TypeScript支持和更好的处理大型应用程序的能力。Vue3在保留了Vue2的易用性的同时,提供了更强大的功能,为开发者在实现复杂应......
  • 《Vue进阶教程》第十一课:响应式系统介绍
    1什么是响应式当数据改变时,引用数据的函数会自动重新执行2手动完成响应过程首先,明确一个概念:响应式是一个过程,这个过程存在两个参与者:一方触发,另一方响应比如说,我们家小胖有时候不乖,我会打他,他会哭.这里我就是触发者,小胖就是响应者同样,所谓数据......
  • 给我2分钟,保证教会你在Vue3中实现一个定高的虚拟列表
    前言虚拟列表对于大部分一线开发同学来说是一点都不陌生的东西了,有的同学是直接使用第三方组件。但是面试时如果你简历上面写了虚拟列表,却给面试官说是通过三方组件实现的,此时空气可能都凝固了。所以这篇文章欧阳将会教你2分钟内实现一个定高的虚拟列表,至于不定高的虚拟列表下一......
  • vue3 wspt 插件的使用 wsplayer无插件开发包封装
    基于大华插件:H5播放器开发套件(wsplayer无插件开发包)V1.2.9使用方法npmiwsptwspt使用说明1.找到node_modules目录中wspt文件夹,将static文件夹、jquery.min.js、palyer.css、PlayerControl.js、WSPlayer.js文件复制到项目public目录下。public|--jquery.min.js......
  • 基于SpringBoot+Vue实现的个人备忘录系统
    ......
  • vue-实现loading页面
    效果实现步骤第一步先编写一个加载页面在APP.vue中引入将控制加载的变量添加到状态管理库中例如pinia或VueX中在loading页面中导入常量并控制主体是否显示在请求拦截器和响应拦截器里配置......
  • vue-axios响应请求拦截器
    importaxiosfrom"axios";//import{ElMessage}from'element-plus'import{BASE_URL,TIMEOUT}from"../config";constAxios=axios.create({ //后端url地址baseURL:BASE_URL,//设置超时时间timeout:TIMEOUT,//请求头类型/......