在地图上实现管网拓扑批量移动、旋转与缩放(参考图片的实现方式)

在 L7 地图上实现拓扑批量编辑:移动、旋转、缩放

用户导入的拓扑数据坐标往往是他们软件内部的坐标,不是标准经纬度。导入到地图上后发现位置完全不对,这时就需要可视化的批量编辑功能,把拓扑"搬"到正确位置。

效果图

一个真实的痛点

我们系统的用户经常需要从其他软件导入拓扑数据。问题来了:

用户提供的坐标不是标准经纬度。

可能是他们软件内部的坐标系,可能是局部坐标,甚至可能是随便画的相对坐标。导入到地图上后,整个拓扑可能:

  • 跑到了太平洋里
  • 缩放比例完全不对
  • 方向是反的

传统解决方案:让用户自己换算坐标。但用户哪懂什么墨卡托投影、坐标转换?

我们的方案:让用户直接在地图上"拖"到正确位置。

具体操作流程:

  1. 导入拓扑数据(位置可能完全错误)
  2. 点击"批量选择",框选整个拓扑
  3. 拖动、旋转、缩放,把拓扑"搬"到地图上的正确位置
  4. 点击"批量提交"保存

这就是 BlockWriteL7 组件要解决的核心问题。

背景

智慧供热系统中,拓扑编辑是常见需求。用户经常需要:

  • 导入数据后校正位置:将错误位置的拓扑整体移动到正确位置
  • 调整拓扑方向:旋转整个区域的管网
  • 缩放比例校正:调整拓扑的缩放比例
  • 批量删除:删除某个区域的管网

单个操作效率太低,我们需要批量操作能力。

之前我开源了一个 TranslateBox 组件,实现了元素的移动、旋转、缩放。现在需要把它应用到 L7 地图上,实现拓扑数据的批量编辑。

整体架构

bash 复制代码
BlockWriteL7/
├── index.vue           # 主组件
└── js/
    ├── index.ts        # 坐标转换、盒子操作
    ├── data.ts         # 数据类型定义
    └── blockHandle.ts  # 批量操作逻辑

核心思路

  1. 用户框选区域 → 获取区域内的设备和管道
  2. 在选中区域上叠加 TranslateBox
  3. 用户操作 TranslateBox(移动/旋转/缩放)
  4. 将变换应用到所有选中的设备和管道

核心实现

1. 批量选择

用户点击"批量选择"后,在地图上框选区域:

typescript 复制代码
export async function blockChooseHandle(param: {
  scene: Scene | null,
  renderDataHandle: () => void,
  inBlockChoose: Ref<boolean>,
}) {
  param.inBlockChoose.value = true;

  // 获取框选区域
  const writeArea = await WriteArea(param.scene);
  if (writeArea) {
    // 标记选中区域内的设备
    BlockEditObj.getChooseEquipment(writeArea);
    param.renderDataHandle();
  }
}

选中状态标记

typescript 复制代码
// 设备标记
device.userData.blockChoose = { status: 1 }

// 管道标记(需要考虑部分选中)
line.userData.blockChoose = {
  status: 1,        // 1=完全选中,0=部分选中
  ports: [1, 2]     // 选中的端口
}

2. 初始化变换盒子

选中数据后,计算选中区域的边界,初始化 TranslateBox:

typescript 复制代码
export function initData(pictureWeb: PictureWeb, translateValue: TranslateValue) {
  let [minX, maxX, minY, maxY] = [Infinity, -Infinity, Infinity, -Infinity];
  
  // 遍历所有选中的设备,计算边界
  for (const item of publicTopology.getTopologyData().devices) {
    if (item.userData.blockChoose?.status) {
      if (item.latLng[0] < minX) minX = item.latLng[0];
      if (item.latLng[0] > maxX) maxX = item.latLng[0];
      if (item.latLng[1] < minY) minY = item.latLng[1];
      if (item.latLng[1] > maxY) maxY = item.latLng[1];
    }
  }

  // 存储墨卡托坐标边界
  pictureWeb.leftTop = [minX, maxY];
  pictureWeb.rightBottom = [maxX, minY];
}

坐标转换:将墨卡托坐标转为屏幕坐标,用于渲染 TranslateBox:

