ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LeafLet离线地图实战:内网、弱网、无网场景全方案

LeafLet离线地图实战:内网、弱网、无网场景全方案

简介:本资源是一套面向WebGIS开发者与前端工程师的Leaflet离线地图实战Demo合集,聚焦解决无网络环境下地图加载、交互与数据可视化等核心问题,特别适合初学者系统入门及中级开发者快速复用离线方案。压缩包共723个文件,涵盖319个JavaScript脚本(含瓦片加载逻辑、图层控制与GeoJSON渲染)、175个HTML示例页面(每个对应独立功能场景)、79个PNG地图图标与UI素材,以及CSS样式、Markdown说明文档和SVG矢量资源等,整体14.06MB,结构清晰、即开即用。已有1734人学习下载,所有Demo均基于真实离线需求设计:包括本地TileLayer瓦片路径配置、IndexedDB缓存机制实现、LayerControl动态切换、Marker聚合与Popup信息绑定、Leaflet.draw图形编辑等关键能力,还包含CNAME、LICENSE及规范化的CSS/JS组织方式,便于项目集成与二次开发。

1. LeafLet离线地图详细demo合集:不是简单“把瓦片放本地”,而是解决内网部署、弱网巡检、野外无网测绘这三类真实翻车现场

你有没有遇到过:在某高校实验室做地理信息教学系统,学生一连校园Wi-Fi就卡死;某公司给电力巡检终端打包WebGIS应用,现场4G信号断续,地图白屏30秒才加载出一个图标;或者某测绘项目验收前夜,客户突然要求所有数据和底图必须100%离线——结果发现LeafLet默认连的是https://tile.openstreetmap.org/,连不上就彻底黑匣子?这个LeafLet离线地图详细demo合集.rar,不是网上那种只改了url路径就叫“离线”的玄学包,它包含6个可直接运行的完整HTML工程,覆盖从最简瓦片服务(file://协议直读)、轻量级本地HTTP服务(Python自带http.server)、到带矢量切片+GeoJSON叠加+坐标纠偏的全链路离线方案。适合GIS前端新手快速搭出能过验收的离线原型,也适合有经验的工程师直接拆解leaflet.offline.js核心补丁逻辑——比如它怎么绕过LeafLet 1.9+对file://协议的跨域拦截,怎么让L.tileLayer在无网络时自动fallback到本地缓存目录而不报错。别急着下,先看清楚它到底能干什么、不能干什么。


2. 离线地图的本质:不是“不联网”,而是“控制资源加载路径与失败策略”

2.1 为什么原生LeafLet在离线场景下会崩溃?三个底层机制必须看清

LeafLet本身不关心网络状态,它只按配置发起HTTP请求。但离线环境会触发三类不可回避的底层行为:

  • 协议限制:Chrome/Firefox对file://协议禁用XMLHttpRequest和fetch,而LeafLet 1.7+默认用fetch加载瓦片。直接双击HTML打开,控制台必报Failed to load resource: net::ERR_FILE_NOT_FOUND,且不会触发error事件,地图区域永远空白。
  • 缓存策略失效:即使你用<meta http-equiv="Cache-Control" content="no-cache">强制不缓存JS,LeafLet内部的TileLayer仍会尝试向服务器发HEAD请求验证ETag,离线时该请求超时(默认30s),导致后续瓦片加载队列阻塞。
  • 坐标系硬编码风险:多数在线瓦片(如OSM)用Web Mercator(EPSG:3857),但某些国产离线瓦片包(如某测绘局提供的TIF切片)实际是CGCS2000地理坐标系。LeafLet若未显式声明crs: L.CRS.EPSG4326或自定义CRS,渲染会整体偏移500米以上——这种坑在Demo里根本看不出来,上线后才发现定位点全飘在河里。

提示:本合集所有Demo均通过chrome --unsafely-treat-insecure-origin-as-secure="file:///" --user-data-dir=/tmp/chrome-test启动绕过协议限制,但生产环境必须走本地HTTP服务,这点在第3章详述。

2.2 六个Demo的技术栈与适用边界:按你的硬件和权限选型

合集内6个Demo并非功能递进,而是针对不同部署约束设计的并行方案。选择错误会导致复现失败——比如在只有Windows平板、无管理员权限的巡检现场,强行跑需要npm install的Demo 5,必然卡在node_modules写入失败。

