Leaflet 记录

Leaflet 记录

概要

Leaflet 是一个开源的 js 地图库,基本可以满足地图的常用功能开发,且非常轻量。生态较为完善,比 openlayers 稍差。

针对大批量的数据渲染,其性能表现不佳,需要一定优化。也可以采用 webgl 的插件,但是不兼容当前的样式规则,渲染效果一般。

因为年代比较久远,存在各种版本,主要表现在 1.0 前后的插件兼容问题。

官网:Leaflet - a JavaScript library for interactive maps (leafletjs.com)

插件:Plugins - Leaflet - a JavaScript library for interactive maps (leafletjs.com)

  1. 如果为了效率,选择 openlayers;如果为了好看,选择 mapbox 相关库;三维选择 Cesium;
  2. Leaflet 的控件比较难修改,建议使用自定义控件去实现相关工具功能;
  3. 注意 leaflet 中的 layer 不一定是指图层,单个 feature 也是 layer;

创建地图

1
<div id="map" style="width:100%;height:100%;"></div>

声明地图属性,通过 id 绑定 html 中的元素

1
2
3
4
5
6
7
8
9
let map = L.map("map", {
    renderer: L.canvas(),
    center: [23, 113.5], // 中心位置
    minZoom: 3,
    maxZoom: 21,
    zoom: 10, // 缩放等级
    zoomControl: true,
    attributionControl: false // 版权控件
});

栅格数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 天地图-影像图
var tdtRasterLayer = L.tileLayer(tdtRasterUrl, {
    // 最大瓦片层级
    maxNativeZoom: 18,
    // 最大缩放层级(大于18级时,显示的瓦片依旧是18级)
    maxZoom: 21,
    subdomains: subdomains,
    crossOrigin: true
});
tdtRasterLayer = tdtRasterLayer.addTo(this.map);

矢量数据

矢量 GeoJSON

1
const geoJSONLayer = L.geoJSON(feature).addTo(map);

矢量切片服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let planTileLayer = vectorTileLayer(url, {
    rendererFactory: L.canvas.tile,
    style: {
        weight: 1.0,
        color: "#FF3900",
        fill: true,
        // dashArray: "2, 6",
        fillOpacity: 0.0
    }
});
planTileLayer.addTo(this.map);

选择

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
this.targetTaskLayer.on("click", e => {
    const feature = e.layer;
    // 设置选中的颜色
    feature.setStyle({
        weight: 1,
        color: "#ffff00",
        fillColor: "#ffff00",
        opacity: 1,
        fillOpacity: 0.3
    });
    this.map.fitBounds(feature.getBounds());
});

框选

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 框选范围
var selectedBounds = [];
var startLatLng;
// 选中的所有要素
var intersectingFeatures = [];

map.on("mousedown", function(e) {
    startLatLng = e.latlng;
});

map.on("mousemove", function(e) {
    if (startLatLng) {
        selectedBounds = L.latLngBounds(startLatLng, e.latlng);
        // 实时选择
    }
});

map.on("mouseup", function(e) {
    if (startLatLng) {
        selectedBounds = L.latLngBounds(startLatLng, e.latlng);
        // mouseup 时选择
        vectorLayer.eachLayer( layer => {
            if (layer.getBounds().intersects(selectedBounds)) {
                intersectingFeatures.push(layer);
            }
        });
        startLatLng = null;
    }
});

编辑

geoman-io/leaflet-geoman: 🍂🗺️ The most powerful leaflet plugin for drawing and editing geometry layers (github.com)

绘制、合并、裁剪、移除、融合、简化、炸开等操作暂不展开描述。

标注

添加 html 元素作为标注,性能上限很明显。有 WebGL 插件可用,存在一定限制,暂未测试。查看详情请到插件页搜索 WebGL。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
 * 标注要素。拓展 L.Layer 的方法,调用 layer.drawAnno()
 */
(function () {
    L.Layer.prototype.drawAnno = function (annoContent, color) {
		// 要素为空
        if (!this.feature) {
            return;
        }
        // 要素已有的注记移除
        if (this.anno) {
            this.anno.remove();
        }
        let center = turf.center(this.feature).geometry.coordinates.reverse();
        let marker = L.marker(center, {
            interactive: false,
            icon: L.divIcon({
                className: "custom-color-icon",
                html:
                "<div style='color:" +
                color +
                "; text_align:center; word-break: keep-all;text-shadow: #E0E0AE 1px 0 0, #E0E0AE 0 1px 0, #E0E0AE -1px 0 0, #E0E0AE 0 -1px 0;'>" +
                annoContent +
                "</div>"
            })
        });
        marker.addTo(this._map);
        this.on('remove', () => marker.remove());
    };
})();