typescript 复制代码
export function getTranslateValue(pictureWeb: PictureWeb, scene: Scene) {
  const leftTopPoint = webMercatorToScreen(scene, pictureWeb.leftTop);
  const rightBottomPoint = webMercatorToScreen(scene, pictureWeb.rightBottom);

  return {
    left: leftTopPoint.x,
    top: leftTopPoint.y,
    width: rightBottomPoint.x - leftTopPoint.x,
    height: rightBottomPoint.y - leftTopPoint.y,
  };
}

3. 批量移动

用户拖动 TranslateBox 时,计算位移量,应用到所有选中设备:

typescript 复制代码
export function translateEndOfMove(
  scene: Scene,
  res: EmitMoveObj,
  pictureWeb: PictureWeb
) {
  // 计算新位置的墨卡托坐标
  const newLeftTop = screenToWebMercator(scene, res.left, res.top);

  // 计算位移量
  const moveX = newLeftTop[0] - pictureWeb.leftTop[0];
  const moveY = newLeftTop[1] - pictureWeb.leftTop[1];

  // 应用到所有选中的设备和管道
  BlockEditObj.moveEquipmentHandle(moveX, moveY);

  // 更新边界
  pictureWeb.leftTop[0] += moveX;
  pictureWeb.leftTop[1] += moveY;
  pictureWeb.rightBottom[0] += moveX;
  pictureWeb.rightBottom[1] += moveY;
}

BlockEditObj.moveEquipmentHandle 的实现

typescript 复制代码
moveEquipmentHandle(moveX: number, moveY: number) {
  // 移动选中的设备
  for (const item of publicTopology.getTopologyData().devices) {
    if (item.userData.blockChoose?.status) {
      item.latLng[0] += moveX;
      item.latLng[1] += moveY;
      item.userData.status.move = true;
    }
  }

  // 移动选中的管道
  for (const item of publicTopology.getTopologyData().lines) {
    if (item.userData.blockChoose?.status) {
      for (let i = 0; i < item.latLng.length; i++) {
        item.latLng[i][0] += moveX;
        item.latLng[i][1] += moveY;
      }
      item.userData.status.move = true;
    }
  }
}

4. 批量旋转

旋转比移动复杂,需要:

  1. 确定旋转中心(选中区域的中心)
  2. 计算每个设备到中心的距离和角度
  3. 应用旋转角度,计算新位置
typescript 复制代码
export function translateEndOfRotate(
  res: EmitMoveObj,
  pictureWeb: PictureWeb,
  translateValue: TranslateValue
) {
  // 计算旋转角度
  const rotateValue = res.rotate - translateValue.value.rotate;

  // 旋转中心
  const center: [number, number] = [
    (pictureWeb.rightBottom[0] + pictureWeb.leftTop[0]) / 2,
    (pictureWeb.rightBottom[1] + pictureWeb.leftTop[1]) / 2
  ];

  // 应用旋转
  BlockEditObj.rotateEquipmentHandel(rotateValue, center);

  // 更新累计旋转角度
  translateValue.value.rotate += rotateValue;
}

旋转变换公式

typescript 复制代码
rotateEquipmentHandel(rotateValue: number, center: [number, number]) {
  const rotateRad = rotateValue * Math.PI / 180;  // 角度转弧度

  for (const item of devices) {
    if (item.userData.blockChoose?.status) {
      // 设备位置旋转
      const [newX, newY] = rotatePoint(item.latLng, center, rotateRad);
      item.latLng = [newX, newY];

      // 设备自身角度也要旋转
      item.rotationAngle = (item.rotationAngle || 0) + rotateValue;
    }
  }
}

function rotatePoint(
  point: [number, number],
  center: [number, number],
  angle: number
): [number, number] {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  const dx = point[0] - center[0];
  const dy = point[1] - center[1];

  return [
    center[0] + dx * cos - dy * sin,
    center[1] + dx * sin + dy * cos
  ];
}

5. 批量缩放

缩放最复杂,需要处理:

  1. 先旋转回正常位置
  2. 分别计算宽度和高度的缩放比例
  3. 应用缩放
  4. 再旋转回原位
  5. 平移到目标位置
