ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Leaflet离线地图示例集合:瓦片加载、标注与图层控制实战

Leaflet离线地图示例集合:瓦片加载、标注与图层控制实战

简介:这份 Leaflet 离线地图案例合集面向 GIS 开发人员与前端地图爱好者,围绕网页端二维地图的离线集成、交互定制与性能调优提供系统参考。压缩包共 723 个文件,约 14.08MB,以 js 脚本、html 页面、png 图示和 md 说明为主,并辅以 css、hbs 模板及少量配置与文档文件,便于按示例查阅和二次改造。内容覆盖基本地图加载、图层管理、标记与弹出框、控件使用、拖拽缩放等交互、地理编码、路径与多边形绘制、地图事件监听、自定义图层、地图投影、性能优化和响应式设计等十余类场景,每个 Demo 对应独立学习点。已有 4150 人学习,适合希望从示例出发掌握 Leaflet 核心能力、并迁移到 GIS、导航或房地产地图项目中的读者。

1. 离线地图的真实需求:一套能直接跑的 Leaflet 示例集合

某公司的资产巡检系统上线前一周,测试环境里的地图整片灰白——外网瓦片域名全被内网防火墙挡掉了。需求变得很直接:地图数据必须跟系统一起打包,不能依赖任何外部请求。Leaflet 这个库本身很小,BSD 协议,插件生态也成熟,问题从来不在“要不要用”,而在“离线这一层怎么补齐”。一份完整的 leaflet demo 集合,价值就在这里:它把 gis 场景下最常见的几件事——瓦片加载、标注、图层控制、坐标处理——拆成一个个能直接跑的例子,照着改就能拼进 web 项目。这套东西适合两类人:正在做毕设或课设、需要交付内网可运行地图模块的学生,以及要把离线地图集成进后台系统的前端开发。

2. 技术选型与总体架构:Leaflet 内核加离线瓦片的三层分工

2.1 为什么是 Leaflet,而不是整套 GIS 全家桶

离线地图能选的路线大致三条。OpenLayers 功能最全,但 API 抽象层多,打包体积大,一个普通二维业务项目为了画几个标记去啃整套投影体系,投入产出比并不划算;Cesium 面向三维,没有三维需求就是杀鸡用牛刀;剩下的就是 Leaflet,内核压缩后几十 KB,API 就是一层一层 Layer 往上叠,文档读一遍能上手,配上少量插件后覆盖绝大多数 web 端二维地图场景。

选择 Leaflet 之后,要清楚一件容易被忽略的事:Leaflet 自己不带任何地图数据。它只负责渲染视图、处理缩放和事件,瓦片从哪来,得由你指定。在线场景下 L.tileLayer 填一个公共瓦片地址就行,离线场景则要求瓦片躺在本地目录或者本地服务里。所以真正决定项目能不能跑起来的,从来不是 Leaflet 本身,而是瓦片源这一层准备得够不够扎实。

这也是为什么一套按功能拆开的 demo 集合比单篇教程好用。教程通常只讲“怎么写一个 Marker”,但真实项目里瓦片路径、层级范围、坐标一致性这些问题一旦有一处对不上,页面就是一整块灰白。demo 集合的作用是把每个环节单独拆出来,先让每个零件能独立转,再谈拼装。

2.2 三层架构:瓦片源、地图内核、业务图层

把这套方案摊开,是三层结构,每层职责清晰,改一层不会牵动另一层。

层级核心职责典型产物改动频率
瓦片源层提供可访问的瓦片地址模板本地瓦片目录 / MBTiles / 切片服务低,数据更新时才动
地图内核层管理视图、缩放层级、坐标系与事件L.map 实例、基础配置中,交互需求变化时动
业务图层层承载业务数据与交互Marker、GeoJSON、绘制图层高,业务迭代就跑这层

瓦片源层是地基。它对外只暴露一个模板,比如./tiles/{z}/{x}/{y}.png,Leaflet 拿到模板后按当前视图换算出需要请求的瓦片编号。这一层的关键是“命名规则必须和 Leaflet 的换算逻辑一致”,XYZ 和 TMS 两种命名差一个 y 轴翻转,搞混了就是整屏错位。