样式

Documentation - Leaflet - a JavaScript library for interactive maps (leafletjs.com)

样式内容比较简单,基本照搬了部分html的样式。都可以在 MDN 中搜索到 stroke - SVG:可缩放矢量图形 | MDN (mozilla.org)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
    stroke: stroke,
    color: "#ffff00",
    weight: 1,
    opacity: 1,
    lineCap: 'round',
    lineJoin: 'round',
    dashArray: null,
    dashOffset: null,
    fill: depends,
    fillColor: "#ffff00",
    fillOpacity: 0.3,
    fillRule: 'evenodd'	,
    bubblingMouseEvents: true,
    renderer: ,
    // Custom class name set on an element. Only for SVG renderer.
    className: null,
}
1
2
3
const geoJSONLayer = L.geoJSON(feature, {
    style: f => this.getTaskStyle(f)
}).addTo(map);

自定义控件

查询对应的 map 方法封装控件

Documentation - Leaflet - a JavaScript library for interactive maps (leafletjs.com)

地图上各类数据渲染层级

通过 getPane(<String|HTMLElement> *pane*) 或者 map.getPanes() 获取到 Pane 元素,并进行修改。也可以自行创建 Pane map.createPane(<String> *name*, <HTMLElement> *container?*)

Pane Type Z-index Description
mapPane HTMLElement 'auto' Pane that contains all other map panes
tilePane HTMLElement 200 Pane for GridLayers and TileLayers
overlayPane HTMLElement 400 Pane for vectors (Paths, like Polylines and Polygons), ImageOverlays and VideoOverlays
shadowPane HTMLElement 500 Pane for overlay shadows (e.g. Marker shadows)
markerPane HTMLElement 600 Pane for Icons of Markers
tooltipPane HTMLElement 650 Pane for Tooltips.
popupPane HTMLElement 700 Pane for Popups.

其他

针对海量矢量数据的解决办法

发布矢量切片服务;注记需要自定义避让规则,选择性显示;

地图示例

1
<div id="map"></div>
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
/**
     * @description: 初始化地图
     * @return {*}
     */
