GeoJSONcn
GeoJSONcn / 技术专栏 / GeoJSON 规范里四个隐蔽的…

GeoJSON 规范里四个隐蔽的坑:坐标顺序、crs 字段、环方向与 bbox

发布于 2026-07-25 · GeoJSONcn 技术团队 · 阅读约 12 分钟

--- title: GeoJSON 规范里四个隐蔽的坑:坐标顺序、crs 字段、环方向与 bbox ---

GeoJSON 看着简单,就是个 JSON 文件。但现行规范 RFC 7946 里有几处规定特别容易被忽视,出了问题还很难排查:点明明存在却渲染到了海里、多边形把整个地球填成一种颜色、老数据挂着一个解析器根本不认的坐标系声明。这篇把最常见的四个坑讲清楚,每个都附上能直接跑的检查代码。

坑一:坐标顺序是先经度、后纬度

RFC 7946 规定,position 的顺序是 [经度, 纬度, 高程(可选)]。天安门写作 [116.397428, 39.90923],经度在前。

写反的后果分两种。北京这类高纬度、高经度的点写反后变成纬度 116,超过 ±90 的合法区间,校验工具能直接报错,还算好排查。麻烦的是广州 [113.264385, 23.129112] 这种:写反后的 [23.129112, 113.264385] 是印度洋上一个完全合法的坐标,数据不报错,点却默默漂到了海上。

更常见的翻车场景是和 Leaflet 混用。Leaflet 自己的 API 用的是纬度在前:L.marker([39.90923, 116.397428])。用 L.geoJSON() 加载数据时 Leaflet 内部会自动交换顺序,不用管;但如果手动从 feature.geometry.coordinates 里取值再传给 L.marker,就必须自己交换。漏掉这一步,是我见过的最高频错误来源。两套顺序约定并存的历史原因,在坐标系那篇里有更完整的说明。

坑二:crs 字段在 2016 年就被删掉了

GeoJSON 有两版规范。2008 年的社区版规范允许用 crs 成员声明坐标参考系;2016 年发布的 RFC 7946 把 crs 整个删除,规定 GeoJSON 坐标一律是 WGS84 经纬度(等同 EPSG:4326 的经纬度表达)。所以老数据里这样的声明:

"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }

现代解析库大多直接忽略。忽略 CRS84 没事,它本来就是经纬度。真正的风险在另一种老数据上:有些数据生产方把投影坐标系写进了 crs,坐标值是几十万、几百万量级的米制数值。解析器忽略 crs 声明后,会把这些米制数值直接当成经纬度处理,几何图形瞬间飞出地球范围。判断方法不复杂:打开文件看第一个坐标,数值超出 [-180, 180] 就一定是投影坐标,需要先重投影成经纬度,具体流程可以照搬 Shapefile 转 GeoJSON 那篇里的 pyproj 步骤。

顺带说一句:国内常见的 CGCS2000(EPSG:4490)经纬度与 WGS84 经纬度数值差异在厘米级,Web 制图场景直接按 4326 使用即可,不需要专门转换。

坑三:多边形环方向,右手定则

RFC 7946 第 3.1.6 节规定:Polygon 的外环必须逆时针,内环(洞)必须顺时针。判断一个环的方向,用鞋带公式算有向面积即可:

// 鞋带公式求有向面积:> 0 逆时针,< 0 顺时针(平面近似,判方向足够)
function ringArea(ring) {
  let sum = 0;
  for (let i = 0; i < ring.length - 1; i++) {
    const [x1, y1] = ring[i];
    const [x2, y2] = ring[i + 1];
    sum += (x2 - x1) * (y2 + y1);
  }
  return -sum / 2;
}
const isCounterClockwise = ring => ringArea(ring) > 0;

Leaflet 和 OpenLayers 在平面上渲染,不校验环方向,方向错了图也能画对。所以很多人从来没意识到这条规定的存在。d3-geo 是例外:它按球面几何解释多边形,环方向决定"哪一侧算内部"。如果外环方向不符合它的预期,d3 会认为你要画的是"整个地球减去这块区域",渲染结果就是全球被填色、目标区域反而被抠掉。这是 d3 画地图最经典的错误现象。

修复用 @turf/rewind 一行搞定。要注意 d3-geo 采用的环方向约定与 RFC 7946 相反(d3 要求外环顺时针),所以喂给 d3 的数据要用 reverse: true:

import rewind from '@turf/rewind';
const spec  = rewind(geojson, { reverse: false }); // RFC 7946:外环逆时针
const forD3 = rewind(geojson, { reverse: true });  // d3-geo:外环顺时针

坑四:bbox 的顺序是西、南、东、北

bbox 是可选成员,四个数的顺序是 [最西经度, 最南纬度, 最东经度, 最北纬度]。以北京市为例,bbox 大致是 [115.4, 39.4, 117.5, 41.1]。

它的实用价值在加载优化:前端拿到 bbox 就能在解析整个 FeatureCollection 之前判断数据是否落在当前视野内,不相关的数据直接跳过。但注意又是坐标顺序坑:bbox 里是经度在前,而 Leaflet 的 LatLngBounds 是纬度在前,把 bbox 转成 fitBounds 参数时要交换:

const [w, s, e, n] = geojson.bbox;
map.fitBounds([[s, w], [n, e]]); // Leaflet 用 [纬度, 经度]

一张自查表

拿到一份来路不明的 GeoJSON,按这张表过一遍:

检查项规范要求快速检查方法
坐标顺序[经度, 纬度]中国境内数据第一个数应落在 73~135 区间
crs 成员RFC 7946 已删除,默认 WGS84坐标值超出 [-180, 180] 说明是投影坐标,先重投影
外环方向逆时针鞋带公式有向面积 > 0
内环方向顺时针鞋带公式有向面积 < 0
bbox[西, 南, 东, 北]第 1 项 < 第 3 项,第 2 项 < 第 4 项

如果对 GeoJSON 的整体结构还不熟,建议先读GeoJSON 到底是什么打底;需要和空间数据库交换数据的场景,环方向问题在 WKT 互转那篇提到的 PostGIS 里同样存在,ST_ForcePolygonCCW 可以做同样的修正。

全国省、市、区县三级的行政区划边界 GeoJSON(坐标顺序、环方向均按 RFC 7946 整理),可以在 GeoJSONcn 主站按行政区划检索预览。