feat(map): 优化地图缩放逻辑及点位渲染

- 将设备管理地图最大缩放比例提升至15,支持更大范围缩放
- 修改地图缩放增量为按倍率缩放,提升缩放体验平滑度
- 新增根据地图宽高比计算默认缩放,消除宽扁地图显示过小问题
- 自动缩放时聚焦地图原点,提升用户定位便利性
- 调整地图点位渲染样式,统一圆圈大小,简化视觉效果,只用描边色区分
- 移除点位标签背景框绘制,减少视觉干扰
- 鼠标滚轮缩放时以光标为中心,缩放过程视野自动平移保持焦点
- 集成中心页面地图缩放支持按倍率调整和平滑过渡
- 优化保存巡检任务时进度条逻辑,取消模拟递增,调用完成立即完成进度
- 增强地图缩放动画过渡时长,从0.18秒提升至0.45秒
- 修复集成中心页面真实点位显示优化,起点终点显示标签,途经点隐藏标签
This commit is contained in:
2026-08-12 11:11:07 +08:00
parent d29075e9b4
commit 03d319ebf6
2 changed files with 106 additions and 122 deletions

View File

@@ -269,8 +269,7 @@ const mapStageRef = ref<HTMLElement | null>(null);
const mapCanvasRef = ref<HTMLCanvasElement | null>(null); const mapCanvasRef = ref<HTMLCanvasElement | null>(null);
const suppressStageClick = ref(false); const suppressStageClick = ref(false);
const minMapZoom = 0.6; const minMapZoom = 0.6;
const maxMapZoom = 3; const maxMapZoom = 15;
const mapZoomStep = 0.2;
const mapZoom = ref(1); const mapZoom = ref(1);
const mapZoomPercent = computed(() => Math.round(mapZoom.value * 100)); const mapZoomPercent = computed(() => Math.round(mapZoom.value * 100));
@@ -338,29 +337,21 @@ const mapPointPalette: Record<
{ {
stroke: string; stroke: string;
glow: string; glow: string;
labelBackground: string;
labelBorder: string;
} }
> = { > = {
业务点: { 业务点: {
stroke: "#66d7ff", stroke: "#66d7ff",
glow: "rgba(102, 215, 255, 0.28)", glow: "rgba(102, 215, 255, 0.28)",
labelBackground: "rgba(16, 74, 118, 0.92)",
labelBorder: "rgba(91, 208, 255, 0.18)",
}, },
途经点: { 途经点: {
stroke: "#9af7c8", stroke: "#9af7c8",
glow: "rgba(154, 247, 200, 0.3)", glow: "rgba(154, 247, 200, 0.3)",
labelBackground: "rgba(18, 90, 66, 0.94)",
labelBorder: "rgba(91, 208, 255, 0.18)",
}, },
}; };
const selectedPointHighlight = { const selectedPointHighlight = {
stroke: "#ffb26e", stroke: "#ffb26e",
glow: "rgba(255, 178, 110, 0.32)", glow: "rgba(255, 178, 110, 0.32)",
labelBackground: "rgba(122, 72, 18, 0.94)",
labelBorder: "rgba(255, 196, 118, 0.72)",
}; };
const defaultRosMapMeta: RosMapMetaState = { const defaultRosMapMeta: RosMapMetaState = {
@@ -382,6 +373,10 @@ const mapConfigInputRef = ref<HTMLInputElement | null>(null);
rosMapImage.onload = () => { rosMapImage.onload = () => {
rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width; rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width;
rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height; rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height;
// 图片尺寸就绪后按实际宽高比重算默认缩放,避免宽扁地图默认显示过小
if (configDialogVisible.value) {
resetMapView();
}
scheduleMapCanvasRender(); scheduleMapCanvasRender();
}; };
rosMapImage.src = rosMapMeta.imageUrl; rosMapImage.src = rosMapMeta.imageUrl;
@@ -1322,7 +1317,6 @@ async function openConfigDialog(device: DeviceRecord) {
configuringDeviceId.value = device.id; configuringDeviceId.value = device.id;
configDialogVisible.value = true; configDialogVisible.value = true;
mapEditorMode.value = "normal"; mapEditorMode.value = "normal";
resetMapView();
if (!config.plans.some((plan) => plan.id === config.activePlanId)) { if (!config.plans.some((plan) => plan.id === config.activePlanId)) {
config.activePlanId = config.plans[0]?.id ?? ""; config.activePlanId = config.plans[0]?.id ?? "";
@@ -1346,6 +1340,7 @@ async function openConfigDialog(device: DeviceRecord) {
resetPlanForm(); resetPlanForm();
nextTick(() => { nextTick(() => {
attachMapResizeObserver(); attachMapResizeObserver();
resetMapView();
scheduleMapCanvasRender(); scheduleMapCanvasRender();
}); });
} }
@@ -1518,27 +1513,80 @@ function setMapZoom(value: number) {
scheduleMapCanvasRender(); scheduleMapCanvasRender();
} }
// 按固定步长放大地图 // 按固定倍率放大地图,倍率越高放大步进越大
function zoomInMap() { function zoomInMap() {
setMapZoom(mapZoom.value + mapZoomStep); setMapZoom(mapZoom.value * 1.1);
} }
// 按固定步长缩小地图 // 按固定倍率缩小地图
function zoomOutMap() { function zoomOutMap() {
setMapZoom(mapZoom.value - mapZoomStep); setMapZoom(mapZoom.value / 1.1);
} }
// 将地图缩放比例和平移位置恢复为默认状态 // 将地图缩放比例和平移位置恢复为默认状态:宽高比自适应缩放并聚焦原点
function resetMapView() { function resetMapView() {
mapPan.x = 0; mapPan.x = 0;
mapPan.y = 0; mapPan.y = 0;
setMapZoom(1); setMapZoom(computeDefaultMapZoom());
focusMapOnOrigin();
} }
// 处理鼠标滚轮缩放地图 // 处理鼠标滚轮缩放地图
function handleMapWheel(event: WheelEvent) { function handleMapWheel(event: WheelEvent) {
const zoomDirection = event.deltaY > 0 ? -1 : 1; const zoomFactor = event.deltaY > 0 ? 1 / 1.1 : 1.1;
setMapZoom(mapZoom.value + zoomDirection * mapZoomStep); setMapZoom(mapZoom.value * zoomFactor);
}
// 根据地图宽高比计算默认缩放:常规比例地图保持完整显示,宽扁/高瘦地图放大让短边占满画布
function computeDefaultMapZoom() {
const rect = getMapStageRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
return 1;
}
const imageRatio = rosMapImage.naturalWidth && rosMapImage.naturalHeight
? rosMapImage.naturalWidth / rosMapImage.naturalHeight
: 178 / 50;
const padding = 30;
const maxWidth = rect.width - padding * 2;
const maxHeight = rect.height - padding * 2;
if (maxWidth <= 0 || maxHeight <= 0) {
return 1;
}
// 与 getRosMapFrame 的 fit 逻辑保持一致:完整显示时短边占比过小才放大到铺满画布
const fitHeight = maxWidth / imageRatio;
const fitWidth = maxHeight * imageRatio;
const shortSideRatio = Math.min(fitWidth / maxWidth, fitHeight / maxHeight);
if (shortSideRatio >= 0.6) {
return 1;
}
const defaultZoom = fitHeight <= maxHeight
? maxHeight / fitHeight
: maxWidth / fitWidth;
return Number(defaultZoom.toFixed(2));
}
// 将地图视野平移到原点位置,使原点位于画布中心
function focusMapOnOrigin() {
const rect = getMapStageRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
return;
}
const originRatioX = (0 - rosMapMeta.origin[0]) / rosMapMeta.resolution / rosMapMeta.width;
const originRatioY = 1 - (0 - rosMapMeta.origin[1]) / rosMapMeta.resolution / rosMapMeta.height;
const frame = getRosMapFrame(rect);
// 原点位于画布中心时,平移量等于地图尺寸的一半减去原点偏移
mapPan.x = frame.width * (0.5 - originRatioX);
mapPan.y = frame.height * (0.5 - originRatioY);
} }
// 根据 ROS 地图图片比例计算画布中的实际地图显示区域 // 根据 ROS 地图图片比例计算画布中的实际地图显示区域
@@ -1577,28 +1625,6 @@ function percentToStagePixels(x: number, y: number, rect: DOMRect) {
}; };
} }
function estimatePointLabelWidth(name: string) {
return Math.max(72, name.length * 12 + 18);
}
function getPointLabelFrame(point: PatrolPoint, rect: DOMRect) {
const pixelPoint = percentToStagePixels(point.x, point.y, rect);
const labelWidth = estimatePointLabelWidth(point.name);
const labelHeight = 24;
let left = pixelPoint.x + 18;
if (left + labelWidth > rect.width - 10) {
left = pixelPoint.x - labelWidth - 18;
}
return {
left,
top: pixelPoint.y - labelHeight / 2,
width: labelWidth,
height: labelHeight,
};
}
function findHitPoint(event: MouseEvent) { function findHitPoint(event: MouseEvent) {
const rect = getMapStageRect(); const rect = getMapStageRect();
if (!rect) { if (!rect) {
@@ -1617,45 +1643,11 @@ function findHitPoint(event: MouseEvent) {
if (Math.hypot(deltaX, deltaY) <= 18) { if (Math.hypot(deltaX, deltaY) <= 18) {
return point; return point;
} }
const labelFrame = getPointLabelFrame(point, rect);
if (
pointerX >= labelFrame.left &&
pointerX <= labelFrame.left + labelFrame.width &&
pointerY >= labelFrame.top &&
pointerY <= labelFrame.top + labelFrame.height
) {
return point;
}
} }
return null; return null;
} }
function drawRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
const clampedRadius = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
ctx.moveTo(x + clampedRadius, y);
ctx.lineTo(x + width - clampedRadius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + clampedRadius);
ctx.lineTo(x + width, y + height - clampedRadius);
ctx.quadraticCurveTo(x + width, y + height, x + width - clampedRadius, y + height);
ctx.lineTo(x + clampedRadius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - clampedRadius);
ctx.lineTo(x, y + clampedRadius);
ctx.quadraticCurveTo(x, y, x + clampedRadius, y);
ctx.closePath();
}
function renderMapCanvas() { function renderMapCanvas() {
const canvas = mapCanvasRef.value; const canvas = mapCanvasRef.value;
const rect = getMapStageRect(); const rect = getMapStageRect();
@@ -1770,17 +1762,6 @@ function renderMapCanvas() {
context.beginPath(); context.beginPath();
context.arc(originPoint.x, originPoint.y, 4, 0, Math.PI * 2); context.arc(originPoint.x, originPoint.y, 4, 0, Math.PI * 2);
context.fill(); context.fill();
drawRoundRect(context, originPoint.x + 12, originPoint.y - 16, 126, 32, 8);
context.fillStyle = "rgba(72, 42, 13, 0.92)";
context.strokeStyle = "rgba(255, 196, 118, 0.72)";
context.lineWidth = 1;
context.fill();
context.stroke();
context.fillStyle = "#fff5df";
context.font = '600 12px "Microsoft YaHei", sans-serif';
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText("原点 (0.00, 0.00)", originPoint.x + 22, originPoint.y);
context.restore(); context.restore();
if (activeRoundPoints.value.length > 1) { if (activeRoundPoints.value.length > 1) {
@@ -1813,44 +1794,28 @@ function renderMapCanvas() {
const palette = mapPointPalette[point.type]; const palette = mapPointPalette[point.type];
const selected = currentConfig.value?.selectedPointIds.includes(point.id) ?? false; const selected = currentConfig.value?.selectedPointIds.includes(point.id) ?? false;
const highlightPalette = selected ? selectedPointHighlight : palette; const highlightPalette = selected ? selectedPointHighlight : palette;
const labelFrame = getPointLabelFrame(point, rect);
context.save(); context.save();
context.fillStyle = selected ? highlightPalette.glow : "rgba(255, 255, 255, 0.02)"; context.fillStyle = selected ? highlightPalette.glow : "rgba(255, 255, 255, 0.02)";
context.beginPath(); context.beginPath();
context.arc(pixelPoint.x, pixelPoint.y, selected ? 18 : 14, 0, Math.PI * 2); context.arc(pixelPoint.x, pixelPoint.y, selected ? 14 : 10, 0, Math.PI * 2);
context.fill(); context.fill();
context.fillStyle = "rgba(5, 18, 38, 0.94)"; context.fillStyle = "rgba(5, 18, 38, 0.94)";
context.strokeStyle = highlightPalette.stroke; context.strokeStyle = highlightPalette.stroke;
context.lineWidth = selected ? 3 : 2; context.lineWidth = selected ? 3 : 2;
context.beginPath(); context.beginPath();
context.arc(pixelPoint.x, pixelPoint.y, 11, 0, Math.PI * 2); context.arc(pixelPoint.x, pixelPoint.y, 8, 0, Math.PI * 2);
context.fill(); context.fill();
context.shadowColor = highlightPalette.glow; context.shadowColor = highlightPalette.glow;
context.shadowBlur = selected ? 18 : 12; context.shadowBlur = selected ? 18 : 12;
context.stroke(); context.stroke();
drawRoundRect(context, labelFrame.left, labelFrame.top, labelFrame.width, labelFrame.height, 8); // 所有点位统一圆圈大小,仅以描边颜色区分类型,内部标注序号
context.fillStyle = selected
? highlightPalette.labelBackground
: "rgba(4, 19, 39, 0.82)";
context.strokeStyle = selected
? highlightPalette.labelBorder
: "rgba(91, 208, 255, 0.18)";
context.lineWidth = 1;
context.shadowBlur = 0;
context.fill();
context.stroke();
context.fillStyle = "#eefcff"; context.fillStyle = "#eefcff";
context.font = '700 11px "Microsoft YaHei", sans-serif'; context.font = '700 10px "Microsoft YaHei", sans-serif';
context.textAlign = "center"; context.textAlign = "center";
context.fillText(String(index + 1), pixelPoint.x, pixelPoint.y + 0.5); context.fillText(String(index + 1), pixelPoint.x, pixelPoint.y + 0.5);
context.font = '500 12px "Microsoft YaHei", sans-serif';
context.textAlign = "left";
context.fillText(point.name, labelFrame.left + 8, labelFrame.top + labelFrame.height / 2 + 0.5);
context.restore(); context.restore();
}); });
@@ -2462,14 +2427,8 @@ async function saveConfigDialog() {
buildInspectionTaskPayload(currentConfiguredDevice.value, currentConfig.value), buildInspectionTaskPayload(currentConfiguredDevice.value, currentConfig.value),
); );
saveProgressTimer = window.setInterval(() => { // 保存接口真实调用完成后,进度条立即走完,不再模拟递增动画
const step = saveProgressValue.value < 72 ? 12 : saveProgressValue.value < 90 ? 6 : 3;
saveProgressValue.value = Math.min(100, saveProgressValue.value + step);
if (saveProgressValue.value >= 100) {
finishSaveProgress(); finishSaveProgress();
}
}, 180);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
stopSaveProgressTimers(); stopSaveProgressTimers();
@@ -3800,7 +3759,7 @@ watch(
rgba(24, 102, 180, 0.92) 0%, rgba(24, 102, 180, 0.92) 0%,
rgba(35, 158, 215, 0.9) 100%); rgba(35, 158, 215, 0.9) 100%);
box-shadow: 0 0 18px rgba(52, 177, 255, 0.26); box-shadow: 0 0 18px rgba(52, 177, 255, 0.26);
transition: width 0.18s ease; transition: width 0.45s ease;
} }
.save-progress-value { .save-progress-value {

View File

@@ -668,7 +668,7 @@ const realRoutePath = computed(() => routeMapPoints.value
.join(" ")); .join(" "));
const realCheckPoints = computed(() => routeMapPoints.value.map((point, index) => ({ const realCheckPoints = computed(() => routeMapPoints.value.map((point, index) => ({
...point, ...point,
label: index === 0 ? "起点" : index === routeMapPoints.value.length - 1 ? "终点" : "途经点", label: index === 0 ? "起点" : index === routeMapPoints.value.length - 1 ? "终点" : "",
}))); })));
const inspectionRunning = computed(() => currentInspection.value?.status === 0); const inspectionRunning = computed(() => currentInspection.value?.status === 0);
const inspectionStatusText = computed(() => inspectionRunning.value ? "巡检中" : "空闲"); const inspectionStatusText = computed(() => inspectionRunning.value ? "巡检中" : "空闲");
@@ -1270,9 +1270,10 @@ function switchDriveMode(mode: DriveMode) {
activeDriveMode.value = mode; activeDriveMode.value = mode;
} }
// 调整真实 ROS 地图整体缩放,不影响底部状态浮层 // 调整真实 ROS 地图整体缩放,不影响底部状态浮层,倍率越高放大步进越大
function changeMapScale(delta: number) { function changeMapScale(delta: number) {
mapScale.value = Math.min(3, Math.max(0.55, Number((mapScale.value + delta).toFixed(2)))); const factor = delta > 0 ? 1.1 : 1 / 1.1;
mapScale.value = Math.min(15, Math.max(0.55, Number((mapScale.value * factor).toFixed(2))));
} }
function resetMapScale() { function resetMapScale() {
@@ -1280,10 +1281,34 @@ function resetMapScale() {
mapOffset.value = { x: 0, y: 0 }; mapOffset.value = { x: 0, y: 0 };
} }
// 鼠标滚轮缩放地图内容,按住拖拽平移地图视野 // 鼠标滚轮以光标为缩放中心缩放地图内容,按住拖拽平移地图视野
function handleMapWheel(event: WheelEvent) { function handleMapWheel(event: WheelEvent) {
event.preventDefault(); event.preventDefault();
changeMapScale(event.deltaY > 0 ? -0.05 : 0.05);
if (!rosMap.value || !navMapSvgRef.value) {
return;
}
const factor = event.deltaY > 0 ? 1 / 1.1 : 1.1;
const nextScale = Math.min(15, Math.max(0.55, Number((mapScale.value * factor).toFixed(2))));
if (nextScale === mapScale.value) {
return;
}
// 缩放前后保持光标下的地图点位置不动,需要同步平移视野
const rect = navMapSvgRef.value.getBoundingClientRect();
const pointerX = (event.clientX - rect.left) * (rosMap.value.width / rect.width);
const pointerY = (event.clientY - rect.top) * (rosMap.value.height / rect.height);
const centerX = rosMap.value.width / 2;
const centerY = rosMap.value.height / 2;
const keepRatio = 1 - nextScale / mapScale.value;
mapOffset.value = {
x: mapOffset.value.x + (pointerX - centerX) * keepRatio,
y: mapOffset.value.y + (pointerY - centerY) * keepRatio,
};
mapScale.value = nextScale;
} }
function startMapDrag(event: PointerEvent) { function startMapDrag(event: PointerEvent) {
@@ -1539,10 +1564,10 @@ function getStatusClass(status: DeviceItem["status"]) {
<path v-if="realRoutePath" class="route-glow" :d="realRoutePath" /> <path v-if="realRoutePath" class="route-glow" :d="realRoutePath" />
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" /> <path v-if="realRoutePath" class="route-line" :d="realRoutePath" />
<g class="checkpoint-layer"> <g class="checkpoint-layer">
<g v-for="point in realCheckPoints" :key="`${point.label}-${point.x}-${point.y}`" class="checkpoint" <g v-for="(point, index) in realCheckPoints" :key="index" class="checkpoint"
:transform="`translate(${point.x}, ${point.y})`"> :transform="`translate(${point.x}, ${point.y})`">
<circle r="3"></circle> <circle r="3"></circle>
<text x="7" y="2.5">{{ point.label }}</text> <text v-if="point.label" x="7" y="2.5">{{ point.label }}</text>
</g> </g>
</g> </g>
<g v-if="robotMapPoint" class="robot-layer real-robot-layer" <g v-if="robotMapPoint" class="robot-layer real-robot-layer"