ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

各国地图GeoJSON字段校验、渲染与简化实战

各国地图GeoJSON字段校验、渲染与简化实战

简介:面向地图可视化、GIS制图与应用开发者的世界国家/地区GeoJSON数据合集,覆盖各国及部分地区的多边形边界,可用于解决底图数据零散、格式不统一的问题,适合前端地图、数据大屏、空间分析与教学演示等场景。压缩包共约2000个文件,主体为JSON格式的GeoJSON边界数据,另含少量md说明、gitignore与unlicense许可文件,整体仅1.69MB,轻量易取用。数据源自Natural Earth,边界经过制图优化,兼顾视觉美观与小规模GIS软件使用需求;按国家或地区拆分组织,便于按需检索和单独加载,可支撑区域高亮、地理围栏、分级统计填色及地图底图搭建。已有3695人学习下载,适合需要现成世界地图JSON、希望减少数据清洗成本的中初级开发者与制图人员参考。

1. 拿到一套各国地图 json 数据,先别急着画

上周帮一个做跨境业务后台的团队加班,需求就一句话:世界地图上点哪个地区,右边出这个地区的汇总。前后端都写完了,卡在数据上。网上搜到的 geojson 大全,有的文件里properties只有个英文名,跟数据库主键对不上;有的国家地图 json 数据精度糙到海岸线像锯齿;还有的把一个地区拆成三个碎文件,按 iso 码聚合直接掉数据。这份资源把各地区边界按统一命名切成单文件,配一套字段口径一致的 properties,正好补上这段最枯燥的活。它适合三类人:做地图选区的后端和前端、按地区切统计口径的数据分析、拿边界做空间计算的算法同学。下面按我实际拆包的顺序走:字段怎么读、渲染怎么选、体积怎么瘦、坑在哪。

2. 拆包先看结构:命名规则、properties 口径与 RFC 7946 硬约束

刚拿到这套数据时,我做的第一件事不是打开地图看效果,而是jq扫一遍字段。原因很实际:地图渲染是最容易被“看起来对了”骗过去的环节,画出来能看,不代表按 iso 码聚合时不会丢一半数据。这一步花二十分钟,后面能省掉一整轮返工。

2.1 目录分层:按 iso2 切分的收益与代价

这类各国地图 json 数据通常不会是单个巨型文件,而是分成三到四层,各层解决不同问题:

路径内容典型用途
index.json每个地区的 iso2、中英文名、bbox 元信息下拉框、搜索框、懒加载前的清单
<iso2>.geojson单地区 FeatureCollection,一般只含一个 Feature点选后二次加载、单区域详情图
world.geojson全量合集首屏世界总览
world.lite.geojson简化后的合集移动端、弱网、缩略图场景

按 iso2 切分的最大好处是懒加载:首屏只下发合集,用户点开某地区再去取单文件。代价是文件名大小写必须严格一致,Windows 上不敏感、Linux 上敏感,本地跑得好、上了容器就 404,这种血泪经验我在两个项目里都遇过。统一做法是把文件名和properties.iso_a2都转大写,别留第二种写法。

先扫一遍量级,找出哪些文件是“重灾区”:

# 统计每个文件的要素数与文件大小,按体积倒序,先盯住头部几个 for f in geo/*.geojson; do n=$(jq '.features | length' "$f" 2>/dev/null) s=$(du -k "$f" | cut -f1) echo "$s KB $n features $f" done | sort -nr | head -20

jq '.features | length'取要素数量,du -k取 KB 体积,两者倒序排。这里带的参数含义要清楚:如果一个文件要素数是 1 但体积特别大,说明它的多边形顶点极密(典型是岛屿多、海岸线碎的地区),后面第 4 章的简化主要就是针对它;如果要素数远大于 1,说明这个“国家文件”里塞了多个独立要素,聚合时得先按 iso 码做一次 merge,否则同一地区会被算两遍。

2.2 properties 字段:三层口径决定你能做多少事

字段是最容易踩坑的部分。同一份 geojson 里,英文名、中文名、编码、所属洲经常混着三种命名风格。我一般按三层来归:

字段类型用途注意事项
namestring英文展示名有的数据集带括号备注,展示前要清洗
name_zhstring中文展示名可能缺失,必须有 fallback
iso_a2string主键,两位码统一转大写,别和iso2、ISO_A2混用
iso_a3string三位码,对接外部系统少数特殊区域为空字符串而非 null
continentstring分组、图层过滤取值口径要统一,别一半简写一半全称
bboxarray[4]预筛、视角定位顺序是[minLng, minLat, maxLng, maxLat]