地图内核层负责视图状态。中心点、初始层级、最小最大缩放、是否允许跨经度循环,这些参数决定了用户第一次打开地图看到什么。离线场景下有个参数特别容易被漏——noWrap,默认允许跨经度循环,本地瓦片只有一份时,往东拖过边界会出现同一套瓦片重复铺开的怪象。

业务图层层是真正体现业务价值的地方。巡检点、区域边界、热力分布这些数据都挂在这一层,每个图层都可以独立开关、独立绑定事件。把这三层分开之后,后面无论是换瓦片格式还是加业务标注,都不会牵一发而动全身。

2.3 目录结构与依赖清单

一套能直接跑起来的 demo 集合,目录最好按“公共资源 + 单例演示”来组织,避免每个例子复制一份 Leaflet 造成版本漂移。

leaflet-offline-demos/ ├── lib/ │ ├── leaflet/ # 统一入口的 Leaflet 内核与样式 │ └── plugins/ # 按需引入的绘制、聚合等插件 ├── tiles/ # 离线瓦片,按 {z}/{x}/{y}.png 存放 ├── data/ # GeoJSON、业务点位等静态数据 ├── img/ # 图标、标记图片 ├── demos/ │ ├── 01-basic-map/ # 最小可运行地图 │ ├── 02-marker-popup/ # 标注与弹窗 │ ├── 03-layer-control/ # 底图与业务图层切换 │ ├── 04-geojson/ # 矢量要素加载 │ └── 05-events/ # 交互事件与坐标拾取 └── index.html # 演示索引页

依赖清单保持在最小集:Leaflet 内核、一个负责图层控制的官方控件(内核已自带)、按需的绘制插件、按需的点聚合插件。这里有一条血泪经验——插件版本必须跟 Leaflet 主版本对齐,写这篇文章时 Leaflet 主流是 1.x 系列,很多老插件还停在 0.7 时代,混用会在控制台抛出“L 未定义某方法”的报错,排查起来像玄学。统一放在lib/下集中管理,是让 demo 集合长期可维护的前提。

提示:每个 demo 只引公共目录下的 lib,不要在单例目录里再复制一份 Leaflet,否则改一处版本要改十处。

3. 离线瓦片的生产与加载:从切片工具到地图渲染

3.1 瓦片切片:层级、命名规则与存储格式

离线瓦片不是拿一张大图切九宫格那么简单,它需要按照 Web 墨卡托投影切成金字塔结构,每一层(zoom)把上一层切成四块。常用的切片工具有 gdal2tiles、QGIS 的切片插件、以及一些在线切图工具,命令行里最省事的还是 gdal2tiles。

# 把一份影像或矢量渲染结果切成 XYZ 瓦片 # -z 指定层级范围,-p 指定投影,--xyz 让输出目录结构与 Leaflet 默认对齐 gdal2tiles.py -z 6-15 -p mercator --xyz ./source.tif ./tiles

几个参数值得单独说明。-z 6-15是层级范围,层级越多瓦片数量指数级增长,一个中等城市范围到 15 级大概是几万张图,打包前要想清楚项目实际需要的最大缩放;-p mercator必须是 Web 墨卡托,因为 Leaflet 默认就在这个投影下工作,换成别的投影后地图放上去会整体拉伸错位;--xyz最关键,它让输出目录变成tiles/{z}/{x}/{y}.png这种顺序,跟 Leaflet 的换算规则一致,不加这个参数输出会变成 TMS 顺序,y 轴从下往上数,直接拿过来用就是上下颠倒。

切片完成后建议先做一次完整性检查——随手抽几个层级看目录里 x 和 y 的编号范围是否连续,有没有空洞。空洞通常出现在源数据的边缘区域,如果源图覆盖范围不够,某些瓦片就是空的,前端加载时会看到局部空白,容易被误判成代码问题。

3.2 用 L.tileLayer 加载本地瓦片

瓦片准备好之后,Leaflet 这一侧其实只有一行核心代码。