typescript 复制代码
export function translateEndOfSpread(
  scene: Scene,
  res: EmitMoveObj,
  pictureWeb: PictureWeb,
  translateValue: TranslateValue
) {
  const center = [
    (pictureWeb.rightBottom[0] + pictureWeb.leftTop[0]) / 2,
    (pictureWeb.rightBottom[1] + pictureWeb.leftTop[1]) / 2
  ];

  // 1. 先旋转回正常位置
  BlockEditObj.rotateEquipmentHandel(-translateValue.value.rotate, center);

  // 2. 计算缩放比例
  let widthScaleValue = res.width / translateValue.value.width;
  let heightScaleValue = res.height / translateValue.value.height;

  // 处理翻转
  if (translateValue.value.isReverseX !== res.isReverseX) {
    widthScaleValue = -widthScaleValue;
  }
  if (translateValue.value.isReverseY !== res.isReverseY) {
    heightScaleValue = -heightScaleValue;
  }

  // 3. 应用缩放
  if (widthScaleValue !== 1) {
    BlockEditObj.scaleEquipmentHandel(widthScaleValue, 'row', pictureWeb.leftTop);
  }
  if (heightScaleValue !== 1) {
    BlockEditObj.scaleEquipmentHandel(heightScaleValue, 'column', pictureWeb.leftTop);
  }

  // 4. 再旋转回原位
  BlockEditObj.rotateEquipmentHandel(translateValue.value.rotate, centerNew);

  // 5. 平移到目标位置
  const newLeftTop = screenToWebMercator(scene, res.left, res.top);
  const moveX = newLeftTop[0] - pictureWeb.leftTop[0];
  const moveY = newLeftTop[1] - pictureWeb.leftTop[1];
  BlockEditObj.moveEquipmentHandle(moveX, moveY);
}

缩放变换

typescript 复制代码
scaleEquipmentHandel(scaleValue: number, direction: 'row' | 'column', origin: [number, number]) {
  for (const item of devices) {
    if (item.userData.blockChoose?.status) {
      if (direction === 'row') {
        // 水平缩放
        item.latLng[0] = origin[0] + (item.latLng[0] - origin[0]) * scaleValue;
      } else {
        // 垂直缩放
        item.latLng[1] = origin[1] + (item.latLng[1] - origin[1]) * scaleValue;
      }
    }
  }
}

6. 与 L7 地图同步

TranslateBox 操作过程中,需要让 L7 的渲染层跟随移动:

typescript 复制代码
function translateMovingHandle(res: EmitMoveObj) {
  if (res.type === 'move') {
    // L7 渲染层跟随偏移
    const l7SceneDom = document.querySelector('.l7-scene') as HTMLElement;
    if (l7SceneDom) {
      l7SceneDom.style.left = `${res.left - boxMoveStart[0]}px`;
      l7SceneDom.style.top = `${res.top - boxMoveStart[1]}px`;
    }
  }
}

function translateEndHandle(res: EmitMoveObj) {
  // 操作结束后,重置 L7 渲染层位置
  const l7SceneDom = document.querySelector('.l7-scene') as HTMLElement;
  if (l7SceneDom) {
    l7SceneDom.style.left = '0px';
    l7SceneDom.style.top = '0px';
  }

  // 应用变换到数据
  if (res.type === 'move') {
    translateEndOfMove(props.scene, res, pictureWeb);
  } else if (res.type === 'rotate') {
    translateEndOfRotate(res, pictureWeb, translateValue);
  } else {
    translateEndOfSpread(props.scene, res, pictureWeb, translateValue);
  }

  // 重新渲染
  props.renderDataHandle();
}

7. 地图事件监听

地图移动、缩放时,TranslateBox 需要重新计算位置:

typescript 复制代码
function registerSceneEvents(scene: Scene) {
  // 地图缩放结束 → 更新盒子位置
  scene.on('zoomend', () => {
    if (blockChooseHadGiveData.value) {
      forceUpdateHandle();
    }
  });

  // 地图移动结束 → 更新盒子位置
  scene.on('moveend', () => {
    if (blockChooseHadGiveData.value) {
      forceUpdateHandle();
    }
  });

  // 双击 → 将选中区域移动到点击位置
  scene.getContainer()?.addEventListener('dblclick', (e) => {
    if (!blockChooseHadGiveData.value) return;

    const lngLat = scene.containerToLngLat([e.offsetX, e.offsetY]);
    const newCenter = coordToWebMercator([lngLat.lng, lngLat.lat]);
    const oldCenter = [
      (pictureWeb.rightBottom[0] + pictureWeb.leftTop[0]) / 2,
      (pictureWeb.rightBottom[1] + pictureWeb.leftTop[1]) / 2
    ];

    const moveX = newCenter[0] - oldCenter[0];
    const moveY = newCenter[1] - oldCenter[1];

    BlockEditObj.moveEquipmentHandle(moveX, moveY);
    props.renderDataHandle();
  });
}