主键选iso_a2而不是名称,理由很直白:名称会有多语言、别名、拼写变体,编码不会。但编码也有坑——空字符串和null在不同解析器里行为不一样,JS 里"".toUpperCase()得空串,Python 里None直接抛异常。我一般在校验阶段就把这两类统一成null,业务层只判null。

还有一个常被忽略的点:properties里不要存计算出来的字段(比如面积、中心点)。这些随投影和算法变化,存进去就等于把某一次的算法结果固化,后面想换算法得全量重算。中心点、面积这类,用的时候现算,或者单独建一份索引文件。

2.3 RFC 7946 的三条硬约束

GeoJSON 在 2016 年定稿后,规范收得很紧,几条约束如果破了,表现是“能渲染但位置全错”:

第一,坐标顺序是[经度, 纬度],跟很多人直觉相反。写反了,地图会整体镜像或者所有点挤到赤道附近,而且因为数值范围都合法(经度 ±180、纬度 ±90),校验脚本不报错,只有肉眼能看出来。

第二,线性环必须闭合,第一个点等于最后一个点。少了这个闭合点,部分渲染库会自己补、部分直接丢弃该多边形,同一份数据在不同前端表现不一致。

第三,外环逆时针、内环顺时针(右手定则)。这条最容易被简化工具破坏,破了之后某些渲染器在填充时会出空洞或者反色。

第四,crs字段已废弃,一律按 WGS84 处理。如果你的数据里还带crs声明成别的坐标系,别指望渲染库认它,得自己先转。

上线前我固定跑一遍自检:

import json, pathlib def check_ring(ring): # RFC 7946 要求线性环首尾闭合,不闭合的多边形在部分渲染器里会被直接丢掉 return "ring_not_closed" if ring[0] != ring[-1] else None def check_file(p): data = json.loads(p.read_text(encoding="utf-8")) issues = [] for feat in data.get("features", []): geom = feat.get("geometry") or {} gtype = geom.get("type") if gtype == "Polygon": polys = [geom["coordinates"]] elif gtype == "MultiPolygon": polys = geom["coordinates"] else: issues.append(f"{p.name}: unexpected geometry {gtype}") continue for poly in polys: err = check_ring(poly[0]) # poly[0] 是外环 if err: issues.append(f"{p.name}: {err}") # 主键缺失意味着后面按 iso 码聚合时会静默丢数据,必须在这里拦住 if not feat["properties"].get("iso_a2"): issues.append(f"{p.name}: missing iso_a2") return issues bad = [] for p in pathlib.Path("geo").glob("*.geojson"): bad += check_file(p) print(f"{len(bad)} issues") for i in bad[:20]: print(i)

这段脚本只做三件事:几何类型白名单、环闭合、主键非空。参数上没什么可调的,geo目录按实际路径改;返回的 issue 列表建议直接当 CI 的一步,超过阈值就让流水线红掉。我吃过一次亏:一个地区文件的iso_a2被写成小写,前端按大写匹配,那个地区的数据整整一周没出现在报表里,没人发现,因为地图上它是画出来的。

3. 三条渲染路径:ECharts、Leaflet、服务端裁剪

数据校验过了,接下来是选渲染方案。常见的三条路,选错不是性能问题,是返工问题。判断依据只有两个:要不要底图瓦片、要素量级多大。

3.1 ECharts registerMap:最快出一张可点的图

如果需求是“世界总览 + 点选高亮 + 按值染色”,ECharts 的map系列是最短路径,一份 geojson 注册进去就能出图,不用底图瓦片,也就没有瓦片服务和密钥的麻烦。

import * as echarts from 'echarts'; import worldJson from './geo/world.geojson'; // 注册名必须和 series.map 完全一致,写错不会报错,只会得到一张空白画布 echarts.registerMap('world', worldJson); const chart = echarts.init(document.getElementById('map')); chart.setOption({ series: [{ type: 'map', map: 'world', roam: true, // 允许缩放拖拽 nameProperty: 'name_zh', // 关键:默认取 properties.name,中文名要显式指定 itemStyle: { areaColor: '#eef3f8', borderColor: '#b9c6d4', borderWidth: 0.6 // 描边太粗会在相邻边界处叠出明显色带 }, emphasis: { itemStyle: { areaColor: '#ffd591' } }, select: { itemStyle: { areaColor: '#91caff' } } }] });

nameProperty是最容易漏的参数。ECharts 拿它作为要素的唯一标识去做高亮和事件匹配,默认读name;你界面上显示中文、传入的数据却按英文名匹配,点上去毫无反应。borderWidth也别贪粗,0.6 到 1 之间比较稳,超过 2 之后相邻地区之间会糊成一条深色带,视觉上像边界“错位”。

要素量级上,ECharts 的map系列对几千个要素还算从容,但如果把每个小岛都算一个要素,几万个也是有的,这时首屏会明显卡顿,该走第 4 章的简化流程。

3.2 Leaflet / MapLibre:叠加到底图上的两个细节

需要真实地形、卫星底图,或者要在边界上再叠散点、飞线,就用 Leaflet 这类地图库。加载逻辑很短:

const map = L.map('map', { worldCopyJump: true, // 横向拖拽跨经线时复制世界,避免拖出空白 preferCanvas: true // 要素多时切 canvas 渲染,比默认 SVG 快一个量级 }).setView([20, 0], 2); fetch('/geo/AAA.geojson') .then(r => r.json()) .then(gj => { L.geoJSON(gj, { style: { weight: 1, color: '#5b7fa6', fillOpacity: 0.25 }, onEachFeature: (f, layer) => { layer.on('click', () => { // 属性直接从要素里取,别用图层 index 反查,顺序不保证稳定 console.log(f.properties.iso_a2); }); } }).addTo(map); });

两个细节值得单独说。一是preferCanvas,默认 SVG 渲染在要素过千时每次缩放都要重排 DOM,卡到没法用;切 canvas 之后同样的数据能流畅拖动。二是worldCopyJump,世界地图横向是循环的,不开启的话用户往左拖过 180 度经线就是一片灰。另外样式里fillOpacity建议别超过 0.4,底图会被完全盖住,用户分不清哪是陆地哪是海。

MapLibre 那套矢量瓦片的路线适合要素量大、需要分级加载的场景,但它需要先把 geojson 转成矢量瓦片,属于另一条工具链,量级不到十万要素之前我不太建议上,转换和调参的时间成本不划算。

3.3 服务端按 bbox 裁剪:只下发看得见的部分

数据量再往上走,最有效的一招不是前端优化,是别把全世界都发过去。用户当前视口就那么大,服务端按 bbox 裁一刀,响应体能小一到两个数量级。

from shapely.geometry import shape, box, mapping import json def clip_collection(gj, minx, miny, maxx, maxy): win = box(minx, miny, maxx, maxy) out = [] for f in gj["features"]: g = shape(f["geometry"]) # 先用 bbox 粗筛,绝大多数要素在这一步就被排除了,省掉昂贵的精确求交 if not g.intersects(win): continue c = g.intersection(win) if c.is_empty: continue out.append({ "type": "Feature", "properties": f["properties"], "geometry": mapping(c) }) return {"type": "FeatureCollection", "features": out}

minx/miny/maxx/maxy就是视口四个边界(经度、纬度顺序),前端传上来记得做范围校验,minx > maxx直接拒绝。intersects是粗筛,intersection才是真裁剪,两步都留着是因为后者开销大,能用前者过滤掉的就别进后者。

有一个副作用要提前想到:裁剪之后几何类型会变。原本的Polygon被切了一刀可能变成MultiPolygon,原本闭合的环在窗口边缘会多出一条直边。前端如果按类型做了分支逻辑,得允许这种降级。另外裁剪会切断多边形,边界上的环闭合仍要保持,这个 Shapely 会处理,但如果你后续还要做简化,顺序必须是先裁剪后简化,反过来会出现跨窗口的碎边。

4. 体积与精度:从几十兆压到几百 K 的四步

原始边界数据是为“精确”准备的,不是为“传输”准备的。我经手过的项目里,没做任何处理的合集文件塞进首屏,弱网下能白屏五六秒。压缩这件事有四步,顺序不能乱。

4.1 坐标精度截断:先砍掉没有意义的小数位

第一步最便宜也最有效。地理坐标用十进制度表示,纬度 1 度约 111 公里,那么小数位和实际距离的换算是固定的:

小数位约合距离适用场景
71.1 厘米测绘、地籍,地图展示纯属浪费
51.1 米城市级放大到 18 级仍看不出差别
411 米国家级总览、移动端
3110 米缩略图、大屏概览

大部分展示型需求 4 位就够,5 位是安全值。

def round_coords(obj, nd=5): # 递归处理 coordinates 里任意层级的点数组,nd 为保留的小数位数 if isinstance(obj, (int, float)): return round(obj, nd) if isinstance(obj, list): return [round_coords(i, nd) for i in obj] if isinstance(obj, dict): return {k: round_coords(v, nd) for k, v in obj.items()} return obj # 只对 geometry 做截断,properties 里的数值字段不要碰 feat["geometry"] = round_coords(feat["geometry"], 5)

递归写法比json.dumps(float_format)那种字符串层面替换安全,后者会把properties里的面积、人口也一起改掉。nd默认给 5,实测下来一个合集文件通常能缩掉三到五成,且肉眼看不出差别。

4.2 拓扑简化:mapshaper 的参数怎么定

第二步是简化顶点,这里有唯一一个必须记住的原则:要做拓扑简化,不要逐个要素独立简化。独立简化时,相邻两个地区共享的那条边界会各自算各自的点,简化完必然对不上,地图上就是一条条裂缝。拓扑简化会先建共享边界的拓扑,只保留一份点,简化后仍然严丝合缝。

# -simplify 12% 保留 12% 的顶点,keep-shapes 防止极小要素被简化到消失 # -filter-slivers 过滤面积过小的碎多边形,单位可写 km2 # -o precision=0.0001 输出时统一到 4 位小数,相当于再截断一次 npx mapshaper geo/world.geojson \ -simplify 12% keep-shapes \ -filter-slivers min-area=2km2 \ -clean \ -o format=geojson precision=0.0001 out/world.lite.geojson

12%这个比例不是拍脑袋:对国家级总览,8% 到 15% 之间视觉上基本无损,再低海岸线开始出棱角;keep-shapes必须带,不带的话一些小岛会被简化到顶点不足、整个多边形消失,而这些小岛往往就是用户要点选的地区。-clean会修掉简化过程中产生的自相交,代价是耗时略增,但能避免渲染时的奇怪填充。输出precision=0.0001等于顺手做了 4.1 那一步,两步合一。

4.3 小岛与 MultiPolygon:删还是留看你的业务

第三步是取舍。一个地区的 MultiPolygon 里可能挂着几十个面,大部分是面积不到一平方公里的小岛。全留,体积压不下来;全删,用户搜索某个岛屿地区就点不到。

我的做法是分两档阈值:

from shapely.geometry import shape, MultiPolygon MIN_PART = 0.5 # 单个多边形最小面积,单位与坐标系一致(度² 或投影后 m²) MIN_TOTAL = 5.0 # 整个地区保留的最小总面积,防止岛国被削成空壳 def trim_multipolygon(geom): polys = list(geom.geoms) if isinstance(geom, MultiPolygon) else [geom] kept = [p for p in polys if p.area >= MIN_PART] if sum(p.area for p in kept) < MIN_TOTAL: # 总面积跌破底线时,退而保面积最大的那一个,宁可少一个岛也别丢整个地区 kept = [max(polys, key=lambda p: p.area)] return MultiPolygon(kept) if len(kept) > 1 else kept[0]

关键在MIN_TOTAL那道兜底。只按单面面积过滤,岛国的所有面都小,会被全部删掉,最终这个地区在地图上就没了——这种翻车我在一个统计后台里见过,某个地区的汇总永远是 0,查了两天才发现是数据清洗阶段被自己删空的。阈值单位也要留神,未投影的经纬度坐标下“度²”这个量没有物理意义,靠近极地会被严重放大,要精确控制面积就先投影再算。

四步走完,一般能从几十兆压到几百 K 的量级,首屏体验的差别是数量级的。

5. 避坑与排查:五个现场记录

5.1 相邻地区边界出现白缝或闪烁

现象:世界总览图上,两个相邻地区之间有一条一像素宽的白线,缩放时白线还会闪。

原因:两个来源叠加。一是数据被逐个要素独立简化过,共享边界的顶点不一致,中间留了细缝;二是渲染器给每个要素单独描边,两条描边各占半个像素,视觉上就成了缝。

解决:换成拓扑简化(4.2 那一步),保证共享边界只有一份顶点;渲染层把borderWidth降到 1 以下,或者干脆把描边色设成和填充色一致,只在emphasis状态下显边。如果数据已经改不动,把底图背景色调成和陆地填充同色,白缝会被吃掉。

5.2 海边城市点选被判成境外

现象:用户在海岸线附近点选,明明点在陆地上,返回的却是空或者相邻地区。

原因:射线法判定点在多边形内时,“落在边上”这种情况不同库的默认行为不一样,有的算内、有的算外。加上海岸线经过简化,原来的点可能已经落到多边形外侧几米。

解决:判定函数显式指定边界策略(Turf 的booleanPointInPolygon支持ignoreBoundary参数),业务上再补一层容差——点选失败时向外扩一个小半径(比如 2 公里)重试一次。真正稳妥的做法是服务端维护一份行政中心点表,点选先匹配最近的中心点,再用多边形判定兜底。

5.3 跨 180 度经线的地区被拉成横贯全图的长条

现象:某个地区在地图上变成一条横穿整个世界的细长条,或者填充反过来覆盖了大半张图。

原因:环跨越 ±180 度经线时,坐标会从 +179 直接跳到 -179,平面渲染把它当成一条“往回走”的线,于是一路画穿整个地图。

解决:渲染前按 180 度经线把多边形切分,让每个子多边形的经度范围连续;或者把经度整体平移到 0~360 区间再画。切分要在数据侧做,前端做不了,渲染库不认这种跨线环。反正这类地区数量极少,也可以单独标记,展示时用中心点代替面。

5.4 中文名变乱码或属性整体丢失

现象:地图上显示的标签是问号方块,或者properties里的字段读出来全是undefined。

原因:编码和结构两种。文件存成了 GBK 而解析按 UTF-8 读;或者某些工具在导出时把属性挂到了Feature外层而不是properties里面,GeoJSON 规范要求必须在properties下。

解决:统一 UTF-8 无 BOM 保存,加一步写文件时显式指定encoding="utf-8";解析后先断言features[0].properties存在,不存在就在导入阶段直接报错,别让它流到前端。BOM 这个小东西特别隐蔽,JSON 里有 BOM,json.loads会直接抛异常,报错信息还不指向根因。

5.5 简化后总面积对不上

现象:各地区面积求和,跟业务方给的参考值差了百分之几,而且偏差集中在几个特定地区。

原因:两个可能。一是简化把尖角和细长半岛削掉了,面积系统性偏小;二是面积算法的口径不同——用平面公式算经纬度坐标,高纬度地区会被严重高估,得用球面公式(Turf 的area默认就是球面)。

解决:先确认算法口径统一,再拿简化前后的面积做比值,如果某地区跌破 0.95 就把简化比例调回去单独处理。面积这类值我现在的做法是一律不落库,每次按需现算,省得数据版本和算法版本对不上。

6. 进阶:把边界数据做成一个点选底座

单张地图渲染只是起点,真正费劲的是把这份数据变成服务:任意坐标进来,返回它属于哪个地区。这个能力一旦有了,报表、风控、物流分区都能复用。

6.1 bbox 预筛加空间索引的点选实现

最朴素的做法是遍历所有要素逐个做点在多边形内判定,两百多个地区、每个地区几千个顶点,一次点选就是几十万次运算,交互上能明显感觉到延迟。加一层 bbox 预筛,情况立刻不同:

import * as turf from '@turf/turf'; // 启动时算一次并缓存:每个要素的 bbox + 原始要素引用 const index = features.map(f => ({ f, bbox: turf.bbox(f) })); function pick(lng, lat) { const pt = turf.point([lng, lat]); for (const { f, bbox } of index) { // 矩形粗筛,四个比较就排除掉绝大多数候选 if (lng < bbox[0] || lng > bbox[2] || lat < bbox[1] || lat > bbox[3]) continue; // 精确判定只在粗筛命中的少数要素上跑 if (turf.booleanPointInPolygon(pt, f)) return f.properties; } return null; }

turf.bbox返回[minLng, minLat, maxLng, maxLat],比较顺序别写错。这段代码有两个已知边界:一是跨 180 度经线的地区 bbox 会是[-180, ..., 180, ...],粗筛永远命中,等于没筛,这类地区建议单独拿出来做后置判断;二是纯前端方案只适合几千个要素,再往上该换服务端,PostGIS 的ST_Contains配 GiST 索引,百万级要素也是毫秒返回。

6.2 上线前的三项验证

数据类资源最怕的不是报错,是静默错。我现在固定跑这三项:

验证项方法通过标准
覆盖完整性拿业务主键清单与文件清单做差集差集为空,缺失项要显式登记
几何合法性逐要素跑is_valid,检查自相交与环方向非法要素数为 0
面积一致性球面面积求和与参考值比对相对偏差在 0.5% 以内

第三项最容易被跳过,也最容易暴露问题。偏差超过 1% 通常意味着简化过头或者面积算法用了平面公式,两种情况都会让下游的分区统计跟着错。

要提醒一句:不要自己拼数据源。同一个产品里,如果一部分地区来自 A 数据集、另一部分补自 B 数据集,接缝处的边界画法会不一致,视觉上就是断线,统计上就是面积重叠或漏算。这类问题排查起来极费时间,因为每一份数据单独看都是对的。

从那以后我每次接地图类需求,都强制先跑一遍字段自检和面积比对,再动手写渲染。这一步慢二十分钟,能省掉后面两天。这份各国 geojson 数据的价值也在这里——它把最枯燥的切分和字段统一做完了,你拿到手可以直接进业务逻辑。需要的话按标题搜一下就能找到,希望帮到你。

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

返回列表