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 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