简介:一套基于 mapbox-gl.js v2.13.0 的地图扩展资源,面向需要加载 4326、3857、4490 坐标系切片服务并快速实现矢量绘图的 Web GIS 开发者。资源在原生 Mapbox GL 基础上补齐多坐标系切片适配,关闭 token 请求,降低本地化及私有化部署门槛;绘图控件支持点、线、面、矩形、圆、编辑、删除等操作,可满足业务化标注与简单编辑需求。压缩包共 11 个文件,主体为 6 个 JS 脚本与 2 个 CSS 样式,分别承担地图初始化、坐标系转换、绘图交互及样式控制;另含 2 个 HTML 示例页面和 1 个 JSON 数据文件,便于直接运行与对照调试。包体仅约 601KB,轻量精炼,已有 2254 人学习下载。对矢量切片流程复杂、原始数据分层与样式设计耗时的问题,资源通过完整示例展示了从切片加载到前端可视化的落地路径,适合中高级前端或 GIS 工程师作为扩展参考与二次开发基底。 接手过一个数据展示项目,需要把我单位发布的瓦片服务接进一个已有的 Web 地图前端,前端用的是 mapbox-gl.js v2.13.0。服务端数据来自测绘部门,交付的时候只写了句"CGCS2000 坐标系"。我兴冲冲把切片地址填进addSource,一打开,地图上瓦片错位得乱七八糟,有的地方还出现重影。排查了半天,最后定位到问题:瓦片服务的坐标网格是 4490,而 mapbox-gl 内部只认 3857,两者投影方式不同,瓦片网格对不上。
这次踩坑让我把 4326、3857、4490 这三套坐标系在切片服务里的关系彻底捋了一遍,也把mapbox-gl-draw绘图控件一起集成进去了。下面把这套完整思路和代码分享出来,希望能帮你少走弯路。
1. 坐标系三兄弟与切片服务的投影陷阱
1.1 4326、3857、4490 分别是什么
先快速过一遍这三个坐标系,很多刚接触 GIS 的人经常被它们绕晕。
- EPSG:4326:WGS84 地理坐标系,用经纬度表达位置,单位是度。GPS 直接输出的就是这种坐标,也是 GeoJSON 标准要求使用的坐标系。
- EPSG:3857:Web 墨卡托投影坐标系,单位是米。它把全球地图近似成一个正方形,方便切片,几乎所有在线底图(比如 高德、OpenStreetMap 的 Web 端)都用它。google maps 和 mapbox-gl 默认也采用这套投影。
- EPSG:4490:CGCS2000 地理坐标系,同样用经纬度表达,是中国国家大地坐标系的椭球面基准,跟 4326 的经纬度数值在大多数应用场景下非常接近,但椭球参数有细微差异,高精度测绘场景必须区分。
还有一个常见的是 CGCS2000 投影坐标,比如高斯-克吕格投影(带号 3度带、6度带),但标题里提到的切片服务主要涉及 4326、3857、4490 这三种。经纬度坐标系适合做小比例尺展示,投影坐标系适合做面积量算、大比例尺制图。
1.2 切片服务与坐标系的绑定关系
切片地图服务本身不关心你怎么投影,它只定义了一套瓦片行列号到地理范围的换算规则。
常见的切片规则有三种:
- XYZ:绝大多数在线地图使用,原点在左上角,y 轴向下。缩放级别 z 对应的全球切片数量是 2^z × 2^z,第 0 级就是一张 256×256 的全球图。这里的全球范围默认是 EPSG:3857 的世界范围(约 -20037508.34 到 20037508.34)。
- TMS:原点在左下角,y 轴向上,部分开源 GIS 服务默认使用。
- WMTS:OGC 标准瓦片服务,可配置多个 TileMatrixSet(瓦片矩阵集),比如 EPSG:3857、EPSG:4326、EPSG:4490 都可以作为矩阵集。每一个矩阵集都定义了原点、分辨率、瓦片大小和行列号范围。
所以,同样是加载一张 256×256 瓦片,请求 3857 规则和请求 4490 规则,背后的地理范围完全不同。如果不做处理,直接把 4490 的瓦片塞到 mapbox-gl 里,mapbox-gl 会默认按 3857 网格去请求和摆放,结果就是错位、缝隙、重叠。
1.3 mapbox-gl 为什么只认 3857
mapbox-gl.js 引擎在底层用 WebGL 渲染,相机投影是 Web 墨卡托,所有将经纬度坐标转成屏幕坐标的计算都基于 3857 的世界坐标。虽然 mapbox-gl v2.13.0 通过setProjection支持了其他投影(比如球面墨卡托、地固坐标系),但它的瓦片加载机制仍然以 3857 瓦片网格为默认坐标系。对大多数业务来说,如果你要加载的切片服务不是 3857 网格,要么在服务端转换成 3857 输出,要么在客户端做额外处理,否则 mapbox-gl 没法直接正确显示。
2. 多坐标系切片加载的方案选型:数据预处理、服务端重投影与前端重投影
搞懂了问题根源,解决方案无非三条路线。我实际项目里都调研过,各有适用场景。
| 方案 | 实现位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| A. 离线预处理转切片 | 数据入库/切片时 | 性能最好,前端零负担 | 数据更新需要重新切片 | 数据不变或低频更新,标准发布流程 |
| B. 地图服务实时重投影 | GIS服务器(GeoServer等) | 支持动态数据,一个服务多投影输出 | 服务器有计算开销,并发高时可能变慢 | 数据频繁更新,需要快速发布 |
| C. 前端实时重投影 | 浏览器 | 不需要动服务器 | 计算量大,瓦片有变形缝隙 | 简单测试、临时查看,不推荐生产 |
2.1 方案A:离线预处理转切片
如果你的数据是静态的,比如某年某地的国土调查斑块切片,最推荐的做法是用gdal2tiles.py或 ArcGIS Pro 切片包,直接生成 EPSG:3857 的切片。这样 mapbox-gl 拉取的就是标准 XYZ 瓦片,完全不用考虑坐标系问题。
gdal2tiles.py -p mercator -z 0-18 -w none -s EPSG:4490 source.tif output_tiles/这里有两点要注意:
-s EPSG:4490指定输入数据坐标系,-p mercator表示输出为 Web 墨卡托切片。gdal 会自动把源重投影到 3857。- 如果源数据是 CGCS2000 的三度带投影,建议先用
gdalwarp转换到 4490 或 3857,再切片,避免直接切三维带投影造成边缘错位。
2.2 方案B:GeoServer 实时重投影
如果数据要频繁更新,或者你不想维护两套切片,用 GeoServer 这类 GIS 服务可以同时输出多种坐标系的 WMTS。只要在发布图层时允许默认EPSG:3857输出,前端就能用标准 XYZ/WMTS 地址拉了。
具体配置里,重点确认 WMS 的srs和 WMTS 的TileMatrixSet。比如 GeoServer 的 WMTS 地址可以长这样:
https://your-gis-server/geoserver/gwc/service/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=yourworkspace:yourlayer&STYLE=&TILEMATRIX=EPSG:3857:{z}&TILEMATRIXSET=EPSG:3857&FORMAT=image/png&TILECOL={x}&TILEROW={y}当原始数据是 4490 时,GeoServer 会内部判断,请求输出投影是 3857,则在瓦片输出前做实时重投影。前端代码完全不用区分数据源到底是 4326、3857 还是 4490。
2.3 方案C:前端实时重投影,为什么不推荐
理论上可以通过transformRequest拦截 mapbox-gl 的瓦片请求,用 proj4 把 3857 瓦片坐标反算回源坐标系瓦片坐标,请求到源瓦片后再用 Canvas 重投影贴到 WebGL 纹理上。但这样做有几个致命问题:
- 3857 和 4490 的瓦片网格只在经线方向和极地附近没有简单整数对应关系,强行换算会导致瓦片内容变形、重叠或露白。
- 浏览器端性能瓶颈明显,尤其是在大范围高缩放级别下,用户拖动时会频繁重投影,非常卡。
- 实现复杂度高,调试成本大,产出并不比服务端重投影好。
所以我的结论很直接:生产环境优先方案A或B,前端只负责按 3857 标准请求瓦片。
3. 实战代码:mapbox-gl v2.13.0 加载 4326/3857/4490 切片服务
3.1 统一封装一个切片源加载函数
先封装一个addTileLayer函数,把底图源和图层一起加到 map 上。无论后端切片实际原始坐标系是哪个,只要服务端输出的是 3857 网格,前端就不需要感知投影差异。
function addTileLayer(map, options) { const { id = 'tile-layer', url, minZoom = 0, maxZoom = 20, opacity = 1, tileSize = 256, attribution = '' } = options; if (!map.getSource(id)) { map.addSource(id, { type: 'raster', tiles: [url], tileSize, minzoom: minZoom, maxzoom: maxZoom, attribution }); } if (!map.getLayer(id)) { map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': opacity, 'raster-fade-duration': 100 } }); } }使用方式很简单:
mapboxgl.accessToken = 'YOUR_TOKEN'; const map = new mapboxgl.Map({ container: 'map', style: { version: 8, sources: {}, layers: [] }, center: [116.39, 39.9], zoom: 10 }); // 加载 3857 的在线底图 addTileLayer(map, { id: 'osm', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', maxZoom: 19 }); // 加载由 GeoServer 发布的、原始数据可能为 4490 的 WMTS 服务 addTileLayer(map, { id: 'cgcs2000-layer', url: 'https://your-gis-server/geoserver/gwc/service/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=yourworkspace:yourlayer&STYLE=&TILEMATRIX=EPSG:3857:{z}&TILEMATRIXSET=EPSG:3857&FORMAT=image/png&TILECOL={x}&TILEROW={y}', minZoom: 0, maxZoom: 18 });这里的关键是,WMTS 地址里的TILEMATRIXSET=EPSG:3857,它告诉 GeoServer 前端需要的是 3857 网格的瓦片。即使原始数据是 4490 或 4326,GeoServer 都会自动完成投影转换。
3.2 如果只有一个非 3857 的静态图片或栅格文件怎么办
有时候你拿不到 GeoServer,只有一个打包好的 4490 TMS 切片目录。这时候可以先用gdal2tiles或 Python GDAL 脚本批量转成 3857。比如用 Python 转换:
from osgeo import gdal, gdalconst src = gdal.Open('input_4490.tif') dst = gdal.Warp('output_3857.tif', src, dstSRS='EPSG:3857', resampleAlg=gdalconst.GRA_Bilinear) dst = None然后再用gdal2tiles.py -p mercator切片,前端加载方式不变。
3.3 多坐标系同时加载的图层切换
如果同一个项目里要加载多套坐标系来源的底图,比如一套 4326 的影像,一套 4490 的矢量切片,可以维护一个图层配置数组,然后通过setLayoutProperty切换可见性。
const layerConfigs = [ { id: 'basemap-4326', url: 'https://a.example.com/tiles4326/{z}/{x}/{y}.png', title: 'WGS84影像' }, { id: 'basemap-3857', url: 'https://b.example.com/tiles3857/{z}/{x}/{y}.png', title: 'Web墨卡托底图' }, { id: 'basemap-4490', url: 'https://c.example.com/geoserver/gwc/service/wmts?...EPSG:3857...', title: 'CGCS2000成果图' } ]; layerConfigs.forEach(cfg => addTileLayer(map, cfg)); // 切换显示 function switchBasemap(activeId) { layerConfigs.forEach(cfg => { map.setLayoutProperty(cfg.id, 'visibility', cfg.id === activeId ? 'visible' : 'none'); }); }注意,由于最终前端请求的都是 3857 网格,所以 4326 和 4490 底图本质上都是经过服务端或预处理转换成 3857 后再显示的,前端不需要另外传坐标系参数。
3.4 带 Token 或签名的切片服务请求
自建切片服务经常需要签名认证。mapbox-gl 的transformRequest可以统一改请求头或 URL 参数,这是一个容易踩坑也容易被忽略的功能。
map.transformRequest = (url, resourceType) => { if (resourceType === 'Tile' && url.includes('your-gis-server')) { return { url: url + (url.includes('?') ? '&' : '?') + 'token=' + getToken(), headers: { 'Authorization': 'Bearer ' + getToken() } }; } return { url }; };要留意的是,transformRequest必须返回{url}对象,不能只返回字符串,而且如果改了 URL,要保证不要循环调用。这个函数在 v2.13.0 里同样有效。
4. 叠加绘图控件:mapbox-gl-draw 集成与坐标保存
地图能正常显示了,下一步就是用户画个点、画条线、圈个地块。这里我用的是官方生态里最成熟的开源组件@mapbox/mapbox-gl-draw,在 v1.3.0 上与 mapbox-gl v2.13.0 兼容性比较好。
4.1 安装和初始化
npm install @mapbox/mapbox-gl-draw@1.3.0在代码里引入样式和类:
import mapboxgl from 'mapbox-gl'; import mapboxglDraw from '@mapbox/mapbox-gl-draw'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; const draw = new mapboxglDraw({ displayControlsDefault: false, controls: { point: true, line_string: true, polygon: true, trash: true }, defaultMode: 'simple_select', styles: [ // 这里可以自定义绘制样式,默认样式也够用 ] }); map.addControl(draw);默认情况下,绘图控件在左上角显示:
- 一个蓝色点按钮,进入画点模式;
- 一条斜线按钮,画线;
- 一个多边形按钮,画面;
- 垃圾桶,删除选中要素。
4.2 监听绘制事件并保存数据
地图中绘制出来的要素是 GeoJSON,坐标默认是 WGS84 经纬度(EPSG:4326)。但我们的业务数据库可能要求存 CGCS2000(EPSG:4490)。虽然两者数值差异很小,但不做转换,在高精度应用里会有十几厘米到几十厘米的偏差。这里用 proj4js 来转换:
npm install proj4import proj4 from 'proj4'; // 定义 CGCS2000 经纬度坐标系(EPSG:4490)与 WGS84 的近似关系 // 4490 和 4326 都是经纬度,椭球参数不同,但常用情况下可近似认为一致 // 如果测绘要求严格,需要使用七参数或格网改正,这里演示简单转换 proj4.defs('EPSG:4490', '+proj=longlat +ellps=GRS80 +no_defs'); proj4.defs('EPSG:4326', '+proj=longlat +datum=WGS84 +no_defs'); function convertToCGCS2000(geojson) { if (geojson.geometry.type === 'Point') { const [x, y] = proj4('EPSG:4326', 'EPSG:4490', geojson.geometry.coordinates); return { ...geojson, geometry: { ...geojson.geometry, coordinates: [x, y] } }; } if (geojson.geometry.type === 'LineString' || geojson.geometry.type === 'MultiPoint') { return { ...geojson, geometry: { ...geojson.geometry, coordinates: geojson.geometry.coordinates.map(([x, y]) => proj4('EPSG:4326', 'EPSG:4490', [x, y])) } }; } if (geojson.geometry.type === 'Polygon') { return { ...geojson, geometry: { ...geojson.geometry, coordinates: geojson.geometry.coordinates.map(ring => ring.map(([x, y]) => proj4('EPSG:4326', 'EPSG:4490', [x, y])) ) } }; } return geojson; } map.on('draw.create', (e) => { const feature = e.features[0]; const cgcs2000Feature = convertToCGCS2000(feature); // 发送到后端保存 saveFeature(cgcs2000Feature); }); map.on('draw.update', (e) => { const features = e.features; // 更新逻辑类似 });4.3 已有的经纬度数据如何显示到地图上
从后端拿回来的数据如果是 4490 坐标,直接传给 mapbox-gl 展示,坐标数值与 4326 几乎一样,可以直接用。但如果你拿回来的是 CGCS2000 高斯投影坐标(比如 3857 或 3度带米制坐标),需要先转换为经纬度,再用map.addSource+map.addLayer添加矢量图层:
const geojson = { type: 'FeatureCollection', features: [...features] }; map.addSource('draw-results', { type: 'geojson', data: geojson }); map.addLayer({ id: 'draw-polygons', type: 'fill', source: 'draw-results', paint: { 'fill-color': '#26a69a', 'fill-opacity': 0.4 } });这里有个细节:mapbox-gl 的 vector 图层和 draw 控件都会自动把经纬度坐标投影到 3857 渲染,所以你不需要手动做投影转换,只有数据存储时需要明确坐标系。
4.4 绘图控件的高频坑
集成 draw 过程中我遇到三个坑:
- 坑一:地图启用旋转或倾斜后,绘制线段的落点会偏。mapbox-gl 默认允许用户右键拖动旋转视角,再进入绘制模式,线上点会跟着视角变化,容易画出奇怪的弯曲效果。可在进入 draw 模式前强制重置视角:
map.on('draw.modechange', (e) => { if (e.mode === 'draw_polygon' || e.mode === 'draw_line_string' || e.mode === 'draw_point') { map.easeTo({ bearing: 0, pitch: 0 }); } });坑二:draw 的默认样式里,选中要素的点是灰色半透明,在某些底图上肉眼几乎找不到。建议把选中样式改成高亮色。
坑三:多个图层并存时,draw 新增的要素图层默认在其他图层之上,但如果底图图层的
raster-opacity不透明,draw 结果在图例上可能被遮挡。检查图层顺序,必要时用moveLayer把 draw 相关图层移到顶部。
5. 踩坑记录与加载性能优化
下面这些坑是坐标系切片加载中最高发的,也是我线上环境真实遇到过的。
5.1 坑:坐标系标签写错,服务端“自动”转换导致拼图错位
有次后端同事告诉我瓦片是 3857 的,但前端拉出来后在高纬度地区有横向错位。我用 QGIS 打开服务检查,发现该服务实际发布的是 4326 TileMatrixSet,但服务地址里通过参数伪装成了 3857。GeoServer 遇到不匹配的矩阵集会直接返回错误瓦片或者做默认转换,不会主动报错。这种情况只能用工具验证瓦片内容,比如加载一张瓦片到 QGIS 并叠加边界,跟已知参考数据对比。
排查坐标系的首要手段,是看服务的能力文档。WMTS 的GetCapabilities响应里会列出所有TileMatrixSet,比如EPSG:3857、EPSG:4326,以及 EPSG:4490 对应的TileMatrixSet。一定要确认前端请求的矩阵集和实际返回的范围一致。
5.2 坑:TMS y 轴方向与 XYZ 不一致
如果你的源切片是 TMS 规则(y 轴向上),直接按{z}/{x}/{y}请求,地图会上下颠倒。mapbox-gl 的 raster source 默认按 XYZ 规则解释{y},所以遇到 TMS 服务要自己把 y 翻转:
function tmsUrlTemplate(url) { return url.replace('{z}/{x}/{y}', (match, offset, str) => { // 这里返回一个函数不可行,实际需要构造动态URL }); }mapbox-gl 的tiles数组不支持动态计算 y,但你可以利用transformRequest在请求前把{y}替换成Math.pow(2, z) - 1 - y。类似这样:
map.transformRequest = (url, resourceType) => { if (resourceType === 'Tile' && url.includes('tms-server')) { const match = url.match(/\/tms-server\/(\d+)\/(\d+)\/(\d+)/); if (match) { const [_, z, x, y] = match.map(Number); const flipY = Math.pow(2, z) - 1 - y; url = url.replace(`/${y}`, `/${flipY}`); } } return { url, headers: {} }; };5.3 性能优化:瓦片缓存与并发控制
地图应用最怕的是拖动时白屏和卡顿。对于加载多套切片服务,有几个实测有效的优化点:
- 服务端开启瓦片缓存。GeoServer 默认有 GWC 缓存,但如果你的图层是动态投影,最好预先对常用缩放级别做种子切片(seed),不然每个用户第一次访问都实时重投影,服务器压力很大。
- 前端合理设置
maxzoom和minzoom。超出范围的缩放级别不要加载无效请求。 - 降低瓦片像素尺寸,如果你的业务数据线划简单,可以接受模糊放大的话,设置
tileSize: 512能减少一半的请求数。注意,mapbox-gl 的 raster source 会认为tileSize是 512 但实际瓦片图片是 256,这样会出问题。正确做法是保持 256,或让服务端真正输出 512 瓦片。
我实际项目里用了一套混合方案:原始矢量数据在 GeoServer 里发布为 WMS,叠加地图时用 WMTS 3857 输出,同时开启 GWC 缓存并预生成 0-16 级切片;前端加上绘制控件,数据编辑后重新生成切片缓存。整体跑下来比较平稳。
5.4 一个容易忽略的小点:地图容器尺寸和清晰度
切片加载容易看起来模糊,不一定是坐标系问题,很可能是高清屏(DPR=2)没有正确适配。mapbox-gl 默认会根据pixelRatio自动请求更高分辨率的瓦片,但很多自定义瓦片服务只提供 256px,这种情况下可以用 CSS 强制image-rendering: auto,或者缩放级别加一档来提升清晰度。如果服务端支持dpr参数,优先在 URL 里加上。
addTileLayer(map, { url: 'https://your-server/tiles/{z}/{x}/{y}@2x.png', maxZoom: 20 });这一招在影像底图上尤其有效。
最后分享两个小经验
第一,不要只用代码调试坐标系,多用 QGIS 或者 Global Mapper 拉一遍你的切片服务,肉眼确认边界是否吻合。前端显示错位很多时候不是 mapbox-gl 的问题,而是服务端配置遗漏。
第二,绘图控件保存的坐标,最终要不要转换取决于数据库精度要求。如果只是普通业务标绘,直接存 4326 经纬度就行;如果要建库用于不动产、确权、工程测量这类精度要求极高的场景,必须把坐标系明确写成 CGCS2000,并做好七参数转换配置,不能拿着 WGS84 数据直接套 4490 图层。
mapbox-gl.js v2.13.0 处理多坐标系切片,核心就是把“切片服务坐标系”和“前端渲染坐标系”剥离开:前端统一要求 3857 输出,服务端负责把 4326、4490 的数据重投影成 3857 瓦片。绘图控件负责交互和产出数据,数据的坐标系在存储层做归一化。搞明白了这个分层逻辑,再遇到任何坐标系都不会慌。
本文还有配套的精品资源,点击获取