initMap() {
    let map = L.map("map", {
        renderer: L.canvas(),
        center: [23, 113.5], // 中心位置
        minZoom: 3,
        maxZoom: 21,
        zoom: 10, // 缩放等级
        zoomControl: false,
        attributionControl: false // 版权控件
    });

    this.map = map; // data上需要挂载
    window.map = map;

    // 设置地图语言
    this.map.pm.setLang(this.$i18n.locale);

    this.map.groupLayer = L.control
        .layers(null, null, { position: "bottomright" })
        .addTo(this.map);

    this.initLayers();

    // 搜索控件
    const searchControl = new SearchControl({
        provider: new OpenStreetMapProvider(),
        // style: "bar",
        searchLabel: "输入搜索的地址……",
        autoClose: true,
        position: "topright"
    });
    map.addControl(searchControl);

    map.on("geosearch/showlocation", args => {
        console.log(args);
    });

    L.control
        .zoom({
        position: "topright",
        zoomInTitle: this.$t("map.zoomin"),
        zoomOutTitle: this.$t("map.zoomout")
    })
        .addTo(this.map);

    let zoom2All = this.$t("map.zoom2All");
    L.easyButton(
        '<span class="iconfont icon-wangluo">',
        function (btn, map) {
            getFullExtent().then(res => {
                let pointsStr = res
                .replace("BOX(", "")
                .replace(")", "")
                .split(",");
                let point1 = pointsStr[0].split(" ");
                let point2 = pointsStr[1].split(" ");
                let bounds = L.latLngBounds(
                    L.latLng(point1[1], point1[0]),
                    L.latLng(point2[1], point2[0])
                );
                map.flyToBounds(bounds);
            });
        },
        zoom2All
    )
        .setPosition("topright")
        .addTo(map);

    let mu = this.$t("map.mu");
    /**
       * 测量控件
       */
    let messureControl = L.control
    .measure({
        primaryLengthUnit: "meters",
        secondaryLengthUnit: "kilometers",
        primaryAreaUnit: "sqmeters",
        secondaryAreaUnit: "muUnit",
        units: {
            muUnit: {
                factor: 0.0015,
                display: mu,
                decimals: 2
            }
        }
    })
    .addTo(map);

    // 创建规划
    let a = this;
    let r = this.$refs;
    function createPlan(btn, map) {
        a.isShowBuildPlan = !a.isShowBuildPlan;
        r.bulidPlan.drawPolygon();
    }
    let createPlan_str = this.$t("map.createPlan");
    L.easyButton(
        '<span class="iconfont icon-hangpai">',
        (btn, map) => createPlan(btn, map),
        createPlan_str
    )
        .setPosition("topright")
        .addTo(map);

    L.control
        .Legend({
        Plan: "图例",
        position: "bottomleft",
        opacity: 0.8,
        // collapsed: true,
        legends: StateList.map(p => {
            return {
                label: p.name,
                type: "rectangle",
                color: p.color,
                fillColor: p.color
            };
        })
    })
        .addTo(map);
},
    /**
     * @description: 初始化地图图层
     * @return {*}
     */
    async initLayers() {
        getFullExtent().then(res => {
            let bounds = null;
            if (res != null) {
                let pointsStr = res
                .replace("BOX(", "")
                .replace(")", "")
                .split(",");
                let point1 = pointsStr[0].split(" ");
                let point2 = pointsStr[1].split(" ");
                bounds = L.latLngBounds(
                    L.latLng(point1[1], point1[0]),
                    L.latLng(point2[1], point2[0])
                );
            } else {
                bounds = L.latLngBounds(
                    L.latLng(18.06231230454674, 71.10351562500001),
                    L.latLng(49.23912083246701, 144.84375000000003)
                );
            }
            this.map.fitBounds(bounds);
        });

        // 天地图-影像图
        var tdtRasterLayer = L.tileLayer(tdtRasterUrl, {
            maxNativeZoom: 18,
            maxZoom: 21,
            subdomains: subdomains,
            crossOrigin: true
        });
        tdtRasterLayer = tdtRasterLayer.addTo(this.map);
        tdtRasterLayer.name = "影像图";
        this.map.groupLayer.addOverlay(tdtRasterLayer, tdtRasterLayer.name);
        // 天地图-地形图
        var tdtTerrinLayer = L.tileLayer(tdtTerrinUrl, {
            maxNativeZoom: 18,
            maxZoom: 21,
            subdomains: subdomains,
            crossOrigin: true
        });
        // tdtTerrinLayer = tdtTerrinLayer.addTo(this.map);
        tdtTerrinLayer.name = "地形图";
        this.map.groupLayer.addOverlay(tdtTerrinLayer, tdtTerrinLayer.name);

        // 谷歌地图
        var googleRasterLayer = L.tileLayer(googleRasterUrl, {
            maxNativeZoom: 18,
            maxZoom: 21,
        });
        googleRasterLayer.name = "谷歌地图";
        this.map.groupLayer.addOverlay(googleRasterLayer, googleRasterLayer.name);

        // 天地图-地名地址
        var tdtAnnoLayer = L.tileLayer(tdtAnnoUrl, {
            maxNativeZoom: 18,
            maxZoom: 21,
            subdomains: subdomains,
            crossOrigin: true
        });
        tdtAnnoLayer = tdtAnnoLayer.addTo(this.map);
        tdtAnnoLayer.name = "地名地址";
        this.map.groupLayer.addOverlay(tdtAnnoLayer, tdtAnnoLayer.name);

        let userId = localStorage.getItem("userId");
        let roleName = localStorage.getItem("roleName");
        const host = "http://" + window.location.hostname;
        // 添加规划矢量切片图层
        let url =
            "/vectorTile/plan/{z}/{x}/{y}?userId=" +
            userId +
            "&roleName=" +
            roleName;
        let PlanTileLayer = vectorTileLayer(url, {
            rendererFactory: L.canvas.tile,
            style: {
                weight: 1.0,
                color: "#FF3900",
                fill: true,
                // dashArray: "2, 6",
                fillOpacity: 0.0
            }
        });
        PlanTileLayer.addTo(this.map);
        PlanTileLayer.name = this.$t("map.planLayer");
        this.map.groupLayer.addOverlay(PlanTileLayer, PlanTileLayer.name);

        // 添加架次矢量切片图层
        url =
            "/vectorTile/task/{z}/{x}/{y}?userId=" +
            userId +
            "&roleName=" +
            roleName;
        let taskTileLayer = vectorTileLayer(url, {
            rendererFactory: L.canvas.tile,
            zIndex: 999,
            style: feature => {
                // 配置样式
                let color = "#FFFFFF";
                if (feature.properties.state_index != undefined) {
                    let temp = StateList.find(p => p.index == feature.properties.state_index);
                    if (temp) {
                        color = temp.color;
                    }
                }
                if (feature.properties.is_upload_task_data != undefined) {
                    let temp = StateList.find(p => p.name == '已回传');
                    if (temp) {
                        color = temp.color;
                    }
                }
                if (feature.properties.final_check_result != undefined) {
                    let temp = StateList.find(p => p.index - 100 == feature.properties.final_check_result);
                    if (temp) {
                        color = temp.color;
                    }
                }
                return {
                    weight: 0.5,
                    color: color,
                    fillColor: color,
                    fillOpacity: 0.3
                };
            }
        });
        taskTileLayer.addTo(this.map);
        taskTileLayer.name = this.$t("map.taskLayer");
        this.map.groupLayer.addOverlay(taskTileLayer, taskTileLayer.name);

        /**
       * @description: 选择要素,添加到选择列表,并高亮
       * @param undefined
       * @return {*}
       */
        this.map.selectFeature = function (fea) {
            if (fea != null) {
                if (this.selectFeatures == null) {
                    this.selectFeatures = [];
                }
                if (fea.isSelect == null || !fea.isSelect) {
                    this.defaultFeaStyle = {
                        color: fea.options.color,
                        fillColor: fea.options.fillColor
                    };
                    fea.setStyle({
                        color: "#ffff00",
                        fillColor: "#ffff00",
                        opacity: 0.3
                    });
                    fea.isSelect = true;
                    this.selectFeatures.push(fea);
                } else {
                    fea.isSelect = false;
                    fea.setStyle(this.defaultFeaStyle);
                    // 删除指定元素
                    this.selectFeatures = this.selectFeatures.filter(p => p != fea);
                }
            }
        };

        this.map.on("zoom", function (evt) {
            let map = evt.target;
            // 清除其他规划标注
            let layers = map._layers;
            for (const key in layers) {
                if (Object.hasOwnProperty.call(layers, key)) {
                    const layer = layers[key];
                    if (layer.name == "PlanAnno") {
                        try {
                            layer.remove();
                        } catch (error) {
                            console.log("remove-error: " + error);
                        }
                    }
                }
            }
            if (map.getZoom() >= 10) {
                PlanPosAndNameList({ bbox: map.getBounds().toBBoxString() }).then(res => {
                    // 更新视窗范围内标注
                    res.forEach(item => {
                        lableAnno(
                            JSON.parse(item.pos),
                            item.name,
                            "PlanAnno",
                            "red",
                            map
                        );
                    });
                });
            }
            if (map.getZoom() >= 12) {
                taskPosAndNameList({ bbox: map.getBounds().toBBoxString() }).then(res => {
                    // 更新视窗范围内标注
                    res.forEach(item => {
                        lableAnno(
                            JSON.parse(item.pos),
                            item.task_number ? item.task_number : "",
                            "PlanAnno",
                            "red",
                            map
                        );
                    });
                });
            }
        });

        this.map.on("click", e => {
            if (this.isShowBuildPlan) {
                return;
            }
            this.$refs.taskInfoCard.loadTaskInfo(e.latlng, this.map);
        });
    }

总结

整体流程是明晰且简单的,针对常用场景是完全可以满足的。但是现有插件较混乱,对大数据量的支持有限,底层暴露的接口有限,细节控制需要花费大量精力。


参考

【1】Turf.js | Advanced geospatial analysis (turfjs.org)

【2】Documentation - Leaflet - a JavaScript library for interactive maps (leafletjs.com)

Licensed under CC BY-NC-SA 4.0
Gear(夕照)的博客。记录开发、生活,以及一些不足为道的思考……