// 初始化地图实例,所有参数都围绕“离线”这两个字来定 const map = L.map('map', { center: [30.0, 120.0], // 初始中心点,按实际业务范围替换 zoom: 10, // 初始层级,落在切片层级范围内 minZoom: 6, // 不能小于切片的最小层级 maxZoom: 15, // 不能大于切片的最大层级 attributionControl: false // 离线场景一般不需要版权信息控件 }); // 指向本地瓦片目录,模板必须与切片输出结构完全一致 L.tileLayer('./tiles/{z}/{x}/{y}.png', { minZoom: 6, maxZoom: 15, tms: false, // XYZ 命名用 false,TMS 命名才需要 true noWrap: true, // 关键:禁止跨经度循环,避免边界重复瓦片 keepBuffer: 4 // 预加载周边瓦片,拖动时减少白块 }).addTo(map);

minZoom和maxZoom两处要跟切片范围严格对齐。如果地图允许用户缩到 5 级但切片只有 6 级起,5 级视图会是一整片空白;反过来如果允许放大到 18 级而切片只到 15 级,Leaflet 会把 15 级的瓦片强行放大填充,画面糊成一片,用户第一反应就是“地图坏了”。noWrap值得再强调一次,离线项目里这是最容易翻车的参数之一,不设它,用户往东多拖两屏,整片地图就会开始重复出现,看上去像是数据错了,其实是循环渲染。

注意:keepBuffer设得越大越平滑,但内存占用也越高。移动端建议 2 到 3,桌面端 4 左右比较稳。

3.3 三种离线存储方案的对比

瓦片放哪里,决定了这套方案好不好部署、好不好扩展。常见的三种存法对比如下。

方案部署难度数据量适应性查询能力适用场景
本地目录 + 静态服务低,一个 HTTP 服务即可小到中等无,纯文件读取课设、演示、单机部署
MBTiles 单文件中,需服务端解析大支持按层级查询数据包要整体分发
本地切片服务高,需额外程序大支持动态切片多项目共用底图

本地目录方案最简单,把tiles目录扔到任意静态服务器后面就能用,缺点是文件数多,几万个小文件拷贝和部署都慢。MBTiles 把瓦片塞进一个 SQLite 文件,分发时只有一个文件,体积也小,代价是服务端要能读 SQLite 并按{z}/{x}/{y}查出来再吐给前端,查询逻辑大致是这样。

-- MBTiles 标准结构里,瓦片按 zoom_level / tile_column / tile_row 存储 -- 注意 MBTiles 内部用的是 TMS 行号,对外提供 XYZ 时要翻转 y SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = (1 << ?) - 1 - ?;

最后那句(1 << zoom) - 1 - y就是 TMS 行号到 XYZ 行号的翻转公式,少了它,所有瓦片都会上下颠倒。切片服务方案适合底图被多个项目复用的情况,瓦片按需生成并缓存,代价是引入了一个额外服务,对毕设这种单机交付的场景并不划算。

4. 示例集合拆解:标注、图层控制与空间查询的落地写法

4.1 标注与弹窗:Marker、Popup 与自定义图标

标注是地图上最基础也最常被改动的元素。默认的蓝色水滴图标几乎每个项目都会换掉,换成业务图标这里有两个参数必须调。

// 自定义图标:iconSize 是图片尺寸,iconAnchor 是锚点(对准坐标的位置) const pinIcon = L.icon({ iconUrl: './img/pin.png', iconSize: [32, 40], iconAnchor: [16, 40], // 图片底边中点对准坐标,图标才不会“飘”在点上方 popupAnchor: [0, -36] // 弹窗从图标顶部弹出 }); // 创建标注并绑定弹窗 const marker = L.marker([30.0, 120.0], { icon: pinIcon }).addTo(map); marker.bindPopup('<b>巡检点 A</b><br>状态:正常<br>时间:今日 09:20'); // 批量标注用 FeatureGroup 统一管理,方便后面一次性显示/隐藏 const poiGroup = L.featureGroup([marker]).addTo(map);

iconAnchor是最容易踩的一个点。不设它,Leaflet 会默认把图标中心对准坐标,结果就是图标的下半部分戳进了坐标点里,视觉上整体偏移几十像素。设成[16, 40]表示图标宽度一半、高度全高,正好让图片底边中点落在坐标上。批量场景下用L.featureGroup包一层比直接addTo到地图上更好管理,后面要做“显示全部巡检点”这种开关,一个addLayer/removeLayer就搞定。

4.2 图层切换:底图切换与业务图层开关

