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);
const geoOffsetY = ref(0);
const width = ref(0);
const height = ref(0);
const padding = ref(20);
const scaleX = ref(0);
const scaleY = ref(0);
const minX = ref(0);
const minY = ref(0);
const maxX = ref(0);
const maxY = ref(0);
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);
const dragStartY = ref(0);
const geoDataUrl = './datasets/match/g_data.csv';
const drawingDataUrl = './datasets/match/d_data.csv';
3 数据加载与处理
- 加载数据时,增加一个状态标记,方便后续进行矩形内的数据点选择
- 使用initialDrawingData来记录初始状态,方便还原
- 对考虑到原始数据有一个偏移值,不便于绘制,就先进行偏移,使数据靠近原点(0,0)坐标
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
];
});
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;
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;
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();
};
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),
originalY.toFixed(6),
point[2]
].join(' ');
});
const header = 'x y label';
const csvContent = [header, ...exportData].join('\n');
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();
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);
ctx.fillText(maxY.value.toFixed(2), padding.value - 15, padding.value);
ctx.fillText(minY.value.toFixed(2), padding.value - 15, height.value + padding.value);
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.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);
const geoOffsetY = ref(0);
const width = ref(0);
const height = ref(0);
const padding = ref(20);
const scaleX = ref(0);
const scaleY = ref(0);
const minX = ref(0);
const minY = ref(0);
const maxX = ref(0);
const maxY = ref(0);
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);
const dragStartY = ref(0);
const geoDataUrl = './datasets/match/g_data.csv';
const drawingDataUrl = './datasets/match/d_data.csv';
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
];
});
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;
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;
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();
};
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),
originalY.toFixed(6),
point[2]
].join(' ');
});
const header = 'x y label';
const csvContent = [header, ...exportData].join('\n');
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();
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);
ctx.fillText(maxY.value.toFixed(2), padding.value - 15, padding.value);
ctx.fillText(minY.value.toFixed(2), padding.value - 15, height.value + padding.value);
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.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>
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET10 - 预览版1新功能体验(一)
2021-12-19 对象-构造器详解