简介:针对 Web GIS 中 CGCS2000 数据源兼容难的问题,这份由 MapLibre GL JS 5.23.0 改造而来的版本面向 GIS 开发与测绘人员,可用于地理信息可视化、测绘成果展示等二维地图项目,但三维渲染存在数据变形,仅支持二维场景。压缩包共 2 个文件,整体仅 276KB,核心是一个 JavaScript 库文件与配套的 CSS 样式文件,替换到现有项目即可引入使用,无需额外依赖,部署成本很低。经实际测试,可以正常加载天地图经纬度投影瓦片、CGCS2000 坐标系矢量瓦片、栅格瓦片和 DEM 地形数据,很好地弥补了原版 MapLibre 对国内坐标系支持不足的缺陷,省去手动做投影转换与坐标系适配的繁琐工作,在一定程度上填补了 MapLibre 在本地坐标系支持上的空白。目前已有 86 人学习/下载,适合正在使用 MapLibre 并需要接入国家 CGCS2000 数据的前端地图工程师按需获取,尤其适合资源类项目快速集成。
1. Maplibre 4490版是怎么一回事,为什么测绘数据非得它不可
上个月帮同事调试一张地类图斑的 GeoJSON,属性表里明明白白写着 CGCS2000 / 3-degree Gauss-Kruger zone 40,扔进 Maplibre 里打开,要素直接落在太平洋里。这不是数据坏了,而是默认地图引擎只认 Web 墨卡托那一套 WGS84 经纬度。Maplibre 4490 版就是在官方 maplibre-gl 外面包了一层 CGCS2000 坐标系适配:能识别 EPSG:4490 的 GeoJSON、按 CGCS2000 网格切好的矢量瓦片与栅格瓦片,把投影坐标转换和瓦片网格对齐在内部处理掉。本文写给三类人:手里有 CGCS2000 测绘数据要上 Web 平台的工程师,被数据偏移折磨的自然资源、地质工作者,以及想在浏览器里直接预览 ArcGIS 或 GeoServer 输出的 4490 数据源的人。读完你能自己配通投影参数,也知道哪几步最容易翻车。
2. CGCS2000 与 EPSG:4490:先把坐标系底子打牢,再谈怎么加载
2.1 为什么常规 Web 地图的 Web 墨卡托装不下测绘原始数据
Web 地图默认的 EPSG:3857(Web 墨卡托)是以 WGS84 经纬度为输入、按伪墨卡托投影展开的世界平面。它的输入范围写死为经度 -180 到 180、纬度 -85 到 85,坐标单位是“度”。而测绘数据源里最常见的 CGCS2000 3 度带高斯-克吕格投影,坐标是几百万量级的米制横纵坐标,比如 X=536248.72、Y=3361742.85。把这种坐标直接当成经纬度喂给 3857 引擎,地图引擎会把它解释成东经 53 万度、北纬 336 万度——这已经超出有效范围好几个数量级,表现就是图形碎掉、飞到海里、甚至根本不渲染。
从根源上说,3857 是为了“全球一张图、底图连续”设计的,不是为了“局部区域毫米级精度”设计的。CGCS2000 是 2000 国家大地坐标系,它有一套完整的椭球参数、投影带划分和中央经线定义,和 WGS84 属于不同的空间基准。加载 CGCS2000 数据源,本质要解决两件事:一是把平面投影坐标(米)转成地理坐标(度),二是让瓦片请求的网格和源数据切片的网格对齐。这两件事做不好,后边全是玄学。
2.2 EPSG:4490 是地理坐标系,不是投影坐标系
很多人把 EPSG:4490 当成“CGCS2000 平面坐标”,这是个高频误用。EPSG:4490 是 CGCS2000 的地理坐标系,坐标表达是经纬度,单位是度,它对应的是“CGCS2000 椭球体 + 无投影”的状态。真正的高斯-克吕格投影平面坐标系是 EPSG:4521 到 4535 这一组(3 度带)以及 4543 到 4555 等(6 度带及扩展带),每个带号对应一条中央经线。
比如你在 ArcMap 里做 CGCS2000 坐标系转换时,选的是“CGCS2000 / 3-degree Gauss-Kruger zone 40”,对应 EPSG:4547;如果选“CM 120E”,对应 EPSG:4547 也是它。名称叫法可以有很多种,但 EPSG 编码是唯一的。前端做 Maplibre 适配时,定义 proj4 参数串一定要以 EPSG 编码为准,不要凭记忆写中央经线,否则一个带号查错,直接偏出去几百公里。
这里也有一个需要澄清的点:EPSG:4490 和 WGS84 坐标在绝大多数地区差值在一米上下,如果你只是做可视化叠加,不参与精密测量计算,可以直接把 4490 的经纬度当 WGS84 用;但如果你的业务要出图、要量距,或者要和 ArcMap 里做过七参数转换的成果做对比,前端就必须走同一套转换参数,否则两边差异会让你怀疑人生。
2.3 三种常见加载方案:4490 经纬度直用、高斯投影重投影、3857 标准瓦片
面对一份 CGCS2000 数据源,我一般先在服务器端或本地把方案定下来,再写前端代码。方案不分绝对好坏,取决于数据量、更新频率、底图叠加需求。
| 方案 | 适用场景 | 精度表现 | 前端复杂度 |
|---|---|---|---|
| 4490 经纬度直用 | 小数据量、单图层展示、精度要求不高 | 相对真值偏差约 0.5~1 米 | 最低,中心点直接用经纬度,无需额外代码 |
| 高斯投影重投影到 4326 | 要与在线 3857 底图叠加、出图精度要可控 | 取决于七参数或四参数转换质量 | 中等,需要 proj4 定义 + 坐标遍历转换 |
| 切成 3857 标准瓦片 | 数据量大、多人并发、长期发布 | 切片时按统一参数转换,后续无偏差 | 最高,但交付后前端几乎零改造 |
方案一最省事,但要注意:4490 与 WGS84 之间的差异不是坐标系旋转欧拉角能描述的,它是两个椭球基准间的整体偏移。叠加在线底图时,缩放级别一高,边界错位就会暴露。方案二是本文后面重点展开的做法,绝大多数“Maplibre 4490 版”的项目落到最后都是这一套。方案三适合有 GeoServer 或 ArcGIS Server 的团队,在发布服务时直接按 3857 网格切片,把转换成本放到服务端,前端拿到的就是标准数据源。
3. 在 Maplibre 4490 适配版里加载 CGCS2000 数据源:最小工程、瓦片配置与多源叠加
3.1 先跑通 GeoJSON:proj4 定义、坐标重投影、接入 Map
最常见的起步场景是:手里有一个原始 CGCS2000 3 度带投影的 GeoJSON,目标是把它显示在浏览器地图上并叠加在线底图。我采用的套路是先安装依赖,再在代码里注册投影定义,然后重投影数据,最后初始化地图。
npm install maplibre-gl proj4import maplibregl from 'maplibre-gl'; import proj4 from 'proj4'; import 'maplibre-gl/dist/maplibre-gl.css'; // 1. 注册 CGCS2000 3 度带投影 // 这里以带号 40、中央经线 120°E 为例,对应 EPSG:4547 proj4.defs('EPSG:4547', '+proj=tmerc +lat_0=0 +lon_0=120 +k=1 +x_0=500000 +y_0=0 +ellps=GRS80 +units=m +no_defs'); // 2. 读取本地 CGCS2000 原始 GeoJSON const rawGeoJSON = await fetch('/data/cgcs2000-zone40.geojson').then(res => res.json()); // 3. 把高斯投影坐标转成 WGS84 经纬度 const toWGS84 = proj4('EPSG:4547', 'EPSG:4326'); const converted = reprojectGeoJSON(rawGeoJSON, toWGS84); // 4. 初始化地图 const map = new maplibregl.Map({ container: 'map', style: { version: 8, sources: { 'cgcs2000-layer': { type: 'geojson', data: converted } }, layers: [ { id: 'cgcs2000-fill', type: 'fill', source: 'cgcs2000-layer', paint: { 'fill-color': '#2b7fbc', 'fill-opacity': 0.35 } } ] }, center: [120, 30], zoom: 6 });这里核心逻辑在reprojectGeoJSON,它递归遍历坐标数组,把平面坐标转成经纬度:
function reprojectGeoJSON(geojson, transform) { const copy = JSON.parse(JSON.stringify(geojson)); const walk = coords => { return coords.map(coord => { // coord 是单点坐标 [x, y] if (typeof coord[0] === 'number') { const [lng, lat] = transform.forward([coord[0], coord[1]]); return [lng, lat]; } // coord 是嵌套的坐标串(MultiLineString 等) return walk(coord); }); }; copy.coordinates = walk(copy.coordinates); return copy; }逻辑说明就一句:proj4 的transform.forward接收投影坐标(米),返回经纬度。递归的意义在于 GeoJSON 的 geometry 类型多样,Point 是两层数组,Polygon 是四层,MultiPolygon 是五层,不递归处理必然漏点。参数说明里最值得注意的是EPSG:4547的 lon_0=120,这是带号 40 的中央经线。如果你的数据是带号 41,对应 EPSG:4548,lon_0=123。换成 6 度带也一样逻辑,比如 CGCS2000 / 6-degree Gauss-Kruger zone 18 对应中央经线 105,EPSG:4504。带号查错是 100% 会出现的问题,后面避坑章专门讲。
3.2 矢量瓦片 PBF 源:tile 网格对齐才是真正的黑匣子
GeoJSON 方式适合数据量在几十 MB 以内的场景。一旦数据量上去,或者要接 GeoServer、ArcGIS Server 发布的矢量瓦片,就得用vector类型的数据源。这时候最大的问题不再是坐标转换,而是瓦片网格对齐。
map.addSource('cgcs2000-mvt', { type: 'vector', tiles: [ 'https://gis.xxx.com/cgcs2000_3deg/{z}/{x}/{y}.pbf' ], minzoom: 0, maxzoom: 14, scheme: 'xyz' // 如果服务端是 TMS,改成 'tms' }); map.addLayer({ id: 'cgcs2000-boundary', type: 'line', source: 'cgcs2000-mvt', 'source-layer': 'boundary', paint: { 'line-color': '#e31a1c', 'line-width': 1.5 } });这一段代码本身不复杂,但这里藏着 Maplibre 4490 版和普通版最大的区别:普通版请求瓦片时,x/y/z 是按 3857 网格计算的;而按 CGCS2000 切出来的瓦片,其瓦片网格原点、分辨率、覆盖范围都和 3857 不一致。如果你的服务端有专门的 4490 切片网格,适配版内部会拦截瓦片请求,把当前视图的 3857 网格换算成 4490 网格的 x/y/z。这个换算逻辑如果没做,你的瓦片会一片空白、错位或者只显示某一级别。我一般遇到这种情况,先拿服务端文档里的最小切片级别算一下:在 zoom 0 时源服务的瓦片总数是不是 1 张,原点在哪儿,y 轴是往下还是往上。搞不清的时候,把源服务出的单张瓦片拿出来对比,比改代码快得多。
3.3 栅格瓦片与 WMS/WMTS:bbox 模板和 y 轴方向是两大坑
很多自然资源平台的数据源是 WMS 或栅格瓦片服务,这类源在 Maplibre 中通常配成raster。常见写法有两种:一种是服务端直接输出 4490 网格瓦片,一种是走 WMS 动态出图。
map.addSource('cgcs2000-raster', { type: 'raster', tiles: [ 'https://gis.xxx.com/mapserver/4490/tile/{z}/{x}/{y}.png' ], tileSize: 256, scheme: 'xyz' });map.addSource('cgcs2000-wms', { type: 'raster', tiles: [ 'https://gis.xxx.com/geoserver/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=landuse&STYLES=&FORMAT=image/png&TRANSPARENT=true&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}' ], tileSize: 256 });第二种写法里{bbox-epsg-3857}是 Maplibre 的标准 bbox 占位符,但 GeoServer 的 WMS 如果配置的坐标参考是 4490,它期望的 bbox 也应该落在 4490 空间里。常见做法是在 transformRequest 里拦截 Tile 请求,把 bbox 替换成 4490 的矩形范围,同时把 SRS 参数改成 EPSG:4490。还有一种更省心的方式:如果 GeoServer 配置了 CQL 或已经声明了 EPSG:4490 输出,直接在请求头里用&srs=EPSG:4490,并自己实现 3857 bbox 到 4490 bbox 的换算后拼 URL。这一步不做,出现的症状是图层有图但位置整体偏移,缩放级别越高越明显。
WMS 之外,TMS 栅格源还有一个高频问题:y 轴方向。TMS 的瓦片编号 y 轴原点在左下角,XYZ 在左上角,同一个 URL 模板,把scheme从xyz改成tms,瓦片就能从上下颠倒恢复过来。遇到栅格瓦片倒置,先检查这一项,比去怀疑坐标系快得多。
3.4 多数据源叠加:3857 底图当“底”,4490 业务层当“面”
实际项目里很少只加载一个 CGCS2000 业务图层,通常下面还要叠天地图、OpenStreetMap 或自建的 3857 底图。这种多数据源叠加场景下,关键在于“底图保持 3857,业务层统一重投影到 4326”,而不是把 4490 投影坐标直接塞进source。我以前见过同事把 CGCS2000 的米制坐标直接当成经纬度加进地图,结果图形到了博斯腾湖附近,整个业务层和底图错开几百公里,排查了很久才意识到是坐标系没转。
所以我的经验是:在加法表后,所有业务层数据统一走proj4(源EPSG, 'EPSG:4326'),中心点坐标也只保留经纬度。叠加之后,如果底图和业务层的边界在 1:10000 级别下还有肉眼可见错位,多半不是前端问题,而是源数据本身的基准和底图基准有偏差,需要在服务端做七参数转换,而不是前端硬拉。
4. Maplibre 加载 CGCS2000 的五个常见翻车现场:现象、原因、解决
4.1 要素在 Web 地图上碎成网状或首尾不闭合
现象:加载完 GeoJSON 后,多边形边界像被扯断的渔网,首尾点对不上,甚至图形完全无法渲染。原因大概率是几何类型嵌套层级不对,或者坐标数组里混入了非数值元素。有些数据源导出时把坐标写成了字符串,比如"536248.72",proj4 转换时直接返回 NaN。解决方法是遍历数据时做一次类型校验,把字符串转成 number,并且按 MultiPolygon 的嵌套层级递归。我在上面 reprojectGeoJSON 函数里只处理了坐标数组嵌套,对于 GeometryCollection 这种特殊类型还需要额外处理geometries字段,否则就是静默丢数据。
4.2 中心点用数据框选算出错,地图飞到非洲海岸
现象:数据加载了,但地图定位到完全不相关的位置。常见原因是用数据的外包络矩形中心直接作为 map 初始化时的 center,但这个中心点还是投影坐标,没做重投影。解决方法是先用 proj4 把外包络的四个角点转到 4326,再取平均值作为中心点,或者干脆在数据转换后,用转换完成的数据重新计算一次 bounds。另一个细节:center 的格式是[lng, lat],人的直觉容易写成[lat, lng],这一步错位也是非洲海岸常客。
4.3 WMS 栅格图层有图但位置整体偏移,缩放到大比例尺时尤其明显
现象:图层渲染出来了,影像和底图边界错开几十到几百米,放大后偏移更直观。原因多半是 WMS 请求的 bbox 仍按 3857 模板下发,而服务端图层配置在 4490 或 CGCS2000 投影下,两者空间范围不一致。解决思路是在 transformRequest 里对 Tile 类请求做 bbox 重写。我一般这样写:
import proj4 from 'proj4'; proj4.defs('EPSG:4547', '+proj=tmerc +lat_0=0 +lon_0=120 +k=1 +x_0=500000 +y_0=0 +ellps=GRS80 +units=m +no_defs'); map.transformRequest = (url, resourceType) => { if (resourceType === 'Tile' && url.includes('geoserver/wms')) { const bboxMatch = url.match(/BBOX=([-\d.]+),([-\d.]+),([-\d.]+),([-\d.]+)/i); if (bboxMatch) { const [, west, south, east, north] = bboxMatch.map(Number); const to3857 = proj4('EPSG:4490', 'EPSG:3857'); const tl = to3857.forward([west, north]); const br = to3857.forward([east, south]); const newBbox = `${tl[0]},${br[1]},${br[0]},${tl[1]}`; return url.replace(/BBOX=[-\d.,]+/i, `BBOX=${newBbox}`).replace(/SRS=[\w:]+/i, 'SRS=EPSG:3857'); } } return { url }; };这段代码的适用前提是服务端发布的是 4490 坐标系的数据,并且开启了 WMS 重投影。如果服务端不支持 3857 输出,则要反过来:把地图的 3857 bbox 反算成 4490 的经纬度边界,替换进去,并保持 SRS=EPSG:4490。这个方向很容易搞反,注意看返回的报错信息,GeoServer 通常会明确提示不支持哪个坐标系。
4.4 坐标转换后个别要素消失,MultiPolygon 丢洞
现象:整体数据大部分正常,但个别飞地、岛屿、空洞缺失。原因一般是重投影函数只处理了Polygon和MultiPolygon的第一层坐标,没有递归到polygon的环(即 outer ring 和 inner ring)嵌套。GeoJSON 里 Polygon 的 coordinates 是四层数组:[ [ [ [x,y], ... ] ] ],MultiPolygon 是五层。只处理三层必然丢掉某些子环。解决方式是在 walk 函数里不判断层数,只判断“当前元素是不是一个含两个数值的坐标点”,是则转换,否则递归进入。这样无论嵌套多深都能覆盖。建议写完后用一份带空洞的面数据做测试,比如一个地块中间有个鱼塘,转换后鱼塘还在,才算通过。
4.5 坐标已转 WGS84,但和 ArcMap 导出的结果对比还是差十几米
现象:前端转了 4326,ArcMap 里同一份数据用“CGCS2000 转 WGS84”也转了一版,两者叠加后偏差超过十米。原因大概率是转换参数不一致。ArcMap 里如果没有配置自定义七参数,它默认用“无转换”或者“Molodensky 基准变换”,而前端 proj4 如果不带towgs84参数,等效也是椭球间直接换算,两套结果在部分地区差几米到十几米都正常。解决方式:确认项目遵循的地理基准转换参数,把+towgs84写进 proj4 定义。比如华北某地常用的七参数是一组三元组到七元组数据,前端和桌面端必须用同一组。另外还要提醒一句:Petrel 这类地质软件里设置 CGCS2000 坐标系时,选“CGCS2000 / 3-degree Gauss-Kruger CM 120E”和选“EPSG:4547”,参数串应该是同一个,但有些软件版本对坐标系名称的翻译不一致,导出后最好用 EPSG 编码复核,别只看名字。
5. 进阶:交叉验证与性能调优,把“看着对”变成“我知道它对了”
5.1 用 ArcMap 和 pyproj 做双重验证
前端重投影跑通了,不等于坐标系理解对了。我每次交付前都要做一次交叉验证:先用 Python 的 pyproj 对同一份数据的坐标点做批量转换,再和前端 proj4 输出结果抽样对比。
from pyproj import Transformer transformer = Transformer.from_crs("EPSG:4547", "EPSG:4326", always_xy=True) # 取前端转换结果里的三个坐标点做验证 sample_points = [ (536248.72, 3361742.85), (536312.88, 3361810.23), (536400.15, 3361903.66), ] for x, y in sample_points: lng, lat = transformer.transform(x, y) print(f"{lng:.6f}, {lat:.6f}")拿这份输出和前端 fetch 出来的坐标对比,小数点后 5 位以内一致,说明转换链路没问题。ArcMap 里也一样,在属性表里加两个 XY 字段,用几何计算把投影坐标转成地理坐标,再和浏览器里 point 的 popup 坐标比对。这个验证不花多少时间,但能一次性排除“投影定义写错”“带号搞错”“中心点算错”三类问题,比肉眼看图靠谱得多。
5.2 大数据量 GeoJSON 的加载优化
数据量超过 50MB 时,直接addSource会造成浏览器卡顿,业内常用的做法是加载时先用 geojson-vt 在 Web Worker 里切片,再按视图缩放级别动态请求切片。如果你不想引入额外依赖,至少做两件事:一是把坐标精度从 6 位小数降到 4 位,文件体积能缩小约三分之一;二是对大于某个阈值的多边形做 Douglas-Peucker 简化,保留拓扑的关键拐点即可。我这里吃过亏:一份全省图斑数据 80MB,直接加载后滚轮缩放掉到 10 帧以下,后来切成 12 级瓦片之后,加载速度从 8 秒降到 1 秒以内。性能优化要在数据源进入前端之前解决,不要指望 Maplibre 在渲染层面帮你兜底。
最后聊一点个人习惯:每次做 CGCS2000 数据源接入,我都会单独建一个crs-define.js文件,把用到的 EPSG 定义和七参数集中放在一起,并写上数据来源和验证日期。这样换同事接手时,不需要翻聊天记录找“上次用的那个投影参数”。这行当里坐标系问题永远不是看一遍文档就能解决的,每次以为自己懂了,换个区域带号、换个数据源,新的坑就来了。希望你把这些经验用在自己的项目里时,能少走一段弯路。
本文还有配套的精品资源,点击获取