简介:这份地图数据包面向从事前端可视化、D3.js 地图练习与地理信息展示的开发者,汇集世界地图及主要国家的 GeoJSON 边界数据,可用于全球概览、国家轮廓展示、区域对比和地图交互练习,帮助减少底图数据搜集与格式整理的时间。压缩包共 25 个文件,以 23 个 json 为主,承载世界与多国地理边界数据,另含 2 个 mht 网页存档作参考,整体约 11.31MB,数据体量较完整,便于按需精简后使用。已有 6266 人学习下载,说明其在同类地图数据中具有一定参考价值。读者可获得覆盖世界及欧洲、亚洲、美洲、非洲、大洋洲多个国家的 JSON 文件,适合做地图下钻、国家切换、D3 地理投影与路径绘制等练习,也能作为可视化项目中的基础地理数据素材。
1. 拿到一份世界地图 GeoJSON:先看清楚它到底是哪一层数据
周日晚上十点,大屏项目只剩世界地图这一块没落地,我随手抓了份世界地图的 GeoJSON 丢给 ECharts,结果画布上多出一条横穿全屏的直线,从左边框一直拉到右边框。这类问题与“数据里有没有国家”无关,问题出在没搞清手里这份东西到底是哪一层数据。这份资源是一套世界地图与主要国家的 JSON / GeoJSON 底图包:一个全球整幅文件,外加按主要国家拆开的单国文件,几何类型是 Polygon 与 MultiPolygon,坐标统一用 WGS84 经纬度。它解决的是“不想从零画边界,又要能改、能裁、能入库”的问题,适合做大屏地理分布图、后台的区域统计、离线环境下的本地底图,也适合当空间数据库的初始数据。
2. GeoJSON 结构拆解:四层嵌套、环绕序与一次跑通的体检脚本
2.1 从 FeatureCollection 往下数四层,以及字段命名的不确定
打开任意一个文件,第一个字段是type,值是FeatureCollection。往下拆只有四层,把这四层记牢,后面所有报错都能对上号。
| 层级 | type | 关键字段 | 说明 |
|---|---|---|---|
| 第 1 层 | FeatureCollection | features | 一个文件就是一个集合 |
| 第 2 层 | Feature | properties / geometry | 一个行政单元,对应一个国家或地区 |
| 第 3 层 | Polygon 或 MultiPolygon | coordinates | 面;岛多的国家一定是 MultiPolygon |
| 第 4 层 | Ring 数组 | [lng, lat] | 环,首尾点必须相同才算闭合 |
MultiPolygon 比 Polygon 多一层嵌套:Polygon 的 coordinates 是“环的数组”,MultiPolygon 的 coordinates 是“面的数组,每个面又是环的数组”。写解析代码时这层差异最容易写漏,一旦漏了,遇到岛国就直接抛异常或渲染成空白。
properties 里通常是身份信息,字段名在同类底图里并不统一:name、NAME、name_zh、NAME_ZH、iso_a2、ISO_A3、continent、type。我的习惯是动手写业务代码前,先把第一条 feature 的 properties 原样打印出来看一遍,比翻文档快。
import json with open('geo/world.json', encoding='utf-8') as f: data = json.load(f) first = data['features'][0] print(json.dumps(first['properties'], ensure_ascii=False, indent=2)) print(first['geometry']['type'], len(first['geometry']['coordinates']))这段的输出决定后面所有代码怎么写:name到底叫什么、有没有中文名、有没有 ISO 码、这个国家是 Polygon 还是 MultiPolygon。参数上没什么可调的,唯一需要注意的是ensure_ascii=False,不加它中文名会全部变成\uXXXX,肉眼看不出字段到底有没有值。
2.2 坐标顺序、环绕序,以及“内外”到底谁说了算
GeoJSON 的坐标顺序是[经度, 纬度],不是[纬度, 经度]。把它写反的后果很直观:整张图会南北颠倒并且整体偏移,落在南极附近。判断方法很简单,取第一个点看第一个分量是否落在 -180 到 180 之间、第二个分量是否落在 -90 到 90 之间,两个分量都能落进去时再看数值分布,经度范围明显比纬度大就对了。
更隐蔽的是环的绕序。RFC 7946 规定外环逆时针、内环顺时针,这套规则叫右手定则。麻烦在于不同渲染库对它的依赖程度不一样:一部分球面几何库靠绕序判断“哪一侧是内部”,方向反了不会报错,而是把整个地球涂满、只剩目标国是空的;平面渲染的库大多不关心绕序,照样画对。同一份数据在两套库里表现不一致,很多“玄学”问题就出在这。
判定绕序用带符号面积,两行代码就够:
def signed_area(ring): """返回环的有符号面积(平方度)。>0 逆时针,<0 顺时针。""" s = 0.0 for i in range(len(ring) - 1): # 环首尾相同,最后一段要单独闭合 x1, y1 = ring[i] x2, y2 = ring[i + 1] s += x1 * y2 - x2 * y1 return s / 2.0参数只有一个 ring,必须是闭合数组,首尾点相同,否则最后一条边会丢。返回值单位是“平方度”,只在判定符号时可信,千万别拿它算真实面积,经纬度在不同纬度上的实际尺度差着数量级。需要修正绕序时,把环reverse()一下再把首点补回末尾即可。
注意:内环(湖泊、飞地)的绕序要求与外环相反。只反转外环不改内环,等于把一个湖变成了一整块陆地。
2.3 一次跑通的体检脚本:顶点数、bbox 与可疑记录
拿到一份新底图,我一般先跑一遍体检,不做任何渲染。要看的指标就四个:feature 数、总顶点数、每个要素的 bbox、有没有 geometry 为 null 的记录。
import json, glob, os def inspect(path): with open(path, encoding='utf-8') as f: data = json.load(f) feats = data['features'] if data['type'] == 'FeatureCollection' else [data] total_pts, issues = 0, [] for ft in feats: props = ft.get('properties') or {} name = props.get('name') or props.get('NAME') or 'unknown' geom = ft.get('geometry') if not geom: issues.append((name, 'geometry 为 null')) continue polys = geom['coordinates'] if geom['type'] == 'MultiPolygon' else [geom['coordinates']] lngs, lats = [], [] for poly in polys: for ring in poly: total_pts += len(ring) if ring[0] != ring[-1]: issues.append((name, '环未闭合')) for lng, lat in ring: lngs.append(lng); lats.append(lat) if lngs and (max(lngs) - min(lngs)) > 180: issues.append((name, '疑似跨越 180 度经线')) return len(feats), total_pts, issues for p in sorted(glob.glob('geo/**/*.json', recursive=True)): n, pts, iss = inspect(p) print(f'{os.path.basename(p):>18} feature={n:<4} 顶点={pts:<7} 疑似问题={len(iss)}') for it in iss[:3]: print(' ', it)逻辑说明:先把 FeatureCollection 和单个 Feature 两种输入统一成列表,避免脚本只能吃全球文件、吃不了单国文件;再按 Polygon / MultiPolygon 分别取面,逐环统计。max(lngs) - min(lngs) > 180这一条是专门抓横跨 180 度经线的国家,正常国家的经度跨度不会超过 180,一旦超过,基本可以确定这条记录在渲染时会拉出一条横线。
参数说明:脚本不修改任何数据,纯只读;输出里的“顶点数”是判断体积的第一手依据,全球整幅在几十万顶点量级时,未压缩体积会在几 MB 到十几 MB 之间,上大屏前必须裁。跑完这一遍,数据能不能用、哪里要修,心里就有数了。
3. 三条渲染路径:ECharts 注册、Leaflet 直挂、PostGIS 入库
3.1 ECharts:registerMap 的时机比数据本身更容易出问题
ECharts 消费 GeoJSON 只有一个入口:先registerMap注册成一个地图名,再在 option 里用这个名字引用。数据格式本身它几乎不挑,Polygon / MultiPolygon 都能吃,真正的问题出在注册时机和字段对齐上。
import * as echarts from 'echarts'; import worldGeo from './geo/world.json'; // 注册名必须与 series.map / geo.map 完全一致,且要在 setOption 之前完成 echarts.registerMap('world', worldGeo); const chart = echarts.init(document.getElementById('map')); chart.setOption({ geo: { map: 'world', roam: true, // 允许缩放平移 nameProperty: 'name', // 与 properties 里的字段名对齐 itemStyle: { areaColor: '#12233a', borderColor: '#3d5a80' }, emphasis: { itemStyle: { areaColor: '#1f4e79' }, label: { show: false } } }, series: [{ type: 'map', map: 'world', nameProperty: 'name', data: [{ name: '某国家名', value: 42 }] // 靠 name 全等关联 }] });关键点有三处。nameProperty必须指向 properties 里真实存在的字段,默认它找name,如果你的数据里叫NAME,图能画出来但数据关联全部失效,颜色永远是同一片。registerMap必须在setOption之前执行,异步拉 JSON 的场景下顺序很容易反过来,结果是白屏且控制台不报错,这是最典型的时序问题。geo组件和map系列不是一回事,前者只负责画底图,后者才承载 data,两个一起用时把series.map指向同一个注册名,否则会出现两份重叠的地图。
需要中文名时不必改数据文件,ECharts 支持nameMap做键映射:
chart.setOption({ series: [{ type: 'map', map: 'world', nameProperty: 'iso_a3', nameMap: { 'RUS': '俄罗斯', 'FJI': '斐济' } // 键与 nameProperty 取值一致 }] });nameMap的键必须和nameProperty指向的字段值严格一致,大小写都不能差。改数据文件 properties 更省事,但下次换底图就得重来一遍,我个人更倾向在代码里维护一张映射表,方便和业务侧的地区编码对齐。
3.2 Leaflet:直挂最省事,大数据量要换渲染器
Leaflet 的路子更直接,L.geoJSON一步到位,样式和交互都用回调处理。
const map = L.map('map', { preferCanvas: true }).setView([20, 0], 2); // 离线环境用本地瓦片目录,别依赖在线底图 L.tileLayer('/tiles/{z}/{x}/{y}.png', { maxZoom: 5, minZoom: 1 }).addTo(map); fetch('/geo/world.json') .then(r => r.json()) .then(gj => { const layer = L.geoJSON(gj, { style: () => ({ weight: 1, color: '#8899aa', fillOpacity: 0.25 }), onEachFeature: (f, l) => { const p = f.properties || {}; l.bindTooltip(p.name_zh || p.name || '未命名'); l.on('click', () => console.log(p.iso_a3, p.name)); } }).addTo(map); map.fitBounds(layer.getBounds(), { padding: [10, 10] }); });preferCanvas: true这一项在要素多的时候差别很明显,SVG 渲染器下每个面都是一个 DOM 节点,全球几百个多边形加上缩放重绘会明显卡顿,切到 canvas 之后流畅很多。fitBounds要用layer.getBounds(),不是gj的 bbox,后者在跨经线的情况下会把视野拉到整个地球。onEachFeature里的点击回调拿的是 properties,业务数据关联同样走 ISO 码或名字,注意别在回调里做重的计算,一次点击可能触发多次重绘。
如果只想画某几个国家的高亮,别去 splice GeoJSON 的 features,那是改动源数据。正确做法是单独建一个L.geoJSON(subset)图层叠在上面,或者用layer.setStyle按要素过滤,前者更容易控制层级顺序。
3.3 PostGIS:入库前先统一几何类型和 SRID
底图一旦要参与空间查询(点在哪个国家、两国距离、区域裁剪),就该进库。用 ogr2ogr 一条命令灌进去,比写解析脚本更快。
# -nlt PROMOTE_TO_MULTI 把 Polygon 统一提升为 MultiPolygon,避免入库后类型混杂 # -lco GEOMETRY_NAME 指定几何列名,-t_srs 明确写成 WGS84 ogr2ogr -f PostgreSQL "PG:host=127.0.0.1 dbname=gis user=gis password=***" \ geo/world.json \ -nln world_geo \ -nlt PROMOTE_TO_MULTI \ -lco GEOMETRY_NAME=geom \ -lco FID=gid \ -lco ENCODING=UTF-8 \ -t_srs EPSG:4326参数逐个说:-nln是目标表名,重复执行会追加而不是覆盖,要么先TRUNCATE,要么加-overwrite;-nlt PROMOTE_TO_MULTI是为了让所有记录类型一致,混着 Polygon 和 MultiPolygon 的表在后面做联合查询时经常报类型不匹配;-lco ENCODING=UTF-8不加的话中文名有可能变成乱码;-t_srs EPSG:4326是显式声明,源数据本身没有 CRS 定义,不写的话入库后 SRID 可能是 0,所有距离计算全部失准。
入库之后两件事必做:建 GiST 索引、跑一遍有效性检查。
CREATE INDEX idx_world_geo_geom ON world_geo USING GIST (geom); -- 自相交、环退化都会让 ST_IsValid 返回 false,这条语句把问题记录挑出来 SELECT gid, name, ST_IsValidReason(geom) AS reason FROM world_geo WHERE NOT ST_IsValid(geom); -- 面积用 geography 算,单位是平方米;用 geometry 算出来的是"平方度",没有物理意义 SELECT name, ST_Area(geom::geography) / 1e6 AS km2 FROM world_geo ORDER BY km2 DESC LIMIT 10;ST_IsValidReason会直接告诉你哪条边自相交,比ST_IsValid只返回 false 有用得多。修的时候优先用ST_MakeValid(geom),但它可能把面变成 GeometryCollection,入库前还得用ST_CollectionExtract(geom, 3)只取面。面积那一句是新手最容易踩的:ST_Area(geom)在 4326 下返回的是平方度,数值看起来合理,实际完全不能用。
4. 拆包与减重:单国分片、mapshaper 简化与量化参数
4.1 单文件全量还是单国分片,看首屏而不是看总量
全球整幅文件的顶点数决定了首屏时间,单国文件的数量决定了缓存粒度。这两件事要分开权衡。
| 方案 | 首屏体积 | 缓存粒度 | 适用场景 |
|---|---|---|---|
| 单文件全量 | 大,一次全下 | 粗,改一个国家全量失效 | 只做全球总览、不做下钻 |
| 全球低精度 + 单国高精度 | 小 | 细,单国独立缓存 | 可下钻的分布图、区域详情页 |
| 全量单国分片 | 极小 | 最细 | 只展示少数几个国家的看板 |
我一般的做法是第二种:全球一份简化到能看清轮廓的版本负责首屏,用户点进某个国家再拉那个国家的完整文件。资源里已经按国家拆好了文件,目录结构大致是geo/world.json加geo/countries/xxx.json,配合一个index.json清单记录每个分片的 ISO 码、文件名、要素数,前端先读清单再决定拉哪个,比硬编码路径好维护。
4.2 mapshaper 简化的百分比是“保留”,不是“删掉”
简化不能随便删点,删过头小国会直接消失。mapshaper 的keep-shapes就是为这个准备的。
# -simplify 10% 表示保留 10% 的顶点;keep-shapes 保证再小的国家也不会被简化掉 mapshaper geo/world.json \ -simplify 10% keep-shapes \ -o precision=0.0001 geo/world.slim.json # 转 TopoJSON 并量化:共享边界只存一次,体积通常还能再降一半 geo2topo -q 1e5 geo/world.slim.json > geo/world.topo.jsonprecision=0.0001是把坐标保留到小数点后 4 位,约等于 11 米精度,做大屏完全够用;保留 6 位的话体积会明显回升,而肉眼在小比例尺下看不出区别。-q 1e5是量化参数,把坐标映射到 10 万个格点上,数值越小体积越小、形状越糙,1e5 到 1e6 是常用区间。
TopoJSON 的代价是前端多一步转换,得引入topojson-client用feature()还原成 GeoJSON 才能喂给 ECharts 或 Leaflet。如果项目里已经有这个依赖,收益是值得的;如果只是临时做个图,直接简化 GeoJSON 更省事。
import { feature } from 'topojson-client'; import topo from './geo/world.topo.json'; // 第二个参数传 object 名,不传则返回全部;还原后才是标准 GeoJSON const geo = feature(topo, topo.objects.world); echarts.registerMap('world', geo);topo.objects里的键名取决于生成时的输入文件名,转之前先console.log(Object.keys(topo.objects))确认一下,传错键名拿到的结构不对,注册后地图是空的。
4.3 按需加载、预压缩与缓存头
单国文件的数量可能上百,一次性预加载是自找麻烦。按需拉,配上静态压缩和长缓存。
async function loadCountry(iso3) { const key = String(iso3).toLowerCase(); const res = await fetch(`/geo/countries/${key}.json`, { cache: 'force-cache' }); if (!res.ok) throw new Error(`未找到 ${iso3} 的地图数据`); return res.json(); }文件名统一小写,能避免大小写不一致导致的 404,也方便 CDN 命中。cache: 'force-cache'让浏览器优先用本地副本,底图这种几乎不变的文件就该这么处理。真正占体积的是服务端的压缩配置:
location /geo/ { gzip_static on; # 直接读预生成的 .gz,省掉实时压缩的 CPU brotli_static on; # 有 .br 就优先发 .br expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; }gzip_static on要求同目录下存在同名.gz文件,构建阶段用gzip -9 -k geo/countries/*.json生成一次就行。GeoJSON 是纯文本,压缩比通常在 5 到 8 倍之间,几 MB 的底图压完可能只剩几百 KB,这一步的收益比抠坐标精度大得多。
5. 避坑与排查:世界地图 GeoJSON 最常见的五类翻车现场
5.1 画布上多出一条横穿全屏的直线
现象:地图渲染出来后,某个国家的位置上有一根很细的横线,从图的一侧穿到另一侧,缩放时它跟着变形。
原因:这个国家横跨 180 度经线,边界在 GeoJSON 里同时含有接近 -180 和接近 +180 的经度值,渲染器按平面坐标连线,于是一路横穿过去。全球整幅文件里这条记录几乎必然存在,岛国和大国都有。
解决:在入库或渲染前把跨经线的面切开,让每个面的经度跨度小于 180。PostGIS 用ST_WrapX(geom, 0, 0)或者ST_Split配一条 180 度经线做切割;纯前端场景下更简单的办法是把负经度加 360 做平移,渲染层面再自己处理视口偏移,代价是底图上的位置会偏,只适合局部展示。数据量不大时,直接在 properties 里给这类要素打个标记,单独用一套样式,视觉上压掉它。
5.2 某几个国家点了没反应、颜色永远不亮
现象:series.data里明明写了名字,地图上对应的国家就是不参与 visualMap 着色,点击事件里也拿不到。
原因:ECharts 靠name全等匹配,底图里的名字是英文全称,业务数据里写的是中文简称或者 ISO 码,两边永远对不上;另一种情况是nameProperty指向了一个不存在的字段,这时它回退到name,看起来“有名字”,实际匹配逻辑已经错了。
解决:先用console.log(worldGeo.features.map(f => f.properties.name))把底图里的真实名字打出来,再决定业务侧怎么对齐。要么在数据侧统一,要么用nameMap做映射,要么直接把nameProperty换成 ISO 码这种不会变的键。名字里带括号、带连字符、带空格的记录要单独处理,全等匹配对这类字符极敏感。
5.3 图形反了:整个地球被涂满,只剩目标国是空的
现象:换了一套渲染库之后,同样的数据画出来是“反”的,颜色填在了国土外面。
原因:外环绕序与库的预期相反。球面几何库靠绕序判断内部,方向反了就变成“全世界减去这个国家”。这类问题不报错,只在视觉上呈现,排查时很容易怀疑到数据缺失上。
解决:用 2.2 里的带符号面积扫一遍,外环为负的全部reverse()并把首点补回末尾,内环保持相反方向。批量处理时写成脚本跑一次,别手工改。改完再跑一次面积检查确认符号,不要凭感觉。
5.4 ISO 码字段是 “-99”,关联全表成了未知地区
现象:按 ISO_A3 关联业务数据,有几条记录永远关联不上,日志里显示键值是-99。
原因:公开底图里,部分国家或地区的 ISO 码字段会被填成占位值(常见的是字符串-99)而不是真实代码,原因是编制时的归属争议。这不是数据损坏,是原始数据就这样。
解决:入库或加载时先扫一遍SELECT iso_a3, count(*) FROM world_geo GROUP BY 1 ORDER BY 2 DESC,把-99和空值挑出来,按名字或坐标手工补一张映射表。补的时候别直接改源文件,单独维护一份 patch 字典,换底图时把 patch 再套一次,比改原始文件好追溯。
5.5 入库时报自相交,或者面积算出来是负数
现象:ogr2ogr导入顺利,但ST_IsValid一片 false;或者面积查询结果里出现负数、数量级明显不对。
原因:真实边界数据在简化过程中会产生自相交和退化边,尤其是海岸线密集的区域;面积是负数则多半是绕序反了,ST_Area对方向敏感。另一个高频原因是几何列 SRID 是 0,::geography转换时直接报错或返回离谱数值。
解决:先ST_IsValidReason定位,再ST_MakeValid修复并ST_CollectionExtract(geom, 3)只保留面;SRID 用SELECT UpdateGeometrySRID('world_geo','geom',4326)补上;面积统一走geom::geography。
提示:修复完一定要重新数一遍要素数。
ST_MakeValid有可能把一个面拆成多个,要素数变了意味着后面的统计口径也跟着变。
6. 上线前的自动校验:把地图数据当成会回归的代码
底图不像业务代码那样天天改,但一旦换了版本,问题往往是静默的:某个国家不见了、面积算错了、首屏多了两秒。我的做法是把它当代码管,每次更新跑一遍校验,并把关键指标写进清单文件留档。
import json, glob, os, hashlib def signed_area(ring): s = 0.0 for i in range(len(ring) - 1): x1, y1 = ring[i]; x2, y2 = ring[i + 1] s += x1 * y2 - x2 * y1 return s / 2.0 manifest = {} for path in sorted(glob.glob('geo/countries/*.json')): with open(path, encoding='utf-8') as f: raw = f.read() data = json.loads(raw) feats = data['features'] pts = 0; ring_issues = 0 for ft in feats: geom = ft['geometry'] polys = geom['coordinates'] if geom['type'] == 'MultiPolygon' else [geom['coordinates']] for poly in polys: for ring in poly: pts += len(ring) if ring[0] != ring[-1] or signed_area(ring) <= 0: ring_issues += 1 manifest[os.path.basename(path)] = { 'features': len(feats), 'vertices': pts, 'ring_issues': ring_issues, 'sha1': hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12], 'bytes': len(raw.encode('utf-8')) } with open('geo/manifest.json', 'w', encoding='utf-8') as f: json.dump(manifest, f, ensure_ascii=False, indent=2, sort_keys=True) print(f'共 {len(manifest)} 个分片,总顶点 {sum(v["vertices"] for v in manifest.values())}')脚本里三个指标最有用:vertices突然翻倍,说明换了高精度源或者简化参数写错了;ring_issues大于 0,说明绕序或闭合有问题,前端大概率会出反色;sha1用来做版本比对,两次提交之间只有少数几个分片变化时,缓存可以只失效那几个,不必全量刷新。清单文件跟着代码一起提交,谁改了什么、体积涨了多少,diff 里一眼能看出来。
另一个我常用的技巧是在 CI 里加一条阈值:单个分片超过 300 KB 就失败。GeoJSON 是纯文本,体积失控几乎等价于首屏变慢,与其等线上监控报警,不如在合并前就拦下来。
血泪经验是有一回换底图后没跑校验,某个岛国的环少了个闭合点,ECharts 没报错,那个国家就是画不出来,业务方连着三天以为数据里没这个国家。从那以后我每次更新底图,都强制走一遍“体检脚本 → manifest diff → 单个分片体积阈值”这三步,再进构建。希望帮到你。
本文还有配套的精品资源,点击获取