使用示例

vue 复制代码
<template>
  <BlockWriteL7
    v-model:hideAllNum="hideAllNum"
    v-model:disabledWrite="disabledWrite"
    :scene="scene"
    :render-data-handle="renderDataHandle"
    :on-write="onWrite"
  />
</template>

<script setup lang="ts">
import BlockWriteL7 from '@/components/blockWriteL7/index.vue'

const scene = ref<Scene | null>(null)
const onWrite = ref(false)

function renderDataHandle() {
  // 重新渲染拓扑数据
  l7MapRef.value?.referRender()
}
</script>

典型使用场景

场景一:导入数据后校正位置

arduino 复制代码
用户导入拓扑数据(坐标可能是内部坐标)
    ↓
数据渲染到地图上,位置完全错误(可能在太平洋里)
    ↓
用户点击"批量选择",框选整个拓扑
    ↓
拖动拓扑到正确的大致位置
    ↓
旋转调整方向
    ↓
缩放调整比例
    ↓
微调位置
    ↓
点击"批量提交"保存

场景二:区域调整

arduino 复制代码
用户点击"批量选择"
    ↓
在地图上框选区域
    ↓
标记区域内的设备和管道
    ↓
显示 TranslateBox
    ↓
用户操作(移动/旋转/缩放)
    ↓
实时更新 L7 渲染层位置
    ↓
操作结束,应用变换到数据
    ↓
重新渲染拓扑
    ↓
用户点击"批量提交"保存数据

关键技术点

1. 坐标系统

  • 拓扑数据使用 Web Mercator 坐标
  • TranslateBox 使用屏幕像素坐标
  • 需要双向转换

2. 变换顺序

缩放操作的正确顺序:旋转回正 → 缩放 → 旋转回原位 → 平移

3. 渲染同步

操作过程中,L7 渲染层需要跟随 TranslateBox 移动,避免视觉割裂。

4. 部分选中

管道可能只有一端被选中,需要特殊处理:

typescript 复制代码
line.userData.blockChoose = {
  status: 0,      // 部分选中
  ports: [1]      // 只有端口1在选中区域内
}

总结

这套方案的核心是将通用的 TranslateBox 组件适配到地图场景,解决用户导入数据后坐标不匹配的痛点:

  1. 坐标转换:墨卡托坐标 ↔ 屏幕坐标
  2. 变换应用:将盒子变换应用到批量数据
  3. 渲染同步:操作过程中保持视觉一致性

核心价值

用户不需要懂坐标转换、墨卡托投影。只需要在地图上"拖一拖、转一转、缩一缩",就能把错误位置的拓扑放到正确位置。

优点

  • 复用成熟的 TranslateBox 组件
  • 支持移动、旋转、缩放、翻转等完整变换
  • 操作直观,所见即所得
  • 降低用户使用门槛,无需理解坐标系统

缺点

  • 变换计算有精度损失
  • 大量数据时需要性能优化
  • 坐标转换增加了复杂度

相关链接

相关推荐
Strayer2 小时前
WebGL 地图上做精准编辑?这套分层方案搞定管网拖拽 / 连接
gis·webgl
谙弆悕博士9 小时前
R 语言学习笔记
笔记·学习·数据分析·r语言·数据可视化
盼兮1 天前
用AI编程从零搭建一个响应式数据看板
前端·人工智能·数据可视化
余丁,微生信1 天前
上下调基因可视化新视角:半圆图的直观之美
数据分析·数据可视化·论文插图·生信分析·科研绘图·科学科普·差异基因
山海鲸可视化1 天前
数字孪生项目案例 | 物流园区可视化
webgl·可视化·数据可视化·数据表格·搜索框
SZLSDH1 天前
从“端渲染”到“流渲染”的融合与平衡——数字孪生项目渲染架构的演进逻辑
ai·架构·数字孪生·数据可视化·智能体
山海鲸实战案例分享1 天前
【数字孪生实战案例】怎样设置数据筛选条件,精准控制电子地图飞线的呈现效果?~山海鲸可视化
数字孪生·数据可视化·零代码·数据筛选·实战案例·山海鲸可视化·gis电子地图
杨超越luckly1 天前
Python应用指南:百度热搜数据
python·百度·html·数据可视化
图扑软件2 天前
50ms 级实时数字孪生|汽车先进制造车间工艺流程
3d·数据采集·webgl·数字孪生·可视化·opc ua·汽车制造