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

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 onMouseDown = (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 onMouseMove = (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 onMouseUp = () => {
    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 onMouseLeave = () => {
    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 onMouseDown = (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 onMouseMove = (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 onMouseUp = () => {
    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 onMouseLeave = () => {
    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>

posted @   梧桐灯下江楚滢  阅读(55)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET10 - 预览版1新功能体验(一)
历史上的今天:
2021-12-19 对象-构造器详解
点击右上角即可分享
微信分享提示