首页 / 资讯中心 / 文章详情

全国省市边界JSON解析、坐标转换与地图渲染实践

全国省市边界JSON解析、坐标转换与地图渲染实践 ★ FEATURED ARTICLE
简介面向地图可视化开发、地理信息教学与区域数据整理场景这份中国各省市行政区边界坐标数据以 GeoJSON 形式组织可直接用于在 Web 或桌面地图上描边、填充与区域交互帮助开发者省去逐省收集边界点位的重复工作也适合做区域统计图与空间范围校验。压缩包共 36 个文件以 35 个 json 边界数据和 1 个 js 文件为主前者按省级行政区组织坐标集合后者便于脚本侧调用或统一组织数据整体约 415KB体量轻便、加载友好。以北京密云县为例单个 json 内含 FeatureCollection 结构、区域编码、名称、中心点与多边形坐标字段直观便于解析和二次处理。目前已有 2484 人学习下载适合需要快速搭建省市边界图层、制作区域统计地图或做地理数据入门的读者参考使用。1. 全国省市边界 JSON 落到项目里第一件事是验结构而不是画图拿到一份全国各省市地图边界坐标 json 文件很多人的第一反应是直接echarts.registerMap或者L.geoJSON放进去看见轮廓出来了就收工。真正容易出问题的环节都在后面省份对不上、区域不上色、点位整体偏移几百米、放大到市级边界糊成一团锯齿、切到县级发现文件大到浏览器直接卡死。这份资源解决的是把省市边界坐标变成能直接塞进项目的结构化数据这件事。它给的是纯几何加属性不是底图也不是瓦片服务能不能用得顺手取决于你后面怎么解析、怎么配坐标系、怎么压体积。做数据看板、区域热力图、行政区划下钻、离线地图的前后端同学都用得上。2. 拆开一个 Featureproperties、geometry 和坐标层级同一个省界 JSON从不同渠道拿到的字段命名、几何类型、精度可以完全不一样。有人拿到的是标准 GeoJSON有人拿到的是早期 ECharts 那种{ 某省: {…} }的字典结构还有人拿到的是坐标数组外面裹了一层自定义元信息。所以第一段代码永远不是渲染而是探测。2.1 先跑探针别急着相信任何字段说明下面这段脚本不改数据只打印结构。它能在十秒内告诉你要素有多少、几何类型分布、属性键叫什么、坐标嵌套几层。后面所有处理逻辑都建立在这几个数字上。import json from collections import Counter # utf-8-sig 能同时吃掉带 BOM 和不带 BOM 的文件避免 json.load 直接抛异常 with open(province_boundary.json, encodingutf-8-sig) as f: data json.load(f) print(顶层 type:, data.get(type)) print(要素数量:, len(data.get(features, []))) # 统计 geometry 类型null 也要算进去很多数据缺失就藏在这里 types Counter( (feat.get(geometry) or {}).get(type, NULL) for feat in data[features] ) print(geometry 分布:, types) # 打印前三个要素的属性键确认命名风格小写 name 还是大写 NAME有没有 adcode for feat in data[features][:3]: props feat.get(properties) or {} print(sorted(props.keys()), -, props.get(name)) # 递归量坐标嵌套深度3 层是 Polygon4 层是 MultiPolygon def depth(obj): return 1 depth(obj[0]) if isinstance(obj, list) and obj else 0 print(首个要素坐标深度:, depth(data[features][0][geometry][coordinates]))Counter那一步是我最看重的只要出现NULL或者GeometryCollection后面用任何绘图库都可能直接崩得先把这几类要素挑出来单独处理。depth函数看着简单但它比读文档快——很多文件标题写省界实际混进了市级甚至县级要素嵌套深度会露馅。2.2 四种 geometry 类型对应的坑标准 GeoJSON 的坐标顺序是[经度, 纬度]和很多人习惯的纬度在前正好相反。把顺序搞反轻则整体错位到极北区域重则被渲染器直接判为非法纬度抛异常。这是最经典的翻车点而且因为地图往往还能画出一团东西来很容易被忽略。geometry.typecoordinates 嵌套典型来源处理注意Polygon[ring][point][lng,lat]单块内陆区域ring[0]是外环ring[1:]是洞MultiPolygon[polygon][ring][point]有岛屿、飞地的区域算面积要遍历所有 polygon 累加GeometryCollection混合结构手工拼装或二次加工的数据渲染器兼容性差建议拍平成 MultiPolygonnull无数据缺失直接过滤否则绘图库抛异常沿海和有岛屿的行政区几乎必然是 MultiPolygon。如果只取coordinates[0]画出来的地图会少掉一大块而且是那种看起来还行但面积明显不对的少法很难第一眼发现。2.3 环的方向决定了它是实心还是镂空RFC 7946 规定外环逆时针、内环顺时针但实际拿到的数据里方向经常是乱的。方向反了的直接表现是某个区域被渲染成一个大洞或者两个相邻区域的填充色互相抵消。用 shapely 统一方向最省事。from shapely.geometry import shape, mapping from shapely.geometry.polygon import orient def normalize_winding(feat): geom shape(feat[geometry]) if geom.geom_type Polygon: # sign1.0 表示外环逆时针、内环顺时针符合 RFC 7946 geom orient(geom, sign1.0) elif geom.geom_type MultiPolygon: geom type(geom)([orient(p, sign1.0) for p in geom.geoms]) feat[geometry] mapping(geom) return feat data[features] [normalize_winding(f) for f in data[features] if f.get(geometry)]orient的sign参数是方向约定1.0对应右手定则下的外环逆时针。如果你的渲染器是反着来的少数老版本库确实如此把它改成-1.0再跑一遍。注意这段代码同时做了过滤把geometry为空的要素被剔除掉了——这比留着让下游报错好排查得多。2.4 主键用 adcode别用 nameproperties.name是给人看的properties.adcode是给程序用的。业务表里写某某自治区边界文件里写某某两个字符串一比较就是false地图上那块区域就永远是默认灰色。带自治区特别行政区这类后缀的全称和常用简称几乎必然对不上。adcode 是 6 位数字编码前两位标识省级、前四位标识市级、六位标识县级。做下钻的时候直接用前缀切片就能分组不用维护任何名称映射表。我在项目里一般会做一层转换对外暴露业务 ID对内一律用 adcode 做 join key名称只用来显示。3. 三种渲染落地ECharts、Leaflet 与 geopandas数据验完结构接下来是选择一个承载方式。选型的判断标准其实很朴素只看静态区域着色和鼠标悬浮选 ECharts要叠底图、要打点、要画轨迹选 Leaflet 这类 GIS 库要做统计计算、要出报告图选 Python 侧的 geopandas。三者的输入都是同一份 GeoJSON但参数完全不同。3.1 EChartsregisterMap 之后必须盯住的三个参数ECharts 是消费这类边界文件最省事的方式但它有几个默认值会让你怀疑数据有问题。import * as echarts from echarts; import geoJson from ./province_boundary.json; // 注册 key 不要用库内置的地图名避免内置地图和自定义地图互相覆盖 echarts.registerMap(custom-province, geoJson); const chart echarts.init(document.getElementById(map), null, { renderer: canvas // 市级以上要素数量大时canvas 比 svg 流畅得多 }); chart.setOption({ series: [{ type: map, map: custom-province, nameProperty: adcode, // 数据绑定用 adcode不用 name roam: true, // 允许缩放平移 aspectScale: 0.85, // 默认 0.75高纬度区域视觉上会被压扁 itemStyle: { areaColor: #eef3f9, borderColor: #9db3c8, borderWidth: 0.6 }, emphasis: { itemStyle: { areaColor: #ffd591 } }, data: [{ name: 110000, value: 128 }] // name 的类型要和 properties.adcode 一致 }] });nameProperty指向哪个字段series.data[].name就必须和它严格相等。这里有个隐蔽的坑如果adcode在 JSON 里是数字series.data里的name写成字符串110000某些版本下匹配不上区域依旧是灰的。稳妥做法是在预处理阶段把所有 adcode 统一String()一遍。aspectScale默认值是 0.75它本质上是给经度方向做了一次近似压缩让全国轮廓看起来不那么胖。但如果你只画单个高纬度区域0.75 会让它明显变形得按实际 bbox 的宽高比去调。我一般先用 1.0 看一眼真实比例再往回调。3.2 下钻先把全国市级文件按省拆开全国市级边界一次性加载在现代浏览器上能把主线程卡住好几秒。常见做法是按省级前缀拆成小文件点哪个省再拉哪个。// split-by-province.mjs —— 按 adcode 前两位拆分全国市级边界 import fs from node:fs; const all JSON.parse(fs.readFileSync(city_boundary.json, utf8)); const buckets new Map(); for (const feat of all.features) { const props feat.properties ?? {}; const code String(props.adcode ?? ); // 必须同时满足 6 位长度和 level 为 city否则会把全国汇总要素混进来 if (code.length ! 6 || props.level ! city) continue; const provCode code.slice(0, 2); if (!buckets.has(provCode)) buckets.set(provCode, []); buckets.get(provCode).push(feat); } for (const [provCode, features] of buckets) { fs.writeFileSync( ./dist/city-${provCode}.json, JSON.stringify({ type: FeatureCollection, features }), utf8 ); } console.log(输出省级分组数:, buckets.size);只判长度是不够的。有些文件里会混进一个 6 位的全国汇总要素长度合法但level是country不筛掉的话它会出现在每一个省的下钻结果里。拆完之后顺手打印一下每个文件的大小超过 1MB 的那几个省基本都要单独做抽稀。3.3 Leaflet坐标系不匹配边界和底图会错位Leaflet 的L.geoJSON默认按 WGS84 理解坐标。如果你的边界文件是 GCJ-02底图是 WGS84两者叠在一起会有稳定方向的偏移几百米量级放大后非常明显。import L from leaflet; const map L.map(map, { center: [32, 112], zoom: 4 }); // 瓦片地址换成你自己的服务这里只做占位 L.tileLayer(https://{s}.tile.example.com/{z}/{x}/{y}.png, { maxZoom: 10, attribution: // 按你的底图来源填 }).addTo(map); fetch(/data/province_boundary.json) .then(r r.json()) .then(geo { const layer L.geoJSON(geo, { style: () ({ color: #5b8ff9, weight: 1, fillOpacity: 0.15 }), onEachFeature: (feat, lyr) { lyr.bindTooltip(feat.properties.name, { sticky: true }); } }).addTo(map); map.fitBounds(layer.getBounds(), { padding: [12, 12] }); });fitBounds的padding别省否则最外圈的要素会贴着容器边缘tooltip 一弹出来就被裁掉。bindTooltip用sticky: true让气泡跟着鼠标走比固定锚点好用。还有一个容易忽略的点L.geoJSON对geometry为null的要素会静默跳过不会报错所以如果少画了某个区域先去查数据不是查代码。3.4 Python 侧读进来先看 crs 和 valid做统计、出图、做数据校对都会走 Python。geopandas 读 GeoJSON 很顺但有两个字段必须人工确认。import geopandas as gpd gdf gpd.read_file(province_boundary.json) print(gdf.crs) # GeoJSON 读进来常常是 None print(gdf.geom_type.value_counts()) # 确认几何类型分布 print(gdf.is_valid.value_counts()) # 自相交的多边形要单独处理 # 明确声明坐标系用 set_crs 而不是 to_crs gdf gdf.set_crs(EPSG:4326, allow_overrideTrue) print(gdf.total_bounds) # 用 bbox 反查经纬度有没有颠倒set_crs是贴标签to_crs是真变换两者搞混会把整份数据投影到完全不相关的位置。is_valid为False的多边形通常是自相交gdf.geometry gdf.buffer(0)能修掉大部分但它会静默改变几何形状所以要先定位是哪个要素、再看要不要修别一行buffer(0)无脑过。4. 坐标系与体积WGS84/GCJ-02 互转和抽稀到可用边界数据的两个老大难一个是对不齐一个是太大。前者是坐标系问题后者是点数问题两者互相关联——抽稀过度会让边界明显偏离真实位置看起来像是坐标系出了问题实际是自己把数据削掉了。4.1 先判断手上这份文件到底是哪套坐标国内常见三套坐标WGS84 是 GPS 原始坐标GCJ-02 是主流底图厂商用的加密坐标BD-09 是另外一家在 GCJ-02 上再叠一层偏移的坐标。GCJ-02 相对 WGS84 的偏移在国内是几百米量级方向和大小随位置变化没有固定规律。判断方法不需要任何工具把边界和底图叠一起看就行整体出现稳定方向的偏移且偏移量在百米级基本可以判定是 GCJ-02 配了 WGS84 底图。如果偏移方向和距离在不同区域还不一样大那多半是两套加密坐标互叠了。千万别靠看着差不多来下结论几百米的偏移在小比例尺下是真的看不出来。4.2 互转的实现与逐点遍历GCJ-02 的正向变换是公开的近似算法逆向没有解析解只能用迭代逼近。下面这套实现做可视化足够用。import math A 6378245.0 # 克拉索夫斯基椭球长半轴 EE 0.00669342162296594323 # 第一偏心率平方 def out_of_range(lng, lat): # 粗略边界框框外不做偏移边境地区会误判需要人工核对 return not (72.004 lng 137.8347 and 0.8293 lat 55.8271) def _lat(x, y): r -100.0 2.0*x 3.0*y 0.2*y*y 0.1*x*y 0.2*math.sqrt(abs(x)) r (20.0*math.sin(6.0*x*math.pi) 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r (20.0*math.sin(y*math.pi) 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0 r (160.0*math.sin(y/12.0*math.pi) 320*math.sin(y*math.pi/30.0)) * 2.0/3.0 return r def _lng(x, y): r 300.0 x 2.0*y 0.1*x*x 0.1*x*y 0.1*math.sqrt(abs(x)) r (20.0*math.sin(6.0*x*math.pi) 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r (20.0*math.sin(x*math.pi) 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0 r (150.0*math.sin(x/12.0*math.pi) 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0 return r def wgs84_to_gcj02(lng, lat): if out_of_range(lng, lat): return lng, lat dlat _lat(lng - 105.0, lat - 35.0) dlng _lng(lng - 105.0, lat - 35.0) rad lat / 180.0 * math.pi magic 1 - EE * math.sin(rad) ** 2 sqrt_magic math.sqrt(magic) dlat (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrt_magic) * math.pi) dlng (dlng * 180.0) / (A / sqrt_magic * math.cos(rad) * math.pi) return lng dlng, lat dlat def gcj02_to_wgs84(lng, lat): # 无解析逆解用不动点迭代逼近三次足够收敛到厘米级 if out_of_range(lng, lat): return lng, lat wlng, wlat lng, lat for _ in range(3): glng, glat wgs84_to_gcj02(wlng, wlat) wlng lng - glng wlat lat - glat return wlng, wlat转换要作用到每一个坐标点上写个递归函数遍历嵌套数组最省心同时记得保留可能存在的第三维高程不然[lng, lat, 0]会被削成两维某些工具读的时候会报警。def walk(coords, fn): if isinstance(coords[0], (int, float)): lng, lat fn(coords[0], coords[1]) return [lng, lat] list(coords[2:]) # 保留第三维 return [walk(c, fn) for c in coords]一个提醒正向和逆向不要来回转。每转一次都会引入微小残差转五六次之后边界会肉眼可见地发毛。我的做法是原始文件永远保留一份 WGS84 的副本需要哪种坐标系就从副本转一次绝不链式转换。4.3 抽稀tolerance 用米来给别用度一个省级边界动辄几万个点全国加起来几十万点对前端渲染是实打实的负担。Douglas-Peucker 抽稀是最常用的手段但 shapely 的simplify接受的是和坐标系同单位的 tolerance直接在经纬度上给 0.01 是没有物理意义的。from shapely.geometry import shape, mapping from shapely.ops import transform from pyproj import Transformer FWD Transformer.from_crs(EPSG:4326, EPSG:3857, always_xyTrue).transform INV Transformer.from_crs(EPSG:3857, EPSG:4326, always_xyTrue).transform def simplify_feature(feat, tol_m200): geom shape(feat[geometry]) # 投影到米制后再抽稀tolerance 才有明确的物理含义 geom_m transform(FWD, geom) geom_s geom_m.simplify(tol_m, preserve_topologyTrue) feat[geometry] mapping(transform(INV, geom_s)) return featpreserve_topologyTrue能防止抽稀过程中多边形自相交或者直接消失但防不住相邻两个区域各简各的、把共享边界简成两条不一样的线。表现就是放大之后省界之间出现一条细缝。渲染层加一点borderWidth能遮住但如果你要做空间分析比如判断某个点落在哪个区域缝隙会导致点落到空白地带。严谨的做法是先把所有区域unary_union求出共享拓扑再统一简化代价是流程复杂不少一般可视化项目没必要。tolerance 怎么定按最终展示的屏幕分辨率反推。全国视图下 1 像素大概对应几公里200 米完全够用下钻到市级、能放大到街区级别时20 到 50 米比较合适。同一个值套所有层级一定是某一层会出问题。4.4 精度截断与序列化白捡的体积坐标保留 6 位小数大约对应 0.1 米精度比任何屏幕都细。截断到 4 位是 11 米5 位是 1 米可视化的边界数据保留 5 位完全够体积能掉一截。import json def round_coords(coords, nd5): if isinstance(coords[0], (int, float)): return [round(v, nd) for v in coords] return [round_coords(c, nd) for c in coords] for feat in data[features]: feat[geometry][coordinates] round_coords(feat[geometry][coordinates], 5) # separators 去掉默认的空格ensure_asciiFalse 让中文按 UTF-8 存比转义更短 with open(province_boundary.min.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, separators(,, :))ensure_asciiFalse这个细节很多人反着用。中文按 UTF-8 存是 3 字节写成\uXXXX是 6 字节关掉转义反而更小同时还保住了可读性。separators去掉了每个键值对后面的空格在几十万点的文件上能省下几百 KB。5. 常见问题排查区域不上色、边界漂移和渲染发糊这一节是我自己踩过的、也在别人代码里反复见到的五个问题。每个都按现象—原因—解决写照着对号入座比读文档快。区域画出来了但数据绑不上全是默认灰。现象是setOption之后轮廓正常但series.data里的值完全没体现。原因九成是名称不匹配properties.name和业务数据里的名称差一个后缀、差一个空格、或者编码不同导致看着一样实际不等。解决方法是别猜直接打差集。const geoJson await fetch(/data/province_boundary.json).then(r r.json()); const mapNames new Set(geoJson.features.map(f String(f.properties.adcode))); const dataNames new Set(myData.map(d String(d.adcode))); console.log(数据有、地图没有:, [...dataNames].filter(n !mapNames.has(n))); console.log(地图有、数据没有:, [...mapNames].filter(n !dataNames.has(n)));两个方向都打只打一个方向会让你误判成数据缺失。边界整体偏移几百米放大后尤其明显。现象是边界形状完全正确但就是和底图对不上偏移方向在所有区域基本一致。原因是坐标系混用加密坐标配了原始坐标的底图。解决方式是把两边统一到同一套坐标再叠判断方法在 4.1 节写过。有一种更隐蔽的情况偏移方向在不同区域不一样那说明中间做过一次不完整的转换只转了部分要素得从原始文件重转一遍。放大到市级边界糊成锯齿或者出现细长毛刺。现象是整体视图正常一放大边界就开始抖。原因有两个一是拿到的是简化版数据本身点数就不够二是自己抽稀时 tolerance 给太大。解决方式是先用 4.3 的方法量一下每个要素的点数点数明显偏少的要素说明源头就被削过只能换更精细的源文件。毛刺则通常是自相交导致的用is_valid筛一遍。JSON.parse直接报Unexpected token in JSON at position 0。现象是文件明明能打开、内容看着也是合法 JSON但前端就是解析失败。原因基本是文件头带了 BOM那几个不可见字节让解析器认为第一个字符非法。解决方式是在构建阶段读文件时用utf-8-sig编码或者用一行脚本把 BOM 去掉别在前端补救。市级文件加载时页面卡死或者内存爆掉。现象是切到某个本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站