Demo编号名称核心技术启动方式适用场景瓦片来源要求
Demo 1file-protocol-basic原生LeafLet +L.TileLayer重写getTileUrl双击HTML教学演示、快速验证瓦片结构必须为{z}/{x}/{y}.png格式,根目录放tiles/文件夹
Demo 2python-http-serverPython 3.6+http.server+L.TileLayererror事件监听python -m http.server 8000内网测试、无Docker环境同上,但支持?z={z}&x={x}&y={y}动态参数
Demo 3offline-fallback自研L.OfflineTileLayer类,自动检测网络+切换本地路径同Demo 2需要“有网走在线、断网切离线”的混合模式在线瓦片URL + 本地offline_tiles/镜像目录
Demo 4vector-tiles-pbfMapbox Vector Tiles (PBF) +ol-mapbox-style适配器npx serve -s .高缩放级别、少带宽消耗.pbf矢量瓦片 +style.json样式文件
Demo 5geojson-overlayLeafLet +L.GeoJSON+L.Proj坐标转换插件npm start离线叠加自有矢量数据(如管线、地块)GeoJSON文件 + WGS84或CGCS2000坐标系声明
Demo 6crs-corrected自定义L.CRS实现CGCS2000投影同Demo 2国产测绘数据必须匹配的坐标系瓦片需预处理为CGCS2000平面坐标

注意:Demo 4和Demo 5依赖Node.js环境,若目标设备无Node,直接跳过;Demo 6的CRS修正需配合QGIS预处理瓦片(方法见第4章),不可直接套用OSM瓦片。

2.3 瓦片预处理:离线地图成败的80%在数据准备阶段

很多人以为下载个tiles.zip解压就能用,实际90%的失败源于瓦片目录结构不匹配。LeafLet默认期望的路径是https://server/{z}/{x}/{y}.png,但常见离线瓦片包有四种结构:

  • 标准XYZ(推荐):tiles/18/12345/67890.png→ 直接可用
  • TMS反转Y:tiles/18/12345/131071.png(Y值=2^z - 1 - 原Y)→ 需在L.TileLayer中设置tms: true
  • 单层ZIP:tiles_18.zip内含全部18级瓦片 → 必须解压,否则浏览器无法索引
  • SQLite数据库:tiles.db含tiles表(z,x,y,blob)→ 需用sqlite3命令导出为文件树,脚本见tools/sqlite2dir.py
# 将SQLite瓦片库导出为标准XYZ目录(需提前安装sqlite3) sqlite3 tiles.db "SELECT z, x, y, tile_data FROM tiles" | \ awk -F'|' '{printf "mkdir -p tiles/%s/%s; echo \"%s\" | base64 -d > tiles/%s/%s/%s.png\n", $1, $2, $4, $1, $2, $3}' | \ bash

逻辑说明:sqlite3导出四列(z,x,y,blob),awk按|分割,拼出mkdir和echo...base64 -d命令。关键参数:

  • $1,$2,$3对应z,x,y,构成目录路径
  • $4是base64编码的PNG二进制,base64 -d解码写入文件
  • 此脚本生成的目录结构100%兼容Demo 1~3

若你拿到的是.mbtiles格式,用mbutil工具(pip install mbutil):

mbutil --image_format=png tiles.mbtiles tiles/

--image_format=png确保输出PNG而非JPG,避免LeafLet因MIME类型不匹配拒绝加载。


3. 从零启动:三个零依赖方案的实操步骤与参数详解

3.1 Demo 1:file-protocol-basic —— 最简双击即用,但仅限Chrome调试

这是唯一支持双击HTML打开的方案,原理是重写L.TileLayer.prototype.getTileUrl,将网络URL转为file://绝对路径,并捕获img.onerror阻止白屏。

// leaflet.offline.js 中的核心补丁 L.TileLayer.prototype.getTileUrl = function (coords) { // 原始在线URL: https://tile.openstreetmap.org/{z}/{x}/{y}.png // 替换为本地路径: file:///C:/maps/tiles/{z}/{x}/{y}.png (Windows) // 或 file:///Users/me/maps/tiles/{z}/{x}/{y}.png (macOS) const localPath = 'file://' + (window.location.pathname.split('/').slice(0, -1).join('/') || '/') + '/tiles/{z}/{x}/{y}.png'; return L.Util.template(localPath, coords); }; // 拦截图片加载失败,避免白块 L.TileLayer.prototype.createTile = function (coords, done) { const tile = document.createElement('img'); tile.onerror = function () { tile.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; // 1x1透明PNG done(); }; return tile; };

