最近开发中常遇到一些小场景电子地图的开发,比如一个园区、楼层的平面图,需要展示并在上面标注某设备位置、查看设备信息等。
这类场景不需要gis底图(栅格/矢量瓦片图),不使用经纬度坐标,这似乎不适合用leaflet这类gis库。初时我也并未想到要用它,但其实除了这两点外,其它许多功能用这类gis库都比较合适,比如:信息框不跟随缩放、鼠标标绘、多边形编辑等等。而且其它类型的图形库很少同时包含这几项功能。
leaflet比较轻量,也可以和其它绘图库搭配使用,一般都能满足项目需求。所以使用leaflet.js
多数时候更为合适。以下是一个简单的园区电子地图示例。
一、主要功能清单
以下是这个示例的主要功能部分。我们先看看功能组成再做分析和实现。
- 展示底图:支持使用一张图片作为地图的"底图"。
- 显示比例尺:显示比例尺,并且需要可编辑。
- 放置设备:要求在地图上放置设备,并可进行编辑(移动、旋转、缩放)
- 标绘区域:用户用鼠标绘制区域。
- 编辑区域:区域的形状要支持编辑修改。
- 展示设备/区域信息:点击设备/区域时展示他们的详细信息。
二、实现
以上第4, 5
两项可直接使用leaflet.pm
插件实现。 第6点使用leaflet的Popup
弹框功能展示即可(因为它们的状态信息不应该跟随缩放而变化大小)。
1, 2, 3
点要稍做些处理,以下是按照实现的顺序列出的,这3点功能也包含在其中,详细如下。
1、坐标系选用
leaflet
默认使用的是L.CRS.EPSG3857
也就是使用经纬度作为坐标,而我们只是一个园区的地图,比较小,使用普通的平面直角坐标系,保存设备、区域的点位即可。设置如下:
js
import L from 'leaflet';
const mapCase = L.map('容器元素id', {
/* L.CRS.Simple是将经度和纬度直接映射为 x和 y。可用于平坦表面的地图*/
crs: L.CRS.Simple,
/*使用L.CRS.Simple 后,将zoom在-2~2之间,默认0级为正常大小*/
zoom: 0,
minZoom: -2,
maxZoom: 2,
//leaflet默认每次缩放变更级别为1,
//而我们园区地图较小,所以缩放级别的变化调小更合适
zoomSnap: 0.25, // 滚轮每次缩放变更
zoomDelta: 0.25, // 控件缩放每次变更
});
2、展示底图
将用户上传的图片 (一张)设置一个固定宽,并按其宽高比例设置为园区底图。设置了底图覆盖范围从[0,0]
开始,让坐标系的原点与底图左下角重合。
js
function getImgSize(imgUrl) {
const img = new Image();
const IMG_WIDTH = 1600;
return new Promise((resolve, reject) => {
img.onload = () => {
const ratio = img.width / img.height;
const scopes = [IMG_WIDTH / ratio, IMG_WIDTH];
resolve({ width: scopes[1], height: scopes[0],zoom:0 });
};
img.onerror = () => reject(null);
img.src = imgUrl;
});
}
// 设置底图
const imgSrc = '图片地址或数据';
getImgSize(imgSrc).then(imgObj=>{
// 第2个参数为覆盖的范围。
L.imageOverlay(imgSrc, [[0, 0], [imgObj.height, imgObj.width]]).addTo(mapCase);
// 设置中心和缩放
mapCase.setView([imgObj.height / 2, imgObj.width / 2], 0);
})
3、使用可编辑的比例尺
因为我们的底图是用户上传,规格不一,所以比例尺需要支持可以编辑。
leaflet自带的比例尺控件是不支持修改其值的,所以这里用扩展的方式实现一个可编辑(值的设置)的比例尺。实现如下:
- 单位使用
px/m
(像素/米); - 固定比例尺控件长度为
100px
,方便用户编辑时用其在底图上做度量。 - 可用输入框形式输入
m
(米) 的部分。 - 暴露切换编辑、查看模式,值的设置、获取几个api。
js
import L from 'leaflet';
/**可编辑的比例尺控件**/
function useNewScale(mapCase, option) {
let baseScaleNum = option.value || 5;
// 创建一些比例尺控件中的元素
const inp = L.DomUtil.create('input');
inp.setAttribute('type', 'number');
inp.value = baseScaleNum;
inp.style.cssText = 'width:40px;height:18px;box-sizing:border-box;margin:0 2px;';
const container = L.DomUtil.create('div');
container.style.cssText = 'box-sizing:border-box;width:100px;border:1px solid #000;border-top:none;background:#fff;padding:2px;';
const span1 = L.DomUtil.create('span');
span1.innerText = '100px/';
const span2 = L.DomUtil.create('span');
span2.innerText = baseScaleNum;
const span3 = L.DomUtil.create('span');
span3.innerText = 'm';
// 初始化缩放级数
const initZoom = () => mapCase.setZoom(0);
// 更新比例尺视图中的数值
const updateVal = function() {
const zoom = mapCase.getZoom();
const val = (baseScaleNum/Math.pow(2,zoom)).toFixed(2);
span2.innerText = Number(val);
inp.value = Number(val);
};
// 用户中途离开编辑改变了缩放的情况
inp.addEventListener('focus',initZoom);
L.Control.NewScale = L.Control.extend({
onAdd: function(map) {
container.appendChild(span1);
container.appendChild(span2);
container.appendChild(span3);
// 监听缩放,修改比例尺数值
map.on('zoomend', updateVal);
return container;
},
onRemove: function() {
// Nothing to do here
},
});
L.control.NewScale = function(opts) {
return new L.Control.NewScale(opts);
};
// 比例尺添加到地图
L.control.NewScale({ position: 'bottomleft' }).addTo(mapCase);
return {
// 编辑/查看模式设置
setMode:(mode) => {
if (mode === 'edit') {
initZoom();
container.contains(span2) && container.replaceChild(inp, span2);
} else {
container.contains(inp) && container.replaceChild(span2, inp);
}
},
// 设置新的比例尺数据
setValue: (val) => {
baseScaleNum = val;
updateVal();
},
// 获取当前比例尺数据
getValue:() => Number(inp.value),
};
}
4、新建设备图层并结合第三方库
我们的设备使用图片来表示,但leaflet支持显示图片的只有svgOverLayer, imageOverlay, mark标记
,前两者都不支持编辑,后者编辑只支持移动,缺少我们需要的"旋转、缩放"两项。
结合一个其它的图形库使用是个不错的办法(这里使用antv-x6
做示例)。
用leaflet新建一个图层作为antv-x6
的容器,使用这个库现成的功能做设备的编辑操作。为什么不用一个新的dom元素覆盖在leaflet
容器上来实现呢?因为在进行地图缩放操作时,通过监听leaflet的缩放事件,来控制anvt-x6
容器的缩放会比较生硬。
实现如下:
- 缩放控制 :用leaflet创建一个
svgOverLayer
图层,图层的svg
元素下再创建一个foreignObject
元素放置普通的dom元素作为antv-x6
实例的容器。利用svg缩放的特性,leaflet缩放时antv-x6
容器就可以丝滑的跟随缩放。 - 交互处理 :leaflet会在
svgOverLayer
上设置事件穿透 ,所以我们要在antv-x6
新建的节点元素上启用事件(pointer-events:auto;
),并设置阻止冒泡,这样将两个库的事件隔离开,不会导致交互冲突。
js
import { Graph } from '@antv/x6';
// imgObj: 包含底图宽高。
function getDeviceContainer(imgObj) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const foreign = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
const container = document.createElement('div');
const _css = `width:${imgObj.width}px;height:${imgObj.height}px;position:absolute;z-index:20;`;
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('viewBox', `0 0 ${imgObj.width} ${imgObj.height}`);
svg.setAttribute('width', imgObj.width + 'px');
svg.setAttribute('height', imgObj.height + 'px');
foreign.setAttribute('width', imgObj.width + 'px');
foreign.setAttribute('height', imgObj.height + 'px');
foreign.appendChild(container);
svg.appendChild(foreign);
container.style.cssText = _css;
// 隔绝antv与leaflet的事件影响
container.onmousedown = function(e) {
e.stopPropagation();
};
return { svg,container };
}
// 使用
const bgObj = {width,height};
const { svg,container:graphEl } = getDeviceContainer(bgObj);
const editControl = (node) => {
if (!node) return false;
else return editMap.device.includes(node.id);
};
const graph = new Graph({
...其它配置,
container: graphEl,
interacting: {
nodeMovable: true,
},
width: bgObj.width,
height: bgObj.height,
});
// 设置设备元素部分事件可用。
graph.view.viewport.setAttribute('style', 'pointer-events:auto;');
之后设备的添加/编辑功能使用antv-x6
来完成即可。
三、总结
文章主要是想表明:即使是"园区、楼层"这种小型地图使用leaflet.js
这类gis库也是比较合适的,如果开发中有遇到此类需求可以考虑leaflet.js+其它第三方图形库 开发的方式解决。第三方图形库最好选择svg
类型的,这样在缩放时不用做其它处理。以上的一点经验希望对大家有所帮助。