底图和业务图层分开控制,是让界面保持清爽的关键。Leaflet 自带的图层控件两行就能配好。

// 底图集合:至少准备一套离线底图,另一套可以是简版灰度底图 const baseLayers = { '离线底图': offlineLayer, '灰度底图': grayLayer }; // 业务图层集合:每个开关对应一个 featureGroup const overlays = { '巡检点位': poiGroup, '区域边界': zoneGroup, '热力分布': heatGroup }; // 右上角挂图层控件,collapsed 控制默认是否收起 L.control.layers(baseLayers, overlays, { position: 'topright', collapsed: true }).addTo(map);

底底层和业务图层的区别在于“同一时刻只能显示一张底图”,业务图层则可以任意叠加。写的时候要保证每个业务图层都对应一个独立featureGroup,不要把所有东西都塞进地图实例里,否则控件拿不到切换句柄。图层顺序也有讲究,后加的图层在上层,弹窗和标注要放在区域边界之后加,不然会被面状要素盖住点不中。

4.3 空间数据加载:GeoJSON 与矢量要素渲染

业务数据最常以 GeoJSON 交付,Leaflet 对它的支持是一等公民级别的。

// 异步加载区域数据并渲染,style 控制面样式,onEachFeature 挂交互 fetch('./data/zones.geojson') .then(res => res.json()) .then(data => { L.geoJSON(data, { // 根据要素属性动态决定填充色,比写死一个颜色灵活 style: feature => ({ color: '#0b6', weight: 2, fillOpacity: feature.properties.level > 2 ? 0.35 : 0.15 }), // 每个要素加载完都可以单独绑定弹窗 onEachFeature: (feature, layer) => { layer.bindPopup(`区域:${feature.properties.name}`); } }).addTo(zoneGroup); });

style函数接收每个要素,可以按属性决定颜色、透明度、线宽,这比一次性设置统一样式实用得多。onEachFeature则是挂事件、绑弹窗的统一入口,注意回调里的layer是当前要素图层,不是整个 GeoJSON 图层,作用域搞错就会让所有要素显示同一个弹窗内容。加载时机上,建议等地图load事件之后再拉数据,不然首屏要同时下瓦片和 GeoJSON,弱网环境下容易卡一下。

4.4 交互事件:点击、缩放与坐标拾取

地图上的交互事件都挂在 map 实例或具体图层上,坐标拾取是最常见的一个需求。

// 点击地图任意位置,把经纬度写到页面某个元素里 map.on('click', e => { const { lng, lat } = e.latlng; document.getElementById('coord').textContent = `经度 ${lng.toFixed(6)},纬度 ${lat.toFixed(6)}`; }); // 缩放事件里读取当前层级,常见用途是按层级加载不同精度的数据 map.on('zoomend', () => { const currentZoom = map.getZoom(); // 低层级只显示区域中心,高层级才展开点位 poiGroup.setZIndex(currentZoom >= 12 ? 500 : 100); });

事件回调里的e.latlng是已经做过分层换算的坐标,不需要自己再乘缩放系数。要注意的是批量标注场景下,点击标注会同时触发地图的 click,如果业务里禁止点击标注时还记录坐标,需要在 marker 上再挂一个click事件阻止冒泡,否则每次点标注都会多算一个坐标点。

5. 常见问题与排查:五类最容易翻车的现场

5.1 整屏灰白或瓦片 404

现象:页面打开后地图区域一直灰白,打开开发者工具满屏404 (Not Found)。

原因:绝大多数是瓦片模板和实际目录对不上。要么路径多了或少了一层目录,要么tms参数跟实际命名规则相反,要么静态服务把.png当成未知类型拒绝返回。

解决:先手动把浏览器地址栏改成http://本地地址/tiles/10/512/340.png这种具体路径,能打开就说明目录没错,问题在模板;打不开就查静态服务的根目录是否指到了tiles的上一层。命名规则是 XYZ 就把tms设成false,是 TMS 就设true,这条确认之后 404 基本会消失。

5.2 瓦片错位与层级不连续

现象:地图能显示,但相邻瓦片之间出现明显缝隙,或者整体像被平移了几十像素。

