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

一个真实的痛点
我们系统的用户经常需要从其他软件导入拓扑数据。问题来了:
用户提供的坐标不是标准经纬度。
可能是他们软件内部的坐标系,可能是局部坐标,甚至可能是随便画的相对坐标。导入到地图上后,整个拓扑可能:
- 跑到了太平洋里
- 缩放比例完全不对
- 方向是反的
传统解决方案:让用户自己换算坐标。但用户哪懂什么墨卡托投影、坐标转换?
我们的方案:让用户直接在地图上"拖"到正确位置。
具体操作流程:
- 导入拓扑数据(位置可能完全错误)
- 点击"批量选择",框选整个拓扑
- 拖动、旋转、缩放,把拓扑"搬"到地图上的正确位置
- 点击"批量提交"保存
这就是 BlockWriteL7 组件要解决的核心问题。
背景
智慧供热系统中,拓扑编辑是常见需求。用户经常需要:
- 导入数据后校正位置:将错误位置的拓扑整体移动到正确位置
- 调整拓扑方向:旋转整个区域的管网
- 缩放比例校正:调整拓扑的缩放比例
- 批量删除:删除某个区域的管网
单个操作效率太低,我们需要批量操作能力。
之前我开源了一个 TranslateBox 组件,实现了元素的移动、旋转、缩放。现在需要把它应用到 L7 地图上,实现拓扑数据的批量编辑。
整体架构
bash
BlockWriteL7/
├── index.vue # 主组件
└── js/
├── index.ts # 坐标转换、盒子操作
├── data.ts # 数据类型定义
└── blockHandle.ts # 批量操作逻辑
核心思路:
- 用户框选区域 → 获取区域内的设备和管道
- 在选中区域上叠加 TranslateBox
- 用户操作 TranslateBox(移动/旋转/缩放)
- 将变换应用到所有选中的设备和管道
核心实现
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. 批量旋转
旋转比移动复杂,需要:
- 确定旋转中心(选中区域的中心)
- 计算每个设备到中心的距离和角度
- 应用旋转角度,计算新位置
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. 批量缩放
缩放最复杂,需要处理:
- 先旋转回正常位置
- 分别计算宽度和高度的缩放比例
- 应用缩放
- 再旋转回原位
- 平移到目标位置
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 组件适配到地图场景,解决用户导入数据后坐标不匹配的痛点:
- 坐标转换:墨卡托坐标 ↔ 屏幕坐标
- 变换应用:将盒子变换应用到批量数据
- 渲染同步:操作过程中保持视觉一致性
核心价值:
用户不需要懂坐标转换、墨卡托投影。只需要在地图上"拖一拖、转一转、缩一缩",就能把错误位置的拓扑放到正确位置。
优点:
- 复用成熟的 TranslateBox 组件
- 支持移动、旋转、缩放、翻转等完整变换
- 操作直观,所见即所得
- 降低用户使用门槛,无需理解坐标系统
缺点:
- 变换计算有精度损失
- 大量数据时需要性能优化
- 坐标转换增加了复杂度
相关链接: