news 2026/11/5 10:25:35

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
全国省市边界JSON解析、坐标转换与地图渲染实践

简介:面向地图可视化开发、地理信息教学与区域数据整理场景,这份中国各省市行政区边界坐标数据以 GeoJSON 形式组织,可直接用于在 Web 或桌面地图上描边、填充与区域交互,帮助开发者省去逐省收集边界点位的重复工作,也适合做区域统计图与空间范围校验。压缩包共 36 个文件,以 35 个 json 边界数据和 1 个 js 文件为主,前者按省级行政区组织坐标集合,后者便于脚本侧调用或统一组织数据,整体约 415KB,体量轻便、加载友好。以北京密云县为例,单个 json 内含 FeatureCollection 结构、区域编码、名称、中心点与多边形坐标,字段直观,便于解析和二次处理。目前已有 2484 人学习下载,适合需要快速搭建省市边界图层、制作区域统计地图或做地理数据入门的读者参考使用。

1. 全国省市边界 JSON 落到项目里,第一件事是验结构而不是画图

拿到一份全国各省市地图边界坐标 json 文件,很多人的第一反应是直接echarts.registerMap或者L.geoJSON放进去,看见轮廓出来了就收工。真正容易出问题的环节都在后面:省份对不上、区域不上色、点位整体偏移几百米、放大到市级边界糊成一团锯齿、切到县级发现文件大到浏览器直接卡死。

这份资源解决的是把省市边界坐标变成能直接塞进项目的结构化数据这件事。它给的是纯几何加属性,不是底图,也不是瓦片服务,能不能用得顺手取决于你后面怎么解析、怎么配坐标系、怎么压体积。做数据看板、区域热力图、行政区划下钻、离线地图的前后端同学都用得上。

2. 拆开一个 Feature:properties、geometry 和坐标层级

同一个"省界 JSON",从不同渠道拿到的字段命名、几何类型、精度可以完全不一样。有人拿到的是标准 GeoJSON,有人拿到的是早期 ECharts 那种{ "某省": {…} }的字典结构,还有人拿到的是坐标数组外面裹了一层自定义元信息。所以第一段代码永远不是渲染,而是探测。

2.1 先跑探针,别急着相信任何字段说明

下面这段脚本不改数据,只打印结构。它能在十秒内告诉你:要素有多少、几何类型分布、属性键叫什么、坐标嵌套几层。后面所有处理逻辑都建立在这几个数字上。

import json from collections import Counter # utf-8-sig 能同时吃掉带 BOM 和不带 BOM 的文件,避免 json.load 直接抛异常 with open("province_boundary.json", encoding="utf-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 层是 Polygon,4 层是 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混合结构手工拼装或二次加工的数据渲染器兼容性差,建议拍平成 MultiPolygon
null无数据缺失直接过滤,否则绘图库抛异常

沿海和有岛屿的行政区几乎必然是 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": # sign=1.0 表示外环逆时针、内环顺时针,符合 RFC 7946 geom = orient(geom, sign=1.0) elif geom.geom_type == "MultiPolygon": geom = type(geom)([orient(p, sign=1.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,别用 name

properties.name是给人看的,properties.adcode是给程序用的。业务表里写"某某自治区",边界文件里写"某某",两个字符串一比较就是false,地图上那块区域就永远是默认灰色。带"自治区""特别行政区"这类后缀的全称和常用简称,几乎必然对不上。

adcode 是 6 位数字编码,前两位标识省级、前四位标识市级、六位标识县级。做下钻的时候直接用前缀切片就能分组,不用维护任何名称映射表。我在项目里一般会做一层转换:对外暴露业务 ID,对内一律用 adcode 做 join key,名称只用来显示。

3. 三种渲染落地:ECharts、Leaflet 与 geopandas

数据验完结构,接下来是选择一个承载方式。选型的判断标准其实很朴素:只看静态区域着色和鼠标悬浮,选 ECharts;要叠底图、要打点、要画轨迹,选 Leaflet 这类 GIS 库;要做统计计算、要出报告图,选 Python 侧的 geopandas。三者的输入都是同一份 GeoJSON,但参数完全不同。

3.1 ECharts:registerMap 之后必须盯住的三个参数

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_override=True) 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_xy=True).transform INV = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True).transform def simplify_feature(feat, tol_m=200): geom = shape(feat["geometry"]) # 投影到米制后再抽稀,tolerance 才有明确的物理含义 geom_m = transform(FWD, geom) geom_s = geom_m.simplify(tol_m, preserve_topology=True) feat["geometry"] = mapping(transform(INV, geom_s)) return feat