原因:通常来自两处。一是切片时投影不是 Web 墨卡托,比例尺跟 Leaflet 的换算不匹配;二是瓦片输出不是标准的 256×256 尺寸,而是 512×512 但没做对应配置。

解决:切片命令里补上-p mercator重新切一次。如果是瓦片尺寸问题,在L.tileLayer里加tileSize: 512,同时把zoomOffset: -1加上,让层级换算重新对齐。这类问题翻车之后最难查,因为进度条正常、瓦片数量也对,就是位置歪了。

5.3 坐标系偏移

现象:标注点落在路上而不是建筑上,或者整体偏移固定的一两百米。

原因:业务数据用的坐标系与底图不一致。底图在 Web 墨卡托下不做额外偏移,而部分业务数据来源于其他坐标体系,两者叠在一起就会出现固定方向的偏移。

解决:先确认底图和业务数据是否在同一套坐标下。不一致时应该在数据入库之前就统一转换,不要指望前端在渲染时补偏移,那会让后续所有空间查询跟着歪。转换脚本单独放一份,转换前后各抽三个点人工核对,是性价比最高的检查方式。

5.4 移动端手势冲突

现象:在手机或平板上,地图一拖动整页跟着上下滚,或者双指缩放没反应。

原因:地图容器没有阻止触摸事件冒泡,浏览器的默认滚动行为把拖拽手势吃掉了。

解决:给地图容器加touch-action: none,同时确保地图初始化时没有禁用拖拽。如果页面本身需要滚动,就把地图嵌在一个固定高度的容器里,让手势从地图上开始时不触发页面滚动。这一条在课设演示用笔记本时完全看不出来,一上手机就露馅。

5.5 大瓦片包首屏卡顿

现象:加载十几万张瓦片的大包,首屏要等五六秒才出图,拖动时大面积白块。

原因:瓦片文件数太多,静态服务的并发和磁盘寻道都扛不住;同时前端默认只预加载当前视图附近少量瓦片。

解决:文件多的场景改成 MBTiles 单文件方案,服务端只读一个文件,随机读性能稳定。前端侧把keepBuffer调到 3 到 4,配合updateWhenIdle: false让拖动过程中就发起请求,不要等停下才请求。首屏可以加一个加载遮罩,等tileload事件计数达到阈值再撤掉,视觉上会顺畅很多。

6. 验收与答辩:怎么证明离线地图真的跑通了

地图类项目最容易出现的验收翻车,是“在自己电脑上一切正常,换个环境全白”。所以验收不能只看页面能不能出来,得按一组明确的检查项走一遍,把证明过程留成记录。

我一般会用下面这张表作为交付前的最后一道关卡,每一项都要有可复现的操作路径。

检查项操作方法通过标准
完全断网可运行拔掉网线或关掉无线,只留本地静态服务地图、标注、弹窗全部正常,控制台无外网请求
层级边界从最小层级缩到最大层级再缩回无空白、无拉伸模糊、无超出范围依旧请求
边界拖动向东、向南拖动超过数据覆盖范围不出现重复瓦片,边界处显示为空白而非错乱
标注位置点击三个已知识别点,核对经纬度坐标与数据源误差在可接受阈值内
移动端在手机浏览器打开,做拖拽与双指缩放地图滚动与页面滚动互不干扰
首屏性能清缓存后强刷,记录首图时间达到项目约定阈值,一般 3 秒内

答辩场景下,光有表格不够,还得让评审看到“断网仍是活的”。最直接的方式是现场把网络断开,再刷新页面,地图能起来、点得动、弹窗能打开,这三件事做完,评委基本不会再纠结技术细节。反过来如果当场需要连网才显示,那前面讲的所有架构都不成立。

还有一个技巧值得提前做:给每个 demo 单独准备一个极短的操作路径说明,比如“打开 03-layer-control,点右上角图层按钮,切换灰度底图,观察巡检点位是否保持可见”。答辩时间有限,评审不会自己探索,你把三步路径写清楚,对方照做,整套方案的完整度立刻不一样。

从那以后我每次准备地图类交付,都会强制走一遍“断网 + 换设备 + 缩到底 + 拖出界”这四步,哪怕时间再紧也不跳过——血泪经验是跳过哪一步,翻车就出在哪一步。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表