在 L7 地图上实现拓扑编辑:SVG 叠加层与坐标同步的实战
如何在 WebGL 地图上叠加 SVG 编辑层?如何实现地图移动时图形跟随?这篇文章分享我们项目中 L7 + draw2d 的集成方案。
效果图

背景
智慧供热系统不仅要展示管网,还要支持编辑------拖拽设备、连接管道、修改属性。
L7 是基于 WebGL 的地图引擎,擅长大规模数据渲染。但 WebGL 不适合交互编辑------我们需要的是 SVG 级别的精确控制。
解决方案:在 L7 地图上叠加一个 SVG 层,使用 draw2d.js 处理编辑逻辑。
架构设计
bash
draw/
├── l7AndDraw2d.ts # SVG 叠加层创建 + L7 事件同步
├── createDrawCanvas.ts # 画布初始化
├── draw2dManager.ts # 编辑管理器
├── bindEventOfEquipment.ts # 设备事件绑定
├── coordHelper.ts # 坐标转换
├── loadDraw2d.ts # draw2d.js 延迟加载
└── topologyDrawL7/ # 拓扑数据渲染
├── index.ts
├── device.ts
└── line.ts
核心思路:
- L7 负责地图渲染(底图 + 管网展示)
- SVG 叠加层负责编辑交互(拖拽、连接)
- 两层通过坐标转换保持同步
核心实现
1. 创建 SVG 叠加层
在 L7 地图容器上叠加一个 div,draw2d.js 在这个 div 上渲染 SVG:
typescript
export default function createL7DrawLayer(scene: Scene): Promise<{ canvas: any; overlay: HTMLDivElement }> {
return new Promise((resolve, reject) => {
const container = scene.getContainer();
// 创建叠加层
const overlayEl = document.createElement('div');
overlayEl.id = 'l7-draw2d-overlay';
overlayEl.style.cssText = `
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 10;
pointer-events: none; // 默认穿透,让地图响应事件
`;
container.appendChild(overlayEl);
// 初始化 draw2d Canvas
const canvas = new window.draw2d.Canvas(overlayEl.id);
// 监听 L7 地图事件
scene.on('zoomstart', onZoomStart);
scene.on('zoomend', onZoomEnd);
scene.on('mapmove', onMapMove);
scene.on('moveend', onMoveEnd);
resolve({ canvas, overlay: overlayEl });
});
}
关键点 :pointer-events: none 让空白区域穿透到地图,只有 SVG 图形响应鼠标。
2. 地图移动时的坐标同步
用户拖动地图时,SVG 元素需要跟随移动。有两种策略:
策略一:实时偏移(移动中)
移动过程中,通过 CSS transform 偏移整个 SVG 层,避免频繁重绘:
typescript
function onMapMove() {
if (!isMoving) {
// 记录起始坐标
moveStartLngLat = [scene.getCenter().lng, scene.getCenter().lat];
moveStartScreen = scene.lngLatToContainer(moveStartLngLat);
isMoving = true;
return;
}
// 计算偏移量
const currentScreen = scene.lngLatToContainer(moveStartLngLat);
const deltaX = currentScreen.x - moveStartScreen.x;
const deltaY = currentScreen.y - moveStartScreen.y;
// CSS transform 偏移
overlayEl.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
策略二:精确重绘(移动结束)
移动结束后,重新计算每个元素的精确屏幕坐标:
typescript
function onMoveEnd() {
isMoving = false;
updatePositions();
}
function updatePositions() {
const figures = canvas.getFigures().data;
const lines = canvas.getLines().data;
// 重置 transform
overlayEl.style.transform = '';
// 更新每个元素的屏幕坐标
[...figures, ...lines].forEach((item) => {
if (item.cssClass !== 'draw2d_Connection') {
// 设备:根据地理坐标重新定位
const coord = item.userData?.writer?.coord; // Web Mercator 坐标
if (coord) {
const screen = webMercatorToScreen(scene, coord);
item.setPosition(screen.x - OFFSET, screen.y - OFFSET);
}
} else {
// 管道:根据坐标数组重新定位
const coords = item.userData?.writer?.coord;
if (coords) {
const vertices = coords.map(c => {
const screen = webMercatorToScreen(scene, c);
return { x: screen.x, y: screen.y };
});
item.setVertices(vertices);
}
}
});
}
3. 坐标转换
L7 使用经纬度,draw2d 使用屏幕像素。需要双向转换:
typescript
import { webMercatorToCoord, coordToWebMercator } from '@/tools/tool/tool';
/** Web Mercator → 屏幕坐标 */
export function webMercatorToScreen(scene: Scene, coord: [number, number]) {
const [lng, lat] = webMercatorToCoord(coord); // 转经纬度
return scene.lngLatToContainer([lng, lat]); // 转屏幕坐标
}
/** 屏幕坐标 → Web Mercator */
export function screenToWebMercator(scene: Scene, x: number, y: number) {
const lngLat = scene.containerToLngLat([x, y]); // 屏幕转经纬度
return coordToWebMercator([lngLat.lng, lngLat.lat]); // 转 Web Mercator
}
为什么用 Web Mercator 而不是经纬度?
- 项目中拓扑数据统一使用 Web Mercator 坐标系
- 避免经纬度计算时的精度问题
4. 拖拽时禁用地图交互
用户拖拽设备时,不能让地图跟着移动:
typescript
item.installEditPolicy(
new window.draw2d.policy.figure.DragDropEditPolicy({
onDragStart: function () {
// 禁用地图交互
scene.setMapStatus({ dragEnable: false, zoomEnable: false });
// SVG 层全区域接收鼠标,防止快速拖出导致事件丢失
overlayEl.style.pointerEvents = 'auto';
},
onDragEnd: function (canvas, figure, x, y) {
// 恢复地图交互
scene.setMapStatus({ dragEnable: true, zoomEnable: true });
overlayEl.style.pointerEvents = 'none';
// 更新设备地理坐标
const device = findDevice(figure.id);
const newCoord = screenToWebMercator(scene, x, y);
device.latLng = newCoord;
// 更新画布元素存储的坐标(供后续移动同步使用)
figure.userData.writer.coord = newCoord;
},
})
);
5. 缩放时的优化
缩放时如果实时更新位置,会非常卡顿。优化策略:
typescript
function onZoomStart() {
isZooming = true;
hideDrawLayer(); // 隐藏 SVG 层
}
function onZoomEnd() {
// 防抖:用户停止缩放 200ms 后才恢复
setTimeout(() => {
isZooming = false;
updatePositions(); // 重新计算位置
showDrawLayer(); // 显示 SVG 层
}, 200);
}
6. draw2d.js 的延迟加载
draw2d.js 使用 AMD 模块系统,可能与项目中其他 AMD loader(如 dojo)冲突:
typescript
export function loadDraw2d(): Promise<void> {
if (window.draw2d) return Promise.resolve();
return new Promise((resolve, reject) => {
// 临时屏蔽全局 AMD
const _define = window.define;
const _require = window.require;
delete window.define;
delete window.require;
const script = document.createElement('script');
script.src = '/static/js/draw2d.js';
script.onload = () => {
// 恢复 AMD 环境
if (_define) window.define = _define;
if (_require) window.require = _require;
resolve();
};
script.onerror = reject;
document.body.append(script);
});
}
完整流程
css
用户进入编辑模式
↓
加载 draw2d.js(延迟加载)
↓
创建 SVG 叠加层
↓
初始化 draw2d Canvas
↓
将拓扑数据转为 draw2d 格式渲染
↓
绑定设备事件(拖拽、连接、删除)
↓
监听 L7 地图事件(移动、缩放)
↓
坐标同步更新
使用示例
typescript
import CreateDrawCanvas, { getDraw2dManager } from './draw/createDrawCanvas';
import { DrawDataL7 } from './draw/topologyDrawL7';
// 1. 创建编辑画布
const [canvas, overlay] = await CreateDrawCanvas(scene);
// 2. 渲染拓扑数据到画布
DrawDataL7(scene, writeArea, canvas);
// 3. 获取管理器进行操作
const manager = getDraw2dManager();
manager.rotateEquipmentFromOutside('device-001', 45); // 旋转设备
manager.removeEquipmentFromOutside({ type: 'device', tpId: 'device-001' }); // 删除设备
踩过的坑
坑一:pointer-events 导致地图无法响应
SVG 叠加层默认会拦截所有鼠标事件。解决:设置 pointer-events: none,只让 SVG 图形响应。
坑二:快速拖拽导致事件丢失
用户快速拖拽时,鼠标可能移出 SVG 图形范围。解决:拖拽开始时将整个叠加层设为 pointer-events: auto。
坑三:缩放时 SVG 闪烁
缩放过程中频繁更新位置导致闪烁。解决:缩放时隐藏 SVG 层,结束后再显示。
坑四:AMD loader 冲突
draw2d.js 的 AMD loader 与 dojo 冲突。解决:加载前临时屏蔽全局 define/require。
总结
这套方案的核心是分层架构:
- L7 负责地图渲染(WebGL)
- draw2d 负责编辑交互(SVG)
- 通过坐标转换保持同步
优点:
- 充分利用 WebGL 的渲染性能
- SVG 编辑交互更精确、更灵活
- 两层解耦,可独立优化
缺点:
- 需要维护两层同步
- 坐标转换有性能开销
- AMD loader 兼容性问题
相关链接: