做 GIS 数据这行的人,几乎都经历过同一个瞬间:手里拿到一个.geojson文件,右键、拖进地图、鼠标滚轮一缩,屏幕上干干净净,什么都没有。你以为是文件坏了,打开一看,JSON 结构清清楚楚,Feature 一个不少,坐标也是一串规规矩矩的数字。于是开始怀疑人生——是编码问题?是坐标系问题?还是软件版本问题?折腾两小时后发现,经纬度写反了,或者数据其实是 Web 墨卡托的米制坐标,被渲染器当成了度。
这篇内容就把 GeoJSON 这件事从头到尾拆一遍:它的结构原理到底是怎么规定的、geojson.io这种编辑器怎么在自己机器上搭一套、ArcGIS 生态里怎么双向转换,以及那一堆报错和不显示的问题,逐个怎么定位。适合刚接触矢量数据格式的新人,也适合已经写过几个图层、但每次转换都要靠试错的中间层从业者。我不打算复述规范原文,而是按“为什么会这么设计—实际怎么操作—踩坑时怎么排查”的顺序讲,你看完至少能做到两件事:手写一个结构合法的 GeoJSON 不用查文档,遇到图层不显示能按顺序在十分钟内定位到原因。
1. GeoJSON 的结构原理:规范里规定的和没规定的
很多人把 GeoJSON 当成“带坐标的 JSON”,这个理解只对了一半。它本质上是一套用 JSON 表达矢量几何与属性的约定,背后有明确的规范文本约束,而这些约束恰恰是后面所有转换和排错问题的根源。这一章我们把结构、坐标系、属性三个层面拆开看,理解清楚之后,你会发现很多“玄学问题”其实是规范里写死的行为。
1.1 三种顶层对象与七种几何类型的嵌套关系
规范里把 GeoJSON 对象分成有限的几类,顶层能直接用的只有三种:Geometry(几何)、Feature(要素)、FeatureCollection(要素集合)。几何再细分七种:Point、MultiPoint、LineString、MultiLineString、Polygon、MultiPolygon、GeometryCollection。这个分类不是随便定的,它决定了坐标数组的嵌套层级,而嵌套层级错一层,就是最常见的解析失败原因。
记住一个规律:每多一层“集合”或“环”,坐标数组外面就多一层方括号。点是[x, y],多点是[[x,y],[x,y]],线是[[x,y],[x,y]],面是[[[x,y],...]]——注意面比线多一层,因为面由“环”组成,环由点组成。很多人手写面的时候少写一层括号,结果解析器把它当成了线或者直接报错。这个层级关系用表格看更清楚:
| 类型 | 坐标层级 | 最小结构示例 | 常见误写 |
|---|---|---|---|
| Point | 1 层 | [116.4, 39.9] | 写成[[116.4,39.9]] |
| MultiPoint | 2 层 | [[116.4,39.9],[116.5,39.9]] | 少一层变成线 |
| LineString | 2 层 | [[116.4,39.9],[116.5,40.0]] | 只给一个点(非法) |
| Polygon | 3 层 | [[[116.4,39.9],[116.5,39.9],[116.5,40.0],[116.4,39.9]]] | 少一层括号 |
| MultiPolygon | 4 层 | [[[[x,y],...]]] | 少两层括号 |
要素对象比几何多两个必备成员:geometry和properties。这里有个小细节——geometry允许是null,用来表达“位置未知的要素”,但properties也允许是null。这在规范里是合法的,可大量解析库和渲染器在遇到geometry: null时并不会优雅跳过,而是抛异常,所以生产数据里我建议直接剔除空几何,别指望下游兼容。
FeatureCollection就是{"type":"FeatureCollection","features":[...]},它的features数组里只能放Feature,不能直接放几何。这个限制经常被忽略:有人觉得“反正是数组”,把Polygon直接塞进去,geojson.io能勉强渲染,但 ArcGIS 的转换工具会直接拒绝。
提示:
GeometryCollection只在极少数场景需要(比如同一个要素里既有面又有点),主流工具对它的支持并不一致,能拆成多个 Feature 就拆开,别给自己找麻烦。
1.2 坐标系、坐标顺序与精度:三个最容易翻车的地方
GeoJSON 的坐标系规定非常明确:只使用 WGS 84 地理坐标,单位是度,坐标顺序是经度在前、纬度在后。规范文本里甚至直接把这个写成了强约束,并且移除了早期版本里的crs成员——也就是说,你在文件里写"crs": {...}声明自己用的是别的坐标系,现代解析器基本会忽略它,该按 4326 解析还是按 4326 解析。
这带来两个连锁问题。第一,如果你的数据是从投影坐标系(比如各种以米为单位的平面坐标)直接导出的,坐标值可能是[12987654, 4823456]这种量级,渲染器把它当成度来处理,纬度 482 万度远超 ±90 的范围,结果要么被裁剪到极点,要么直接不画。第二,坐标顺序写反的情况极其普遍,因为不同系统对“xy”和“经纬度”的呈现习惯不一样,人在转录时很容易把纬度写前面。判断方法很土但有效:纬度绝对值不可能超过 90,你看到第二个数大于 90,基本就是顺序反了,或者根本不是地理坐标。
精度方面,很多人以为小数位越多越准,实际上多出来的位数只是增加体积。按赤道附近一度经度约 111 公里估算,小数位对应的地面精度大致如下:
| 小数位 | 地面精度(约) | 适用场景 |
|---|---|---|
| 4 位 | 11 米 | 街区级展示、粗略定位 |
| 5 位 | 1.1 米 | 常规地图标注 |
| 6 位 | 0.11 米 | 精细地块、室内外一体化 |
| 7 位 | 1.1 厘米 | 测量级,一般没必要 |
| 9 位以上 | 毫米以下 | 纯属浪费体积 |
我的习惯是统一保留 6 位,超过的一律四舍五入截断。一个十万点的线数据,从 15 位小数砍到 6 位,文件体积能掉三四成,渲染速度肉眼可见地变快,而精度损失对绝大多数业务场景完全可以接受。
注意:截断精度要在数据入库前做,不要在每次前端加载时动态处理。动态处理看似灵活,实际会让每次请求都多一次全量遍历,数据量一大就是性能黑洞。
1.3 属性字段、id 与 bbox:容易被当成“无所谓”的部分
properties是个自由对象,理论上可以放任意 JSON 值。但实践里它是最容易出问题的部分,因为不同工具对它的处理策略差别很大。geojson.io这类编辑器通常会原样保留嵌套对象,ArcGIS 转要素时却会把嵌套对象序列化成字符串塞进一个文本字段,或者干脆丢掉。所以如果你的数据要跨工具流转,properties里只放扁平的标量值(字符串、数值、布尔、日期字符串),是最省心的做法。
字段命名也有讲究。如果这份数据最终要落到 Shapefile 体系里,字段名会受 10 个字符长度限制,中文和特殊字符的处理在不同版本里表现不一。我一般会提前规划成短英文名加下划线,比如pop_total、area_km2,别用人口总数(万人)这种名字——转换时被截断成莫名其妙的样子,后期对不上字段只能重做。
id成员是要素的可选标识,规范建议用字符串或数字。它的坑在于:有些转换工具会把它提升为独立的要素 ID,有些则会把它复制到properties里变成普通字段,还有些直接忽略。如果业务依赖唯一标识,稳妥做法是既写id,又在properties里放一份同样的字段。
bbox是包围盒,格式是[west, south, east, north],用于快速空间过滤。它不是必需的,但如果你要提供大量要素给前端做空间查询,给FeatureCollection加一个顶层bbox,能让客户端少算一遍全量遍历。手写容易出现的问题是把顺序写成[minX, minY, maxX, maxY]之外的花样,或者只给两个数,这些都会让校验工具报错。
1.4 和 Shapefile、KML、TopoJSON 之间怎么选
GeoJSON 不是万能的,把它放在整个矢量格式谱系里看,选择逻辑会清楚很多。它最大的优势是纯文本、结构自解释、浏览器原生可解析,调试的时候打开文件就能看,不需要任何专用软件。代价是体积大——每个坐标点都是一串字符,重复的键名还要重复出现,压缩前比二进制格式笨重得多。
| 格式 | 优势 | 明显短板 | 典型用途 |
|---|---|---|---|
| GeoJSON | 可读、通用、前端友好 | 体积大、无拓扑、无样式 | 接口传输、Web 地图 |
| Shapefile | 生态老、工具全 | 字段名 10 字符、单文件 2GB 上限、多文件分散 | 传统 GIS 交付 |
| KML | 样式与标注表达力强 | 结构冗长、解析较重 | 三维展示、汇报演示 |
| TopoJSON | 共享拓扑、体积小 | 需要额外解码、可读性差 | 大范围行政边界 |
| GeoPackage | 单文件、支持索引与多图层 | 需专用驱动 | 移动端、离线包 |
我做数据交换时的默认选择是:对内用 GeoPackage 或数据库,对外用 GeoJSON,纯展示再考虑 TopoJSON。这个分界线的逻辑很简单——GeoJSON 的价值在于“任何人拿到都能读懂并立刻用起来”,一旦你需要索引、需要事务、需要处理百万级要素,它就不该出现在链路上。
2. 自己部署一套 geojson.io:可控的编辑与预览环境
geojson.io是很多人接触 GeoJSON 的第一个工具:左边贴 JSON,右边出图,还能直接在地图上点几下生成要素。但直接用公共在线版本有三个现实问题:数据要经过外部服务、部分功能依赖外部接口可能不可用、你没法按自己的需求改。把源码拉到本地构建一份,成本不高,但换来的是一个可以内网共享、可以离线使用、可以随手改的编辑环境。
2.1 自建的实际收益与前置判断
先说清楚什么情况下值得自建。如果你只是偶尔看一眼坐标对不对,在线版足够;但如果你处在下面几种场景,自建就是划算的:数据不能离开内网、需要给团队多人提供一个统一的预览入口、需要把编辑器嵌进自己的管理系统、需要固定版本避免上游更新导致行为变化。
自建的成本主要在两块:一是构建环境,需要 Node.js 工具链;二是后续维护,上游更新后你要重新拉取构建。第二块经常被低估——我见过团队把某个版本部署上去之后再没管过,两年后想升级,发现依赖链已经烂掉,只能推倒重来。所以我的建议是:自建时把版本号和构建脚本一起写进 README,每季度挑个时间重新构建一次,别等到必须升级时才动。
还有一个容易忽略的点:编辑器本身的定位是“轻量查看与手工微调”,不是数据生产工具。指望它处理几十万要素的图层,无论自建还是在线都会卡,这不是部署方式能解决的,属于架构选择问题。
2.2 从源码到可运行产物的完整流程
通用流程是:拿到源码、安装依赖、构建、本地预览。具体命令在不同分支上可能有差异,以下是我常用的一套,如果和你拿到的版本对不上,以仓库里的说明文件为准。
# 1. 取源码(用你实际拿到的仓库地址) git clone <仓库地址> geojson-editor cd geojson-editor # 2. 固定 Node 版本,建议用 LTS(18 或 20) node -v npm -v # 3. 优先用 lockfile 精确安装,保证可复现 npm ci # 4. 构建生产产物 npm run build # 5. 本地起一个预览服务 npm start第一步最容易出问题的是 Node 版本。老项目对 Node 的版本敏感,用最新的实验版本经常在依赖编译环节报错,而用太老的版本又会因为语法特性不支持而失败。我的做法是用版本管理工具切到 LTS,并且把版本号写进.nvmrc之类的文件里,让别人一看就知道该用哪个。
第二步的npm ci和npm install的区别值得说一句:前者严格按照 lockfile 安装,装出来的依赖树和你本地、和 CI 上完全一致;后者可能会更新 lockfile,导致“我这儿能跑,你那儿跑不起来”。团队协作场景一律用npm ci。
构建阶段如果遇到内存溢出(表现为进程被杀掉、日志停在某个模块),最直接的解法是调大内存上限:
export NODE_OPTIONS=--max-old-space-size=4096 npm run build依赖安装慢或者卡在某个包上,属于网络层面的问题,换成就近的包源通常就能解决,这一步和代码本身无关,不用怀疑项目坏了。
2.3 静态托管:把构建产物放到服务器上
构建完成后会得到一个静态目录(通常叫dist或build),里面的内容就是纯静态资源,扔到任意静态服务器上都能跑。用 Nginx 托管是最常见的做法:
server { listen 80; server_name editor.internal.example; root /var/www/geojson-editor/dist; index index.html; # 单页应用的路由回退 location / { try_files $uri $uri/ /index.html; } # 确保 JSON 以正确的内容类型返回 location ~* \.json$ { default_type application/json; add_header Cache-Control "no-cache"; } # 静态资源长缓存,文件名带哈希时可以放心设久一点 location ~* \.(js|css|woff2|png|svg)$ { expires 30d; add_header Cache-Control "public, immutable"; } gzip on; gzip_types application/json application/javascript text/css text/plain; gzip_min_length 1024; }这里的两个配置点值得强调。try_files那行是为了让前端路由在刷新页面时不 404,缺了它,用户从首页点进去一切正常,一按 F5 就白屏,这类问题排查起来很费时间。default_type application/json是为了防止服务器把.json当成纯文本返回,某些前端库会根据响应的内容类型决定是否自动解析,类型不对就会出现“拿到的是一段字符串而不是对象”的现象。
如果只是临时验证,python -m http.server也能用,但它不支持路由回退,也不做压缩,只适合自己看两眼,不要拿它对外提供服务。
2.4 部署后的实用配置与改造方向
跑起来之后,有几处值得按自己的情况调整。第一是底图,默认配置指向公共底图服务,内网环境下可能加载不出来,需要把底图地址换成本地部署的瓦片服务或者内网可访问的地址。换的时候注意协议——页面如果是 HTTPS 而底图是 HTTP,浏览器会拦截混合内容,表现为底图一片灰但矢量数据正常显示,很容易误判成数据问题。
第二是外部依赖功能。编辑器里有些能力依赖第三方接口(比如保存到外部代码托管服务),内网部署时这些按钮点了没反应是正常的,不是部署失败。稳妥的处理是在配置里把这类入口关掉,省得使用者反复来问。
第三是访问控制。这个东西一旦挂到内网,就是个能贴任意文本的编辑页,虽然风险不高,但还是建议加个基础认证,至少别让它在公网上裸奔。
第四是团队协作细节。我习惯在部署目录里放一份极简的说明文件,写清楚三件事:怎么导入文件、支持的最大体积大概是多少、导出的数据默认是什么坐标系。这三条是新人问得最多的,写在页面上比口口相传效率高得多。
提示:手工在编辑器里描的要素,属性字段名往往是自动生成的(类似
name、description),导出后直接入库会导致字段对不上。养成导出后立即重命名字段的习惯,或者干脆只用它看坐标、不用它做数据生产。
3. ArcGIS 侧的转换:双向流转的实操路径
GeoJSON 和 ArcGIS 生态之间的转换,是实际工作中最高频也最容易出问题的环节。原因不复杂:两边对坐标系的假设不同、对环方向的定义不同、对字段类型的推断规则也不同。这一章按“工具怎么用—坐标系怎么处理—批处理怎么写—前端怎么消费”的顺序讲。
3.1 两个方向的转换工具与关键参数
桌面端提供了成对的转换工具:一个是把 JSON 转成要素类,一个是把要素类转成 JSON。后者的输出格式里有一个选项,勾上之后输出的是 GeoJSON,不勾则是 ArcGIS 自己的 JSON 表示——这两者的差别比很多人想象的大,最典型的是环方向相反。ArcGIS 的几何表示沿用的是外环顺时针的约定,而 GeoJSON 规范建议外环逆时针。大多数渲染器不校验方向,但拓扑运算和部分校验工具会,所以转换时那个选项一定要勾对。
前者的关键参数是几何类型。如果你不指定,工具会尝试从数据里推断,但遇到混合类型或者嵌套层级较深的面数据时推断可能失败,明确指定成点、线、面反而更可靠。我一般的做法是:数据里有面就指定面,宁可在转换后过滤掉不符合类型的要素,也不要让它自动猜。
字段方面的规则需要提前规划:数值字段会被推断成整型或浮点,字符串统一进文本字段,日期字符串能否被识别成日期类型取决于格式。如果properties里有嵌套对象,通常会被序列化成一段字符串塞进文本字段,后续想用时还得再解析一次,所以前面强调的“只放扁平标量”在这里就体现价值了。
3.2 坐标系:转换之前必须先做的功课
这是整个流程里最重要的一步,也是最容易被跳过的一步。GeoJSON 只认地理坐标(经纬度、WGS 84),而你的源数据大概率是某个投影坐标系。直接导出会发生什么?坐标值是以米为单位的几十万到几百万量级,GeoJSON 文件结构完全合法,校验工具也不会报错,但一加载到地图上,要素要么消失了,要么全挤在极地附近。
原因在于渲染器无条件按经纬度解释这两个数。经度 12987654 超出 ±180 的范围会被裁剪或环绕,纬度 4823456 超出 ±90 会被钳制到极点。所以现象往往是“所有要素变成一个点,位置在极地附近或者完全不显示”,非常有辨识度。
正确的做法是在导出前先做一次投影转换,把数据统一到地理坐标系。判断当前数据是什么坐标系,看要素类的空间参考信息即可。转换之后再导 GeoJSON。这一步别想着偷懒跳过,也别指望转换工具自动帮你投影——它不会,它只做格式转换。
| 源数据坐标系类型 | 直接导出的结果 | 处理方式 |
|---|---|---|
| 地理坐标系(WGS 84) | 正常 | 直接导出 |
| 平面投影(米制) | 位置完全错误,挤在极地 | 先投影到 WGS 84 |
| 其他地理坐标系 | 位置有偏移(通常几十到几百米) | 先投影到 WGS 84 |
| 未定义空间参考 | 无法判断,坐标可能任意 | 补定义后再处理 |
最后一行那种情况在实际交付里特别常见:别人给你的数据没带坐标系信息,坐标值看着像经纬度,也可能是某种地方坐标。我的处理原则是拿几个已知地物做对照,在地图上找特征明显的点(比如海岸线拐角、道路交叉口)反推坐标量级和偏移,确认了再补定义,不要凭感觉。
3.3 批处理脚本:一次处理一整个目录
单个文件用工具对话框点几下没问题,但当你有一个目录、几十个要素类要处理时,手工点击就是灾难。用脚本批量处理,流程是三步:投影、转换、后处理精度。下面是一段可直接改路径使用的示例:
import os import json import arcpy SRC_WKID = 4326 WORKSPACE = r"D:\data\gdb\in.gdb" OUT_DIR = r"D:\data\geojson" os.makedirs(OUT_DIR, exist_ok=True) arcpy.env.workspace = WORKSPACE arcpy.env.overwriteOutput = True def round_coords(obj, ndigits=6): """递归截断坐标精度,减少体积""" if isinstance(obj, list): if obj and isinstance(obj[0], (int, float)): return [round(v, ndigits) for v in obj] return [round_coords(v, ndigits) for v in obj] if isinstance(obj, dict): return {k: round_coords(v, ndigits) for k, v in obj.items()} return obj for fc in arcpy.ListFeatureClasses(): base = os.path.splitext(fc)[0] tmp_gdb = os.path.join(arcpy.env.scratchGDB, f"{base}_wgs84") # 步骤一:投影到 WGS 84(已经是的话这步可以跳过) sr = arcpy.Describe(fc).spatialReference if sr.factoryCode != SRC_WKID: arcpy.management.Project(fc, tmp_gdb, arcpy.SpatialReference(SRC_WKID)) src = tmp_gdb else: src = fc # 步骤二:转成 GeoJSON(务必开启 GeoJSON 输出选项) out_json = os.path.join(OUT_DIR, f"{base}.json") arcpy.conversion.FeaturesToJSON( in_features=src, out_json_file=out_json, format_json="FORMATTED", include_z_values="NO_Z_VALUES", include_m_values="NO_M_VALUES", geoJSON="GEOJSON" ) # 步骤三:精度截断并改扩展名 with open(out_json, "r", encoding="utf-8") as f: data = json.load(f) data = round_coords(data) final = os.path.join(OUT_DIR, f"{base}.geojson") with open(final, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) os.remove(out_json) print(f"done: {base}")这段脚本里有几个点值得解释。参数名在不同版本上可能有微调,跑之前先用工具对话框点一遍、把结果复制成代码片段是最省事的办法,比翻文档快。打开 GeoJSON 输出选项那一步不能省,省了输出的就是 ArcGIS 自有格式,坐标结构都不一样。
精度截断放在最后做,是因为转换出来的坐标动辄十几位小数,体积能差出一倍。但要注意ensure_ascii=False这个参数——不设置的话中文属性会被转义成\uXXXX的形式,虽然解析后内容正确,但文件可读性差、体积也大。
注意:脚本里用了
scratchGDB做中间存储,跑之前确认它有足够的空间,并且不要在里面留长期数据。临时工作空间被清空是常态,重要结果一定要落到正式目录。
3.4 前端直接消费:把文件变成图层
转到前端环节,现在的地图 SDK 普遍原生支持 GeoJSON 图层,不需要额外引入转换库。核心步骤只有一个:把文件内容变成一个可访问的 URL。如果数据来自本地文件上传或者接口返回,用 Blob 生成临时地址是最省事的做法:
async function addGeoJsonLayer(view, file) { const text = await file.text(); const data = JSON.parse(text); // 加载前做一次基础结构检查 if (data.type !== "FeatureCollection" || !Array.isArray(data.features)) { throw new Error("不是合法的 FeatureCollection"); } const blob = new Blob([JSON.stringify(data)], { type: "application/geo+json" }); const url = URL.createObjectURL(blob); const layer = new GeoJSONLayer({ url: url, title: file.name, popupEnabled: true }); view.map.add(layer); // 图层加载完成后释放临时地址 layer.when(() => URL.revokeObjectURL(url)); return layer; }这里有两个容易忽略的细节。第一,URL.revokeObjectURL要在图层加载完成后调用,太早释放会导致加载失败,太晚又占内存,挂在when回调里是最合理的。第二,直接双击打开本地 HTML 文件测试时,file://协议会让很多加载逻辑失效,表现为跨域错误。这不是代码问题,起一个本地 HTTP 服务再测就行。
样式方面,默认填充和描边往往不适合直接展示,尤其是面数据层层叠加时会糊成一片。我一般会给面图层设半透明填充加细描边,线数据设固定宽度并且注意缩放级别下的自适应,点数据用小尺寸圆点而不是默认图标——默认图标在高密度点位下会互相遮挡,看起来像卡住了。
4. 排错实录:图层不显示与转换异常的定位顺序
这一章是整篇内容里最实用的部分。我把实际遇到过的问题按“从快到慢”的排查顺序整理了一遍,你可以照着顺序走,绝大多数问题在前三步就能定位。
4.1 四类根因与标准排查顺序
图层不显示的根因,我归纳下来无非四类:文件本身解析不过、结构合法但坐标错、坐标对但几何有瑕疵、几何没问题但样式或图层顺序有问题。这四类的排查成本是递增的,所以顺序必须是先验证最便宜的。
第一步,验证能不能解析。控制台里直接JSON.parse一下文本内容,报错就说明文件有问题。最常见的解析失败是开头带了 BOM 标记——某些编辑器保存 UTF-8 时会自动加这个不可见字符,人眼看不出,解析器会直接抛“意外的记号”。处理办法是用编辑器另存为“UTF-8 无 BOM”,或者写个脚本把文件头几个字节剥掉。
第二步,验证结构。检查顶层是不是FeatureCollection,features是不是数组,每个要素有没有geometry,几何的type和coordinates嵌套层级是否匹配。这一步手写脚本五行就能搞定,比盯着看快得多。
第三步,验证坐标范围。遍历所有坐标,判断经度是否在 ±180 内、纬度是否在 ±90 内。有超范围的值就说明坐标系没处理。如果范围看起来正常但位置明显不对(比如全国数据跑到海洋里),大概率是经纬度顺序反了——用“纬度绝对值不会超过 90”这条规则快速验证。
第四步,验证几何与样式。前面三步都过了还不显示,就去看几何本身有没有自相交、环有没有闭合、样式是不是透明或者描边宽度为 0、图层是不是被后加的底图盖住了。这一类问题最费时间,因为每一环都“看起来正常”。
| 排查步骤 | 检查手段 | 典型耗时 | 命中率 |
|---|---|---|---|
| 能否解析 | JSON.parse | 不到 1 分钟 | 高 |
| 结构是否合法 | 检查顶层与嵌套层级 | 1 到 3 分钟 | 高 |
| 坐标是否在范围内 | 遍历极值判断 | 1 到 2 分钟 | 很高 |
| 几何与样式 | 单要素逐步加载、改样式 | 10 分钟以上 | 中 |
这个顺序的意义在于:把最容易验证、命中率又高的检查放在前面,避免一上来就怀疑几何有效性,白白消耗时间。
4.2 坐标与几何层面的典型坑
坐标层面的坑,除了顺序和坐标系,还有几个隐蔽的。一是坐标值写成了字符串,"116.4"和116.4在 JSON 里是两种类型,部分解析库会静默处理,部分会直接报错,表现不一致,很难判断。二是出现了null或者非数字值,常见于从表格数据拼装时某列为空。三是坐标数组长度不足,[116.4]只有一个数,校验工具会报“坐标必须有至少两个元素”。
几何层面的坑更多。多边形环未闭合是高频问题——规范要求首尾点相同,很多手写或程序生成的环直接漏掉最后那个重复点,渲染器可能仍然填充出形状,但面积计算和拓扑判断会出错。环方向虽然在渲染上通常无感,但涉及裁剪、合并时会暴露问题。
自相交是另一个隐蔽问题。一个“8”字形的面,在某些渲染器上填充正常,在另一些上面会出现中间挖空或者填充溢出,看起来像渲染 bug,实际是数据问题。检查方法是用几何校验工具批量跑一遍,把无效要素挑出来单独处理,而不是指望渲染器容错。
还有一类是重复点和零长度线段,多见于从设备采集的轨迹数据。十几个点重合在同一个位置,视觉上看不出,但一旦做缓冲分析或者求长度,结果会诡异地偏小或者报错。清洗规则很简单:遍历坐标序列,相邻点距离小于阈值(比如 1e-9 度)的直接删掉。
提示:清洗几何之前先备份原始数据。拓扑修复类操作是有损的,出了问题回滚成本很高。
4.3 编码、字段与体积引发的连锁问题
编码问题表现得很直接:中文属性显示成乱码,或者干脆整个文件解析失败。根源是保存时的编码和读取时的编码不一致。统一用 UTF-8 无 BOM 保存,基本能杜绝这一类。从表格软件导出时尤其要注意,某些版本默认用本地编码,导出来的文件在别的环境里读就是一堆问号。
字段问题更隐蔽。一个是类型漂移:属性里的数字在某些工具里被读成字符串,做数值比较时"10" > "9"得到的结果是false,这类 bug 极难发现。稳妥做法是在入库前统一做一次类型转换,别依赖下游推断。另一个是字段名冲突,比如两个字段名只差大小写,在某些系统里会被合并成一列。
体积问题在大数据上会集中爆发。几十兆的 GeoJSON 拖进网页编辑器,浏览器会直接失去响应;即使加载成功,编辑时也是每敲一个字符卡一下。这不是编辑器的问题,纯文本格式加上全量渲染本身就撑不住这个量级。处理方向有三个:剪裁到需要的范围、简化几何节点、或者直接用支持空间索引的格式替代。
4.4 常见问题速查表
把上面这些整理成一张表,遇到问题时对着找比翻文档快:
| 现象 | 可能原因 | 快速验证 | 处理办法 |
|---|---|---|---|
| 页面空白,控制台报解析错误 | 文件带 BOM、JSON 语法错 | JSON.parse试一下 | 另存为 UTF-8 无 BOM |
| 什么都不显示,但无报错 | 坐标系不是地理坐标 | 看坐标量级是否远超 180 | 导出前投影到 WGS 84 |
| 要素跑到海洋或极地附近 | 同上,或经纬度顺序反了 | 检查纬度绝对值是否超 90 | 交换 x/y 或重新投影 |
| 面填充异常、中间挖空 | 环方向反、自相交 | 用几何校验工具跑一遍 | 修复拓扑、统一方向 |
| 中文显示成问号或方块 | 编码不一致 | 用十六进制查看文件头 | 转成 UTF-8 无 BOM |
| 数值比较结果不对 | 数字被存成字符串 | 打印字段类型 | 入库前统一转换类型 |
| 大文件加载卡死 | 体积超出渲染能力 | 看文件大小与要素数 | 简化、剪裁、换格式 |
| 内网页面底图是灰的 | 底图地址不可达或协议混用 | 看网络面板请求 | 换内网底图、统一协议 |
| 刷新页面后 404 | 静态服务缺路由回退 | 直接访问子路径 | 配置try_files |
| 导出的文件没有中文 | 序列化时转义了 | 看文件里是否有\u | 设置不转义输出 |
5. 把 GeoJSON 放进工程链路:校验、瘦身与版本管理
前面几章解决了“能看懂、能转换、能排错”,这一章讲的是怎么让它变成一条可重复执行的链路。手工处理一次两次没问题,但要长期稳定地产出数据,就得把校验、优化、版本管理固化下来。
5.1 入库前的自动化校验流水线
我的做法是在数据入库前设一道强制关卡,包含四个检查项:JSON 语法与结构合法性、坐标范围、几何有效性、字段规范。这四项里前三项可以靠通用工具完成,第四项必须自己写规则。
通用的校验工具有现成的命令行程序,能在几秒内扫完一个文件并列出所有结构问题,比如坐标层级不对、缺少必需成员、类型不合法。几何有效性检查则需要几何库的支持,对每个要素做一次自相交和闭合判断,把无效的挑出来。
字段规范检查最容易被忽略。至少要检查三件事:字段数量是否在预期范围内(防止上游加了一堆没人用的字段)、字段类型是否和约定一致(防止数值变字符串)、必填字段是否为空。这三条写成一个简单的校验脚本,几十行就能覆盖,但能挡掉后续 80% 的数据问题。
import json REQUIRED = {"name", "code"} ALLOWED = {"name", "code", "length_km", "area_km2"} def validate(path): with open(path, encoding="utf-8") as f: data = json.load(f) assert data.get("type") == "FeatureCollection", "顶层不是 FeatureCollection" errors = [] for i, feat in enumerate(data["features"]): geom = feat.get("geometry") props = feat.get("properties") or {} if not geom: errors.append(f"#{i} 几何为空") continue # 字段白名单与必填检查 extra = set(props) - ALLOWED if extra: errors.append(f"#{i} 出现未约定字段: {extra}") missing = REQUIRED - set(props) if missing: errors.append(f"#{i} 缺少必填字段: {missing}") # 坐标范围检查 def walk(c): if isinstance(c, list) and c and isinstance(c[0], (int, float)): lon, lat = c[0], c[1] if not (-180 <= lon <= 180 and -90 <= lat <= 90): errors.append(f"#{i} 坐标超范围: {c[:2]}") elif isinstance(c, list): for item in c: walk(item) walk(geom["coordinates"]) return errors这段代码刻意写得很朴素,目的是能直接跑、能快速改。实际项目里可以把它挂到数据导入的钩子上,校验不通过就拒绝入库并返回明确的错误位置,比事后人工比对高效得多。
5.2 体积优化的几个可量化手段
GeoJSON 体积大是结构决定的,但优化空间也不小。按收益从高到低排列,我常用的手段有这么几个:
第一,坐标精度截断。从 15 位小数砍到 6 位,单个坐标的字符数能少一半左右。一个十万点的数据集,光这一项就能把文件从几兆压到两兆以内。前面给过换算表,6 位对应约 0.11 米,绝大多数展示场景完全够用。
第二,几何简化。用抽稀算法减少节点数,容差按数据的实际比例尺设。省道级别的线数据,容差设到 10 米级别,节点能砍掉六七成,视觉上几乎看不出差别。但要注意:简化会让几何偏离原始位置,涉及面积统计或边界裁定的数据不要做,或者只在展示用的副本上做。
第三,字段裁剪。只保留消费端真正用到的属性。我见过一个图层带二十几个字段,前端实际只用了三个,剩下的全是无效负载。
第四,传输层压缩。服务器开启 gzip 或 brotli,对 JSON 这类重复度高的文本压缩率通常能到 70% 以上,这是性价比最高的一步,配置一次长期有效。
第五,按需切片。数据范围大、用户只看局部时,按行政边界或者瓦片切分,前端按视口请求对应片段。这一条改动成本最高,但收益也最大——把几十兆的全国数据切成一堆小块,单次请求可能只有几百 KB。
具体选哪几条,取决于你的瓶颈在哪。如果瓶颈是首次加载慢,优先做压缩和切片;如果是文件传输占用带宽,优先做精度截断和字段裁剪;如果是渲染卡顿,优先做几何简化。
5.3 版本管理里的现实麻烦与应对
把 GeoJSON 放进版本库有个天然的麻烦:它是一个超长的文本文件,改一个坐标,差异对比就变成几百行红色绿色的噪音,代码评审根本没法看。我试过几种应对方式,效果最好的是按要素拆分文件——一个要素一个文件,文件名用稳定 ID,这样改动只影响对应文件,差异一目了然。代价是文件数量暴涨,需要配套的组装脚本。
如果拆不动,次优方案是只把生成脚本入库,把数据产物排除在外,数据通过流水线从源数据重新生成。这样就回到“数据可复现”的路子上,版本库里只有规则,没有产物。
还有一种情况是坐标精度导致的伪差异:同一份数据在不同机器上重新导出,坐标末几位不一样,差异看起来很大但实际无变化。解决办法就是在导出环节统一精度截断,把不确定的低位直接砍掉,从源头上消除噪音。
最后提醒一句:无论用哪种方案,都别把大体积的 GeoJSON 直接提交到公开的代码托管服务上。单文件超过一定体积会被拒绝推送,而且历史记录里的冗余数据几乎无法彻底清理,后续迁移会很痛苦。
我个人在处理这类数据时最深的体会是:绝大多数“格式问题”其实是坐标系问题和精度问题伪装成的格式问题。所以现在我拿到一份陌生数据,第一件事不是打开看结构,而是先看坐标值的量级——几百以内的,大概率是经纬度,接着确认顺序;上万的,基本可以确定是投影坐标,先投影再说。这个判断只需要两秒钟,但能省掉后面两小时的折腾。
再分享一个小技巧:给团队做数据交付时,在文件名里带上坐标系和精度信息,比如roads_wgs84_6dp.geojson。看着有点啰嗦,但下次有人拿到文件不确定坐标系时,看一眼文件名就不用再来问你一遍。这个习惯我已经保持了几年,省下的沟通时间远超当初写下这几个字符的成本。