demo简介
- 读取两个csv文件(geo数据和drawing数据)
- 绘制散点图
- 使用矩形框选中范围内的数据(只选中drawing数据)
- 拖动矩形框 或 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>