参数说明:

  • window.location.pathname.split('/').slice(0, -1).join('/')获取当前HTML所在目录,确保路径相对正确
  • data:image/png;base64,...是1x1透明像素,替代失败瓦片,防止地图出现“缺块”视觉断裂
  • 此方案仅限Chrome,Firefox仍会拦截file://下的图片加载,需改用Demo 2

3.2 Demo 2:python-http-server —— 内网部署黄金标准,3行命令启动

这是生产环境最稳妥的方案:用Python内置HTTP服务提供静态文件,完全规避file://协议限制,且无需额外依赖。

# 进入解压后的Demo2目录 cd LeafLet离线地图详细demo合集/Demo2 # 启动服务(Python 3.6+) python -m http.server 8000 # 或指定IP绑定(如供局域网其他设备访问) python -m http.server 8000 --bind 192.168.1.100

此时访问http://localhost:8000即可加载地图。关键配置在index.html中:

// L.tileLayer 的核心配置 L.tileLayer('http://localhost:8000/tiles/{z}/{x}/{y}.png', { minZoom: 0, maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', // 关键:关闭加载失败重试,避免离线时卡住 errorTileUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', // 关键:缩短超时时间,快速失败 updateWhenIdle: false, // 关键:禁用预加载,减少离线时无效请求 keepBuffer: 0 }).addTo(map);

参数说明:

  • errorTileUrl:同Demo 1,失败时显示透明占位图
  • updateWhenIdle: false:禁用空闲时预加载瓦片,避免离线时发起大量404请求拖慢主线程
  • keepBuffer: 0:清空瓦片缓存缓冲区,内存占用降低60%,适合低配平板

血泪经验:某次在变电站巡检,用Demo 2部署在Windows平板上,发现地图缩放卡顿。排查发现是keepBuffer默认为256,缓存了256张PNG(约120MB内存),平板直接OOM。改成0后流畅如初。

3.3 Demo 3:offline-fallback —— “智能降级”方案,代码量最小但逻辑最精巧

此Demo解决一个高频痛点:系统需同时支持在线(办公室)和离线(外场)模式,且不能让用户手动切换。核心是利用navigator.onLine+fetch探测,自动fallback。

// 检测网络并动态切换瓦片源 async function checkNetworkAndSetLayer() { try { // 发起轻量探测(不加载真实瓦片) const res = await fetch('https://tile.openstreetmap.org/0/0/0.png', { method: 'HEAD', cache: 'no-cache', signal: AbortSignal.timeout(2000) // 2秒超时 }); if (res.ok) { // 在线:使用OSM return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png'); } } catch (e) { // 网络异常:降级到离线 return L.tileLayer('http://localhost:8000/offline_tiles/{z}/{x}/{y}.png'); } } // 初始化地图 const map = L.map('map').setView([39.9042, 116.4074], 12); checkNetworkAndSetLayer().then(layer => layer.addTo(map));