preserve_topology=True能防止抽稀过程中多边形自相交或者直接消失,但防不住相邻两个区域各简各的、把共享边界简成两条不一样的线。表现就是放大之后省界之间出现一条细缝。渲染层加一点borderWidth能遮住,但如果你要做空间分析(比如判断某个点落在哪个区域),缝隙会导致点落到"空白地带"。严谨的做法是先把所有区域unary_union求出共享拓扑再统一简化,代价是流程复杂不少,一般可视化项目没必要。

tolerance 怎么定?按最终展示的屏幕分辨率反推。全国视图下 1 像素大概对应几公里,200 米完全够用;下钻到市级、能放大到街区级别时,20 到 50 米比较合适。同一个值套所有层级,一定是某一层会出问题。

4.4 精度截断与序列化,白捡的体积

坐标保留 6 位小数大约对应 0.1 米精度,比任何屏幕都细。截断到 4 位是 11 米,5 位是 1 米,可视化的边界数据保留 5 位完全够,体积能掉一截。

import json def round_coords(coords, nd=5): 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_ascii=False 让中文按 UTF-8 存,比转义更短 with open("province_boundary.min.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, separators=(",", ":"))

ensure_ascii=False这个细节很多人反着用。中文按 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 去掉,别在前端补救。

市级文件加载时页面卡死或者内存爆掉。现象是切到某个

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 10:23:04

AGI时代智慧主权与《全球智慧文明宪章》技术解析

1. 项目背景与核心议题 2026年AGI&#xff08;通用人工智能&#xff09;技术突破临界点&#xff0c;全球范围内出现首个通过图灵测试的自主意识体。这一里程碑事件引发各国对"智慧主权"概念的激烈辩论——当AI系统具备自我意识后&#xff0c;其创造者、部署国是否天然…

作者头像 李华
网站建设 2026/11/5 9:39:25

机器人雕刻编程优化与iRobotCAM应用实践

1. 机器人雕刻技术概述与核心挑战机器人雕刻作为智能制造领域的重要应用方向&#xff0c;正在从传统的简单轮廓加工向复杂艺术造型、精密工业零件等领域快速扩展。与传统CNC雕刻相比&#xff0c;机器人雕刻具有工作空间大、灵活性高、成本相对较低等优势&#xff0c;但同时也面…

作者头像 李华
网站建设 2026/11/5 9:39:24

机器人雕刻技术:从路径规划到工艺优化的全面解析

1. 机器人雕刻技术概述 机器人雕刻作为现代智能制造领域的重要应用方向&#xff0c;正在逐步改变传统雕刻行业的生产方式。与传统CNC雕刻相比&#xff0c;机器人雕刻具有工作空间大、灵活性高、可扩展性强等显著优势。特别是在复杂曲面、异形件加工方面&#xff0c;六轴工业机器…

作者头像 李华
网站建设 2026/11/5 9:38:59

解决IDEA中Tomcat控制台输出乱码的终极方案

1. 问题现象与背景分析最近在IDEA中启动Tomcat时遇到了控制台输出乱码的问题&#xff0c;这让我不得不停下开发工作来解决这个看似简单却影响效率的编码问题。作为一名Java开发者&#xff0c;我们经常需要在IDEA中集成Tomcat进行本地开发和调试&#xff0c;而控制台输出的日志信…

作者头像 李华
网站建设 2026/11/5 9:37:00

ORM实战避坑指南:从实体映射到事务处理的完整案例

简介&#xff1a;这份资源面向具备一定Python与深度学习基础、希望上手时间序列预测的开发者&#xff0c;以Autoformer模型为核心&#xff0c;围绕股票价格预测场景组织了一套可运行的实战代码。压缩包共94个文件&#xff0c;约8.1MB&#xff0c;以py源码与pyc缓存为主&#xf…

作者头像 李华
网站建设 2026/11/5 9:36:19

SAP HR人岗关系刷新:OOPH与OOHQ实战指南

1. 为什么“刷新人岗关系”是个值得单独拎出来讲的话题 做过 SAP HR 的朋友大概都有过这种体验&#xff1a;组织架构调整、岗位职责重划、批量调岗&#xff0c;业务部门一句话下来&#xff0c;系统里要动的东西却是一大片。尤其是人岗关系这块&#xff0c;表面上看只是 PA0001…

作者头像 李华