简介:一套面向Web前端与GIS开发者的LeafLet离线地图示例合集,定位是解决离线场景下地图开发缺少可直接运行参考的问题。内容覆盖地图初始化、图层切换与管理、Marker标记与Popup弹窗、事件监听、GeoJSON几何绘制、离线瓦片加载与缓存等主干功能,同时演示了markercluster聚合、LeafLet.draw绘图、geodesic大圆弧等常用插件的接入方式。压缩包共723个文件,压缩后约14.06MB,其中包含319个js脚本、175个html可运行页面、29个css样式表、79张png图片及若干md说明文档,代码示例与说明文档分层存放,便于按模块检索和对照调试。已有1735人学习下载,适合正在从入门到进阶的前端开发者,也可作为团队内部离线地图技术选型的参考资料。示例还覆盖了地址与坐标互转、自定义图标样式、离线瓦片地图服务配置与本地缓存机制等实际场景,读者可直接复用其中代码,快速搭建稳定且可交付的离线地图应用。
1. 离线地图不是玄学:LeafLet 离线地图资源包能帮你解决什么
做园区可视化的那段时间,我接了个内网部署的活儿——机房不能通外网,又要在大屏上展示设备分布和巡检路径。商用地图SDK的内网授权报价高得离谱,最后我选了Leaflet自己拼离线方案。说句实话,比我想象中快得多,但坑也比想象中多。这套LeafLet离线地图demo合集,就是我那两周一脚一脚踩出来的东西,里面有十几个可以直接改着用的示例页面,覆盖了从最基础的瓦片加载到标绘、量测、弹窗交互的常见场景。适合谁用?跟我一样被内网环境卡住的前端开发者、GIS实施工程师,还有接政企项目需要做地图演示的团队。你不需要把OpenLayers那套大头逻辑啃透,只需要会改JS配置,就能把离线地图跑起来。这份资源解决的核心就一件事:让叶子节点和瓦片文件安安静静地在你的内网里工作。
2. 瓦片金字塔与坐标系的三个关键细节:离线地图跑起来的前提
2.1 瓦片为什么是一张张“小方块”:切片规则与缩放层级
Leaflet本身不存地图数据,它只负责把一堆小方块放到屏幕正确的位置。这些小方块就是瓦片,整张地图被切成256×256像素的PNG或JPG。瓦片的编号规则比想象中直白:z代表缩放层级,x和y代表在该层级下的列号和行号,从左上角开始数,向右向下递增。
这里有个容易忽略的点:层级每加1,瓦片数量翻4倍。0级是1张,1级变4张,2级变16张。这个指数增长在离线场景下是致命的。我曾经为了把某个城市的15到18级全量切下来,算过一笔账——15级大约是14万张,16级就是56万张,17级直接跳到224万张。如果你没有规划好范围,本地硬盘和加载速度都会被教做人。
瓦片的x、y范围与坐标系绑定,Web墨卡托投影(EPSG:3857)下,x和y范围都是0到2^z-1。但Leaflet的默认坐标原点跟瓦片编号坐标不同,它用[-85, -85, 85, 85]做初始边界,实践中最直接的感受就是:传瓦片URL模板时,x/y/z三个占位符必须按规则传,传错一个整层灰屏。
| 层级 | 世界瓦片总数 | 单层文件大小估算(256×256 PNG,约40~100KB/张) |
|---|---|---|
| 10 | 约104万 | 40~100GB |
| 12 | 约1677万 | 670GB~1.6TB |
| 15 | 约14.3亿 | 5.7TB~14.3TB |
所以demo合集里凡是涉及范围选择的,我都做了最大层级限制。这个限制不是拍脑袋,是为了控制文件总量在你磁盘和浏览器可承受的范围内。
2.2 demo合集里藏着哪些可复用的工程模块:从目录看全貌
解开rar之后,目录结构大概是下面这个样子:
leaflet-offline/ ├── index.html ├── js/ │ ├── leaflet.js │ ├── leaflet.draw.js │ └── map-config.js ├── css/ │ ├── leaflet.css │ └── main.css ├── tiles/ │ ├── osm/ # 离线瓦片目录,按 z/x/y 三级存放 │ │ ├── 10/ │ │ ├── 11/ │ │ └── 12/ ├── demos/ │ ├── 01-basemap-load.html │ ├── 02-marker-popup.html │ ├── 03-polygon-draw.html │ ├── 04-measure-tool.html │ ├── 05-layer-control.html │ ├── 06-geojson-import.html │ ├── 07-cluster-markers.html │ ├── 08-heatmap.html │ ├── 09-tile-load-tester.html │ └── 10-offline-map-init.html └── tools/ ├── tile-downloader.py └── merge-tiles.py比如04-measure-tool.html演示的是量测工具,03-polygon-draw.html是绘制多边形,07-cluster-markers.html是点聚合,09-tile-load-tester.html是排错利器,能逐块检测本地瓦片是否在正确路径。我实际接项目时,基本是复制某个demo页面,改改中心坐标和图层配置就上线了。
2.3 坐标系选错,离线地图永远对不上:两个坐标系的血泪账
这大概是我掉的第一个大坑。项目方给了一批设备坐标点是GCJ-02(国测局火星坐标),我拿EPSG:3857的标准瓦片去套,结果所有点位偏移了大概几十到几百米。原因很简单:Leaflet默认用的是WGS84经纬度来做地图初始化,但瓦片服务的投影是Web墨卡托。这两者之间的换算,Leaflet内部会做,但如果你从第三方导入GeoJSON数据,数据本身的坐标系得你自己保证。
demo合集里专门写了一个坐标转换模块,支持WGS84、GCJ-02、BD-09三者互转。核心代码如下:
// 坐标转换模块:WGS84 <-> GCJ02 <-> BD09 function wgs84ToGcj02(lng, lat) { if (isOutsideChina(lng, lat)) return [lng, lat]; // 不在国内范围不做偏移 const a = 6378245.0; const ee = 0.006693421622965943; let dLat = transformLat(lng - 105.0, lat - 35.0); let dLng = transformLng(lng - 105.0, lat - 35.0); const radLat = (lat / 180.0) * Math.PI; let magic = Math.sin(radLat); magic = 1 - ee * magic * magic; const sqrtMagic = Math.sqrt(magic); dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI); dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; } function gcj02ToWgs84(lng, lat) { const gcj = wgs84ToGcj02(lng, lat); return [lng * 2 - gcj[0], lat * 2 - gcj[1]]; }逻辑说明:wgs84ToGcj02接受WGS84的经纬度,输出GCJ-02加密后的坐标;gcj02ToWgs84则反过来做近似反算。实际项目中,我通常先用WGS84做底图,需要叠加第三方数据时再按源坐标系调用对应转换。
参数说明:a是克拉索夫斯基椭球长半轴,ee是偏心率平方,这两者是国内坐标系转换算法里的标准常量。isOutsideChina是个简单的边界判断,超出国内经纬度范围直接返回原值,避免对无关区域做无意义的偏移计算。
3. 把在线图源变成离线瓦片:下载与切割的工程化流程
3.1 常见瓦片获取方式与适用边界:爬图、工具与切片
离线瓦片不会自己长出来,你得先从有网络的环境把需要的瓦片“搬”到本地。常见做法有三种:用现成的地图下载器、自己写爬虫脚本、或者用GIS工具切割本地矢量/影像数据成瓦片。
第一种省事但定制能力差,第二种适合精确控制范围和层级,第三种适合手头有原始栅格数据的情况。demo合集里tools目录下放了一个我常用的下载器脚本,基于requests和concurrent.futures并发下载,支持断点续传。
import os import math import requests from concurrent.futures import ThreadPoolExecutor, as_completed def lonlat_to_tile(lon, lat, zoom): """经纬度转瓦片坐标""" lat_rad = math.radians(lat) n = 2.0 ** zoom x = int((lon + 180.0) / 360.0 * n) y = int((1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n) return x, y def download_tile(z, x, y, base_url, save_root): """下载单张瓦片并保存到本地 z/x/y 目录""" path = os.path.join(save_root, str(z), str(x)) os.makedirs(path, exist_ok=True) filename = os.path.join(path, f"{y}.png") if os.path.exists(filename) and os.path.getsize(filename) > 0: return True # 已存在且非空,跳过 url = base_url.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y)) headers = {"User-Agent": "Mozilla/5.0 (tile-downloader)"} try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: with open(filename, "wb") as f: f.write(resp.content) return True else: print(f"HTTP {resp.status_code}: z={z} x={x} y={y}") return False except Exception as e: print(f"Error: {e} url={url}") return False逻辑说明:脚本把经纬度范围按当前层级换算成瓦片行列号范围,然后逐块请求URL模板中的{ z }、{ x }、{ y }占位符。download_tile先检查文件是否已经存在且非空,满足条件就直接跳过,这是断点续传的基础。
参数说明:zoom决定瓦片层级,建议先跑通一个小范围、低层级(如z=10),确认图源没封你IP之后再加并发。base_url要用标准瓦片URL模板,Leaflet的L.tileLayer能接受同样的字符串格式。timeout=10是超时控制,避免某张瓦片卡死整个队列。
爬取的真谛是控制并发。ThreadPoolExecutor的线程数我一般设置在4~8之间。太快会被对方服务端限流甚至拉黑,太慢又等得着急。18级全量的乌龟工程我已经不想再回忆了——那个下午我深刻理解了什么叫“耐心是美德”。
3.2 瓦片质量把控:文件数校验、日志与重试
你以为下完了就完了?天真。瓦片下载最心虚的地方在于:到底下全了没有。有两类问题很恶心:一是HTTP 200但返回的是错误页(写成了PNG后缀的HTML),二是网络抖动导致文件只有几KB,半张图都在马赛克里。前者排查起来真的让人头大。
我从实践中总结出一个土办法。先把预期瓦片数算出来,再跟本地实际文件数对账。
| 校验项 | 方法 | 判断标准 |
|---|---|---|
| 数量对账 | find tiles -name "*.png" | wc -l | 与实际预期数量误差不超过1% |
| 文件大小 | find tiles -size -1k | 小于1KB的瓦片大概率是错误响应 |
| 随机抽样 | 打开10~20个不同层级的瓦片 | 肉眼观察是否完整渲染 |
计算逻辑很简单:某个范围在某一层级的瓦片总数等于(max_x - min_x + 1) * (max_y - min_y + 1)。我在下载器脚本里加了一个--verify参数跑完自动统计。
def expected_tile_count(min_lon, min_lat, max_lon, max_lat, zoom): """统计给定经纬度范围在指定层级的瓦片总数""" min_x, min_y = lonlat_to_tile(min_lon, min_lat, zoom) max_x, max_y = lonlat_to_tile(max_lon, max_lat, zoom) return (max_x - min_x + 1) * (max_y - min_y + 1) def verify_download(save_root, zoom, expected_count): """实际统计本地文件数并比对""" actual_count = 0 for root, dirs, files in os.walk(os.path.join(save_root, str(zoom))): actual_count += len([f for f in files if f.endswith(".png")]) print(f"Expected: {expected_count}, Actual: {actual_count}") return abs(actual_count - expected_count) / expected_count < 0.01参数说明:min_lon, min_lat, max_lon, max_lat定义的是你要下载的矩形范围,西经南纬用负数。zoom不宜超过18,否则文件总量会失控。0.01是容差率,我一般允许丢1%以下,超过就直接重新跑缺失清单。
3.3 Leaflet加载本地瓦片的最小改动:在线改离线的核心代码
在线地图切到离线,代码层面其实改动很小。核心就是把tileLayer的URL从https://...换成你本地服务器上的相对路径。
// 在线图源:L.tileLayer('https://{s}.tile.osm.org/{z}/{x}/{y}.png') // 离线图源: const offlineLayer = L.tileLayer("tiles/{z}/{x}/{y}.png", { minZoom: 10, maxZoom: 15, tileSize: 256, errorTileUrl: "tiles/blank.png", // 缺失瓦片的占位图 });逻辑说明:tiles/{z}/{x}/{y}.png会相对当前页面路径去请求瓦片。errorTileUrl是Leaflet加载404瓦片时替换显示的兜底图,建议放一张浅灰色纯色PNG,避免整块地图出现刺眼的红叉或空白坑。
参数说明:minZoom和maxZoom必须跟本地瓦片存在的层级一致,超出范围会出大窟窿。tileSize除非你的瓦片是512规格,否则保持默认256。还有一个小细节——如果你要用L.tileLayer加载本地瓦片,crossOrigin参数不需要设置;但如果做瓦片像素级处理或Canvas叠加,就必须带上crossOrigin: true,否则Canvas会被“污染”导致toDataURL报错。
4. 把demo跑起来的完整操作流:一个内网地图页面的落地
4.1 本地起一个静态服务:为什么不能用file://直接打开
拿到demo合集第一步不是双击HTML,而是起一个本地静态服务器。很多新手一上来就双击index.html,结果地图是灰的,控制台飘红一片CORS错误。原因在于浏览器安全机制——file://协议下,页面去请求同目录的JSON、瓦片文件都可能被拦截,尤其是Leaflet加载GeoJSON、离线瓦片目录时,相对路径解析也会出问题。
我一般用Python的http.server,或者Node的live-server。这里给出最省事的方式:
cd leaflet-offline python3 -m http.server 8080然后浏览器打开http://localhost:8080。
参数说明:端口8080可以随便换,只要不跟现有服务冲突。cd到项目根目录是为了让相对路径正确解析。用http.server的好处是零依赖,Python 3自带;如果要看请求详情,加--bind 0.0.0.0还能让局域网内其他设备访问。
4.2 写一个可用的离线地图页面:从初始化到图层加载
最基础的离线地图页面结构比想象中简单:一个div容器,一段init代码。下面是demo合集里10-offline-map-init.html的核心代码,我加了注释方便贴到你的项目里直接用。
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="css/leaflet.css" /> <style> #map { width: 100%; height: 600px; } </style> </head> <body> <div id="map"></div> <script src="js/leaflet.js"></script> <script> // 初始化地图,中心点取本地瓦片覆盖范围的中心坐标 const map = L.map("map", { center: [31.23, 121.47], zoom: 12, minZoom: 10, maxZoom: 15, zoomControl: true, }); // 加载离线瓦片图层 const baseLayer = L.tileLayer("tiles/{z}/{x}/{y}.png", { minZoom: 10, maxZoom: 15, tileSize: 256, errorTileUrl: "tiles/blank.png", }).addTo(map); // 缩放时保持中心点不变,避免跳图 map.setView([31.23, 121.47], 12, { animate: false }); </script> </body> </html>逻辑说明:L.map创建地图实例,center是加载时视角中心,zoom是初始层级。L.tileLayer(...).addTo(map)把离线瓦片作为底图挂到地图上。setView强制设定视图,防止浏览器记住上次的位置状态导致加载空白区。
参数说明:center和zoom必须取在离线瓦片实际覆盖范围内,否则会显示空白底图。minZoom/maxZoom这个组合我在前面说过,必须跟瓦片目录层级契合。animate: false在项目上线时建议打开,减少动画导致的连续请求闪烁。
4.3 demo合集中的高级用法:标绘、量测与GeoJSON导入
跑通了底图,再往上加交互功能就是水到渠成的事。demo合集的路径就是按难度排的:先标点弹窗,再画线画多边形,最后接GeoJSON导入和热力图。这些功能背后的核心依赖是三个插件:leaflet.draw、leaflet-measure和内置的L.geoJSON。
标绘功能的关键在于引入插件后初始化Draw控件:
// 引入draw插件后,在map上添加绘制工具栏 const drawControl = new L.Control.Draw({ position: "topright", draw: { polygon: true, polyline: true, rectangle: true, circle: true, marker: true, }, edit: { featureGroup: drawnItems }, }); map.addControl(drawControl); map.on(L.Draw.Event.CREATED, function (event) { const layer = event.layer; drawnItems.addLayer(layer); });逻辑说明:L.Control.Draw配置允许绘制的图形类型,featureGroup是承载绘图形状的图层组。CREATED事件触发时把新绘制的图层加入组中,这样编辑和删除功能才能作用于它。
参数说明:position控制工具按钮位置,常用"topright"或"topleft"。draw对象里开关对应图形按钮,不需要的功能直接设为false。featureGroup必须提前创建并addTo(map),否则编辑态无法选中图形。
GeoJSON导入就更直接了。只需要调用:
L.geoJSON(geojsonData, { style: function (feature) { return { color: "#ff7800", weight: 2 }; }, onEachFeature: function (feature, layer) { layer.bindPopup(feature.properties.name); }, }).addTo(map);参数说明:geojsonData是符合GeoJSON规范的对象,可以是本地JSON文件加载或接口请求返回。style回调返回的样式对象控制边框颜色和粗细。onEachFeature逐要素绑定弹窗内容,feature.properties.name换成你的数据字段即可。
5. 离线地图的常见问题排查:五个翻车现场还原
5.1 地图是灰的:瓦片路径与404检查
现象:页面能打开,地图容器有网格状灰色背景,但没有任何瓦片渲染,控制台里全是404状态。
原因:瓦片URL路径写错,最常见的是层级z/x/y顺序反了,或者相对路径的基准不对。另一个高频原因是瓦片目录放置的位置与HTML页面不在同一层。
解决:打开浏览器的网络面板,筛选img请求,看实际请求的URL长什么样。比如页面在http://localhost:8080/,瓦片在tiles/目录下,而HTML在demo/子目录里,相对路径tiles/...就会解析成http://localhost:8080/demo/tiles/...,直接404。把瓦片URL改成绝对路径/tiles/{z}/{x}/{y}.png,或者把demo页面移到项目根目录即可。我还会用09-tile-load-tester.html这个demo逐个层级测试,能快速定位是从哪个层级开始断的。
5.2 点位与底图对不上:坐标系或缩放层级错位
现象:标注点和线图层都在,但整体偏移了几百米,放大地图后偏移更明显。手头的数据是经纬度坐标,底图瓦片看起来也是本地的,为什么就是错位?
原因:数据的坐标系跟瓦片坐标系不一致。最典型的是把GCJ-02(火星坐标)的数据直接扔到WGS84标准的瓦片上。还有一种情况是数据的坐标没问题,但你初始化地图时center和zoom与数据所在区域偏差很大,Leaflet在中间层级做了插值重投影,导致视觉偏差。
解决:先确认坐标基准。向数据提供方问清楚三个问题:是什么坐标系、什么投影、什么基准面。如果是GCJ-02,用demo合集里的坐标转换模块转成WGS84再渲染。如果是偏移问题,还可以用L.marker的setLatLng手动对比一个已知点位,微调差值并统一修正。
5.3 瓦片出现缺角或模糊:级别不足或源站缺块
现象:地图大部分正常,但个别区域放大到特定级别后成马赛克状,有些瓦片干脆空白。
原因:下载瓦片时对应层级的某块区域的确不存在。可能是图源服务本身在该级别未覆盖,也可能是下载脚本被源站限流导致部分瓦片没有成功保存,还有可能是被跳过的小文件(比如HTTP 200但返回空内容)。马赛克一旦出现,内存里的残片会让地图看起来像故意的抽象艺术。
解决:重新运行下载器并开启--verify模式,检测缺失清单后只补下确实的文件。对缺失的范围,如果同行层级没有,可以考虑降低缩放级别来兜底——Leaflet支持跨层级混用,但需要你自己做好替换策略。还有一个实用习惯:下载时我一般按zoom/行号/列号三级分层,检查目录的时候能快速定位哪一行哪一列断了。
5.4 控制台报跨域错:CORS与服务端配置
现象:Access to XMLHttpRequest at 'file:///...' from origin 'null' has been blocked by CORS policy,GeoJSON加载失败,或Canvas操作时提示“被污染”。
原因:跨域策略限制。file://协议下origin是null,所有本地文件的XHR请求都可能被判定为跨域。另一种情况是你的瓦片服务器(比如Nginx或IIS)没有加Access-Control-Allow-Origin: *响应头。
解决:本地开发时不要用file协议,起一个静态服务器(见4.1)。内网部署时,在服务端配置允许跨域。Nginx示例如下:
location /tiles/ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, OPTIONS"; }配置说明:关键是给瓦片目录加上CORS头。如果Leaflet页面和瓦片服务在同一个站点下,就不会触发跨域,所以我推荐把页面和瓦片放到同一个静态服务器目录下,省掉这一层麻烦。
5.5 大范围高并发加载卡死:内存与浏览器限制
现象:当地图范围拉大时,浏览器连续请求几百张瓦片,页面掉帧甚至崩溃,DevTools的Network面板里全是pending状态。
原因:Leaflet默认同时向每个TileLayer发出多个请求,瓦片量太大时内存开销飙升。另外,如果瓦片文件都是几MB的大图,渲染线程会被阻塞。有些人图省事把多级瓦片直接合并成大图,那更是把性能往死里逼。
解决:给L.tileLayer增加keepBuffer和updateWhenIdle配置。keepBuffer控制视口外预先加载的瓦片层数,updateWhenIdle让地图静止后才加载新瓦片,拖动过程中不抢资源。同时把瓦片格式统一压成压缩率高的WebP或质量适中的JPG,能在观感和大小之间平衡。生产环境我通常再加一层瓦片服务器(Nginx映射本地目录),配置好缓存,浏览器请求压力会小很多。
6. 预取、拼接与性能监控:离线地图的收尾动作
6.1 两种验证瓦片加载状态的方法
地图跑起来并不意味着瓦片都加载对。我习惯用两个方式做最终确认。第一个是控制台数请求,Network面板按Img筛选,看状态码为200的数量是否占绝大多数;第二个更实用,在tileLayer的tileload事件里做计数,实时确认当前视图内的瓦片是否全部成功加载。
let loadedCount = 0; let errorCount = 0; baseLayer.on("tileload", function () { loadedCount++; console.log("已加载瓦片数:", loadedCount); }); baseLayer.on("tileerror", function () { errorCount++; console.warn("加载失败数:", errorCount); });参数说明:tileload事件在每张瓦片加载完成时触发,tileerror在加载失败时触发。在项目调试阶段,我会把这个计数显示在页面角落,内网演示时再隐藏。这个方法能直接暴露5.1节的404问题和5.3节的缺块问题。
6.2 预取指定范围瓦片与空白边缝处理
离线地图还有一个小坑:地图拖出已下载范围时,Leaflet会尝试加载不存在的瓦片,显示成白底或灰底。我做的处理是提前预取周边一定缓冲范围的瓦片。具体做法是在下载器里把经纬度范围向外扩一个层级或固定值。这个需求在下载器脚本里加两个参数就能实现:
def expand_bounds(min_lon, min_lat, max_lon, max_lat, expansion_ratio=0.01): """按比例扩大下载范围,减少运行时越界""" lon_span = max_lon - min_lon lat_span = max_lat - min_lat return ( min_lon - lon_span * expansion_ratio, min_lat - lat_span * expansion_ratio, max_lon + lon_span * expansion_ratio, max_lat + lat_span * expansion_ratio, )参数说明:expansion_ratio是扩展比例,0.01代表上下左右各扩展1%的经度/纬度跨度。这个扩展比例对绝大多数场景够用,拖出范围的体验会好很多。如果瓦片实在缺失,就用6.1里的tileerror监听,当错误率超过阈值时弹一个“当前区域暂无地图数据”的提示,比白屏体面得多。
6.3 我现在的固定流程:先测后用的习惯
这套东西现在成了我在内网离线项目里的固定工序。拿到一台部署机器,先看磁盘空间和瓦片量级,再规划范围和层级;下载完先校验数量和文件大小,再起本地静态服务过一遍demo;上线前用tileload计数实测整屏加载时间,确认没有明显卡顿才交出去。那次踩完GCJ-02的坑之后,我每次接第三方数据都会先问一句“坐标系是什么”,再也不想靠肉眼去猜偏移量。
离线地图并不复杂,就是一层瓦片加几个图层的事。把瓦片备好、坐标系捋清、性能把握好,它就能在内网里稳定跑很久。希望这份demo合集能帮你少走点弯路。
本文还有配套的精品资源,点击获取