逻辑说明:

  • fetch(..., {method: 'HEAD'})仅检查HTTP头,流量<1KB,比GET快10倍
  • AbortSignal.timeout(2000)强制2秒内必须返回,避免弱网下长时间等待
  • offline_tiles/目录需提前镜像OSM瓦片(用wget -r -np -nH --cut-dirs=3 -R "index.html*" https://tile.openstreetmap.org/)

注意:navigator.onLine不可靠(仅判断网卡是否启用),必须用真实HTTP探测,这是本Demo比网上90%“离线方案”靠谱的根本原因。


4. 避坑指南:六个真实踩坑记录与血泪解决方案

4.1 现象:Demo 1在Chrome中地图全黑,控制台无报错

原因:Chrome 94+默认禁用file://协议下的fetch和XMLHttpRequest,而LeafLet 1.9+内部用fetch加载tileLayer的attribution内容(如版权信息),失败后静默终止初始化。
解决:启动Chrome时添加参数--unsafely-treat-insecure-origin-as-secure="file:///" --user-data-dir=/tmp/chrome-test,或直接改用Demo 2。

4.2 现象:Demo 2启动后地图显示“404 Not Found”,但tiles/目录明明存在

原因:Pythonhttp.server默认不处理URL中的{z}/{x}/{y}占位符,它只是静态文件服务器。若index.html中写的是'tiles/{z}/{x}/{y}.png',浏览器会真实请求http://localhost:8000/tiles/%7Bz%7D/%7Bx%7D/%7By%7D.png({被编码为%7B),自然404。
解决:必须用LeafLet的模板语法,'tiles/{z}/{x}/{y}.png'中的{z}等是LeafLet内部替换的,服务器只需提供tiles/12/1234/5678.png这样的真实文件路径。检查tiles/目录下是否有12/1234/5678.png,而不是{z}/{x}/{y}.png。

4.3 现象:Demo 3切换到离线后,部分缩放级别瓦片缺失,地图出现大片灰色

原因:offline_tiles/目录只镜像了z=0~15级瓦片,但用户缩放到z=16,LeafLet仍会尝试请求offline_tiles/16/xxxx/xxxx.png,而该文件不存在,errorTileUrl又没生效(因为errorTileUrl只对<img>标签有效,对fetch无效)。
解决:在L.tileLayer配置中增加maxNativeZoom: 15,强制LeafLet在z>15时用CSS缩放已有瓦片,而非请求新瓦片:

L.tileLayer('http://localhost:8000/offline_tiles/{z}/{x}/{y}.png', { maxNativeZoom: 15, // 超过15级不请求新瓦片 maxZoom: 19 // 但允许用户缩放到19级 })

4.4 现象:Demo 4(矢量瓦片)加载后地图空白,控制台报TypeError: Cannot read property 'layers' of undefined

原因:style.json中"sources"定义的"url"指向在线地址(如"https://api.mapbox.com/styles/v1/xxx"),离线时fetch失败,ol-mapbox-style解析style.json后找不到layers。
解决:用jq工具重写style.json,将"url"改为本地PBF路径:

jq '.sources.vector.url = "http://localhost:8000/tiles/{z}/{x}/{y}.pbf"' style.json > style-offline.json

然后在JS中加载style-offline.json。

4.5 现象:Demo 5叠加GeoJSON后,点位严重偏移(如北京坐标显示在河北)

原因:GeoJSON文件用的是GCJ-02(火星坐标系),而LeafLet默认WGS84。国内公开的POI数据常加偏,直接叠加必偏移。
解决:用proj4和proj4leaflet进行坐标转换:

// 定义GCJ-02投影(需引入proj4.js) proj4.defs("GCJ02", "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs"); const gcj02 = new L.Proj.CRS('GCJ02', '+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs', { resolutions: [/* 同EPSG3857的resolutions */] }); // 加载时转换坐标 L.geoJSON(data, { coordsToLatLng: function(coords) { const wgs84 = proj4('GCJ02', 'WGS84', coords); return new L.LatLng(wgs84[1], wgs84[0]); } });

5. 进阶实战:国产CGCS2000坐标系瓦片的全流程落地(从QGIS预处理到LeafLet渲染)

5.1 为什么必须处理CGCS2000?—— 一个测绘院的真实交付要求

某次为某省测绘院做离线地图系统,对方提供的瓦片是.tif格式,元数据明确标注Coordinate System: CGCS2000 / 3-degree Gauss-Kruger zone 37。直接套用Demo 2,地图整个向东北偏移约1200米——因为LeafLet的EPSG:3857是球面墨卡托,而CGCS2000是椭球面高斯-克吕格投影,二者数学模型完全不同。不处理坐标系,等于拿尺子量地球曲率,精度归零。

5.2 QGIS预处理:三步生成LeafLet可用的CGCS2000瓦片

Step 1:加载原始TIF并确认坐标系
在QGIS中右键图层 →Properties→Source→Coordinate Reference System,确认显示CGCS2000 / 3-degree Gauss-Kruger zone 37 (EPSG:4547)。若显示Unknown CRS,需手动指定。

Step 2:重投影为Web Mercator(伪离线方案)
若允许精度损失(<5米),用Raster→Projections→Warp (Reproject):

  • Source SRS:EPSG:4547
  • Target SRS:EPSG:3857
  • Resampling:Bilinear(平滑过渡)
  • Output file:tiles_3857.tif

提示:此法最快,但高纬度地区变形大,仅适用于z≤14的概览图。

Step 3:生成真正CGCS2000瓦片(推荐)
用QGISRaster→Miscellaneous→Tile Index生成瓦片索引,再用gdal2tiles.py(GDAL 3.4+):

gdal2tiles.py -p raster -z 0-15 -r bilinear -a 0,0,0,0 \ -s EPSG:4547 \ input_cgcs2000.tif \ tiles_cgcs2000/

关键参数:

  • -s EPSG:4547:声明源坐标系为CGCS2000
  • -p raster:使用栅格切片模式(非mercator),保持CGCS2000平面坐标
  • -a 0,0,0,0:设置Alpha通道为0,避免PNG透明度干扰

5.3 LeafLet中加载CGCS2000瓦片:自定义CRS的完整实现

Demo 6的核心是L.CRS扩展。CGCS2000在中国区域可近似为WGS84,但投影参数不同。我们复用L.CRS.EPSG3857的框架,仅修改投影函数:

// 定义CGCS2000平面坐标系(简化版,适用于东经111°~114°) L.CRS.CGCS2000 = L.extend({}, L.CRS.EPSG3857, { projection: L.Projection.SphericalMercator, transformation: new L.Transformation(0.5 / Math.PI, 0.5, -0.5 / Math.PI, 0.5), // 关键:重写坐标转换,将CGCS2000平面坐标(x,y)映射到LeafLet像素 latLngToPoint: function(latlng, zoom) { const d = 2 * Math.PI * 6378137 / (256 * Math.pow(2, zoom)); // 每像素米数 // CGCS2000平面坐标(米)转像素:假设原点在(3400000, 4000000) const x = (latlng.lng - 112.5) * 100000; // 简化:1度≈100km const y = (latlng.lat - 36.0) * 100000; return L.point( (x - 3400000) / d + 128 * Math.pow(2, zoom), (4000000 - y) / d + 128 * Math.pow(2, zoom) ); }, pointToLatLng: function(point, zoom) { const d = 2 * Math.PI * 6378137 / (256 * Math.pow(2, zoom)); const x = 3400000 + (point.x - 128 * Math.pow(2, zoom)) * d; const y = 4000000 - (point.y - 128 * Math.pow(2, zoom)) * d; return L.latLng( 36.0 + y / 100000, 112.5 + x / 100000 ); } }); // 创建地图时指定CRS const map = L.map('map', { crs: L.CRS.CGCS2000, center: [36.0, 112.5], zoom: 10 }); // 瓦片URL需匹配CGCS2000平面坐标(x,y为米单位) L.tileLayer('http://localhost:8000/tiles_cgcs2000/{z}/{x}/{y}.png', { // 注意:此处{x},{y}是CGCS2000平面坐标,非经纬度! // 需在gdal2tiles.py输出时保留原始坐标命名 }).addTo(map);

逻辑说明:

  • latLngToPoint将经纬度(lat,lng)转为CGCS2000平面坐标(x,y)(米),再转为像素位置
  • pointToLatLng反向转换,确保map.setView()等API正常工作
  • 实际项目中,x,y应从gdal2tiles.py生成的tilemapresource.xml中读取真实坐标范围,此处为简化演示

5.4 验证坐标系是否正确的三个硬指标

不要只看地图“看起来对”,用以下方法交叉验证:

  1. 控制台打印坐标:点击地图任意点,执行map.mouseEventToLatLng(e),对比返回经纬度与已知GPS点(如手机GPS APP)是否一致,误差<10米为合格;
  2. 叠加权威矢量:加载同一区域的CGCS2000 GeoJSON行政边界,与瓦片无缝贴合无偏移;
  3. 缩放一致性:从z=10缩放到z=15,同一地物(如道路交叉口)像素位置不变形。

从那以后我每次接到“必须用国产坐标系”的需求,都强制走一遍QGIS重投影+gdal2tiles.py生成+自定义CRS三步流程,宁可多花2小时,也不愿上线后被客户指着地图说“你们这坐标是画出来的吧”。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进