第13章 - GeoJSON 数据与可视化
13.1 从一份 JSON 到地图上的图形
GIS 后端最常吐出的格式就是 GeoJSON(RFC 7946)。它把几何写成 [lng, lat] 坐标数组,把属性挂在 properties 上。Leaflet 的 GeoJSON 层要做的事只有一件:把每个 Feature 翻译成一个 Leaflet 图层,再按几何类型分派到 Polyline、Polygon、Marker 或 CircleMarker。
翻译规则是固定的,源码 GeoJSON.geometryToLayer() 的 switch 写死了映射:
| GeoJSON 类型 | 生成的图层 | 嵌套层级 |
|---|---|---|
Point |
Marker(默认)或 pointToLayer 返回的图层 |
— |
MultiPoint |
FeatureGroup,成员是一组 Marker |
— |
LineString |
Polyline |
coordsToLatLngs(coords, 0) |
MultiLineString |
Polyline(多段) |
深度 1 |
Polygon |
Polygon |
深度 1 |
MultiPolygon |
Polygon(多环) |
深度 2 |
GeometryCollection |
FeatureGroup |
逐个几何递归 |
FeatureCollection |
FeatureGroup |
逐个 Feature 递归 |
注意 MultiPoint 不是 Marker,而是一个 FeatureGroup。这意味着你调用 layer.setStyle()、bindPopup() 时,行为与单个 Marker 不同:bindPopup 会转发给成员,setStyle 只对带 setStyle 的成员生效。
import {LeafletMap, TileLayer, GeoJSON} from 'leaflet';
import 'leaflet/styles.css';
const map = new LeafletMap('map', {center: [39.9042, 116.4074], zoom: 10});
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const fc = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {name: '点位 A', kind: 'poi'},
geometry: {type: 'Point', coordinates: [116.397, 39.908]}
},
{
type: 'Feature',
properties: {name: '线路 B', kind: 'route'},
geometry: {type: 'LineString', coordinates: [[116.35, 39.90], [116.40, 39.92], [116.45, 39.91]]}
}
]
};
const layer = new GeoJSON(fc).addTo(map);
map.fitBounds(layer.getBounds());
layer.getBounds() 来自 FeatureGroup,会遍历所有成员,取 getBounds() 或 getLatLng() 的并集。对刚加载完的 GeoJSON 调 fitBounds 是标准开场动作。
13.2 addData 的内部顺序
new GeoJSON(data, options) 构造函数只做两件事:保存 options,然后如果传了 data 就调 addData(data)。addData 对每个对象依次执行:
addData(geojson)
├─ 数组或带 features 的对象 → 递归展开
├─ asFeature(geojson) 裸几何包装成 Feature(properties 为 {})
├─ 空 geometry → 直接 return(2.0 行为)
├─ options.filter(feature) 为 false → 直接 return
├─ geometryToLayer(feature, options) 按类型造图层
├─ layer.feature = feature 挂回引用
├─ layer.defaultOptions = layer.options 快照原始样式,给 resetStyle 用
├─ resetStyle(layer) 应用 style 选项
├─ options.onEachFeature(feature, layer) 回调
└─ addLayer(layer) 加入 FeatureGroup,触发 layeradd
这个顺序解释了四个常见疑问:
filter在造图层之前执行,所以被过滤掉的 Feature 不会产生任何 DOM。而且官方类型注释明确写了一句:运行期修改filter只影响之后addData的数据,不会重新评估已加载的 Feature。想按条件重绘,得清空重加。style在onEachFeature之前应用,回调里可以再layer.setStyle()覆盖。onEachFeature拿到的 layer 已经在地图外但已完整构建,适合bindPopup、on(),不适合读取像素坐标。layer.feature始终指向原始 Feature 对象,是取属性的正路,不要从e.target.options里翻。
2.0 有一处行为变化值得记住:输入是裸几何(没有 Feature 包装)时,filter 和 onEachFeature 收到的是包装后的 Feature,且与 layer.feature 是同一个对象;几何为 null 的 Feature 会被跳过。1.x 里这两种情况的表现不同,迁移时如果依赖过旧行为,要重新测。
13.3 五个选项逐个讲
13.3.1 style
style 可以是对象,也可以是函数。对象用于“所有线面同样式”,函数用于按属性分级:
// 统一描边
new GeoJSON(fc, {
style: {color: '#3388ff', weight: 3, opacity: 0.8, fillOpacity: 0.2}
}).addTo(map);
// 按属性设色
function densityStyle(feature) {
const v = feature.properties.density;
return {
fillColor: v > 500 ? '#800026' : v > 100 ? '#E31A1C' : '#FED976',
weight: 1,
color: 'white',
fillOpacity: 0.7
};
}
style 只对 Path(线、面、圆)生效。点默认生成 Marker,Marker 没有 setStyle,所以给点设 style 无效——这是“点了没变色”的最常见原因。要控制点的外观,走 pointToLayer。
13.3.2 pointToLayer
函数签名 (feature, latlng) => Layer,默认实现是 new Marker(latlng)。返回 CircleMarker 是最常用的替代,它能吃到 style 里的路径样式:
new GeoJSON(fc, {
pointToLayer(feature, latlng) {
return new CircleMarker(latlng, {
radius: 6,
fillColor: feature.properties.kind === 'poi' ? '#ff7800' : '#3388ff',
color: '#fff',
weight: 1,
fillOpacity: 0.9
});
},
style: {weight: 2}
}).addTo(map);
如果仍想用 Marker 但想让点继承组选项,加 markersInheritOptions: true(默认 false)。它只对默认生成的 Marker 有效,pointToLayer 自己返回的实例按你自己的参数来。
13.3.3 onEachFeature
在图层加入组之前调用,是绑交互的统一入口:
const geo = new GeoJSON(fc, {
onEachFeature(feature, layer) {
const p = feature.properties;
if (p.name) {
layer.bindTooltip(p.name, {direction: 'top'});
}
layer.on('click', () => {
console.log(feature.geometry.type, p);
});
}
}).addTo(map);
不要在 onEachFeature 里调用 map.fitBounds()——此时图层还没加入组,更没加入地图,getBounds() 是空的。需要全量范围,等 GeoJSON 构造完再调。
13.3.4 filter
new GeoJSON(fc, {
filter: feature => feature.properties.kind === 'poi'
}).addTo(map);
过滤是“创建期一次性”的。数据量大时用它先砍掉不需要渲染的要素,比全量加载再做样式隐藏划算得多——后者仍然要为每个 Feature 创建 DOM 或 canvas 绘制指令。
13.3.5 coordsToLatLng
默认实现是 new LatLng(coords[1], coords[0], coords[2]),即 [lng, lat] → [lat, lng]。当数据源不是标准 GeoJSON 坐标(例如已经是 [lat, lng],或者带投影坐标)时换掉它:
new GeoJSON(raw, {
coordsToLatLng(coords) {
// 数据是 [lat, lng, alt]
return new LatLng(coords[0], coords[1], coords[2]);
}
}).addTo(map);
不要为了“少写一层转换”就改这个函数而数据本身是标准 GeoJSON——坐标系一换,所有几何都会镜像或旋转。
13.4 样式重置与动态改色
GeoJSON 提供三个样式方法:
| 方法 | 作用 |
|---|---|
resetStyle(layer?) |
把图层的 options 恢复成创建时的快照,再套用一次 style 函数 |
setStyle(style) |
对所有成员应用新样式,参数可以是对象或函数 |
eachLayer(fn) |
遍历成员(继承自 FeatureGroup) |
resetStyle 的实现依赖 addData 时存下的 layer.defaultOptions:它把 layer.options 换成以快照为原型的对象,再重新求值 style。所以悬停高亮 + 移出还原的标准写法是:
let geojson;
function highlight(e) {
const layer = e.target;
layer.setStyle({weight: 4, color: '#666', fillOpacity: 0.85});
layer.bringToFront();
}
function reset(e) {
geojson.resetStyle(e.target);
}
geojson = new GeoJSON(fc, {
style: densityStyle,
onEachFeature(feature, layer) {
layer.on({pointerover: highlight, pointerout: reset});
}
}).addTo(map);
这里必须用 resetStyle 而不是 setStyle(densityStyle)。后者不会清除你手动加上的额外属性(比如 dashArray: '' 之外的残留),而前者从快照重建,能保证完全回到初始状态。
2.0 的 Path.setStyle() 有一个例外:className 只在元素创建时应用,后续 setStyle({className}) 只改 options,不改 DOM 的 class。要用 class 控制样式,请在 pointToLayer 或 style 里就给好。
13.5 实战:分级设色 + 悬停高亮
把上面的片段拼成一个完整可用页面:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GeoJSON 分级设色</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<style>
html, body, #map { height: 100%; margin: 0; }
.info { padding: 6px 8px; background: rgba(255,255,255,.9); border-radius: 4px; font: 13px/18px sans-serif; }
</style>
</head>
<body>
<div id="map"></div>
<script type="importmap">
{
"imports": {
"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"
}
}
</script>
<script type="module">
import {LeafletMap, TileLayer, GeoJSON, Control, DomUtil} from 'leaflet';
const map = new LeafletMap('map').setView([37.8, -96], 4);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
const grades = [0, 10, 20, 50, 100, 200, 500, 1000];
const colors = ['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'];
function getColor(d) {
for (let i = grades.length - 1; i >= 0; i--) {
if (d > grades[i]) { return colors[i]; }
}
return colors[0];
}
function style(feature) {
return {
fillColor: getColor(feature.properties.density),
weight: 1,
opacity: 1,
color: 'white',
fillOpacity: 0.7
};
}
let geojson;
function highlight(e) {
geojson.resetStyle(e.target);
e.target.setStyle({weight: 3, color: '#333'});
e.target.bringToFront();
}
function unhighlight(e) {
geojson.resetStyle(e.target);
}
const info = new Control({position: 'topright'});
info.onAdd = function () {
this._div = DomUtil.create('div', 'info');
this.update();
return this._div;
};
info.update = function (props) {
this._div.innerHTML = props
? `<b>${props.name}</b><br>${props.density} 人/mi²`
: '悬停查看详情';
};
info.addTo(map);
geojson = new GeoJSON(statesData, {
style,
onEachFeature(feature, layer) {
layer.on({pointerover: highlight, pointerout: unhighlight});
layer.on('pointerover', () => info.update(feature.properties));
layer.on('pointerout', () => info.update());
}
}).addTo(map);
</script>
</body>
</html>
页面里 statesData 需要你自己提供一份 FeatureCollection。关键点:highlight 里先 resetStyle 再 setStyle,保证多个要素连续悬停时不会互相污染样式。
13.6 反向导出:toGeoJSON
图层能转回 GeoJSON,Polyline、Polygon、Marker、Circle、CircleMarker、GeoJSON、FeatureGroup、LayerGroup 都有 toGeoJSON(precision?):
const poly = new Polygon([[39.9, 116.3], [39.9, 116.5], [40.0, 116.5]], {color: 'red'}).addTo(map);
const feature = poly.toGeoJSON();
console.log(feature.geometry.type); // "Polygon"
console.log(feature.geometry.coordinates[0][0]); // [116.3, 39.9] — 已转成 [lng, lat]
// 控制小数位:默认 6 位,false 表示不取整
console.log(poly.toGeoJSON(4));
Circle 会被导出成 Point(只有圆心),半径信息不在标准 GeoJSON 里,这是有损的。CircleMarker 同理。要做圆形的可交换表达,自己转成 Polygon 或存成属性。
13.7 排错
控制台报 Invalid GeoJSON object. geometryToLayer() 的 switch 走到 default 分支。原因通常是 type 拼错('Featurecollection' 大小写、'MultiPolygon ' 带空格)或传入的不是 JSON 对象而是字符串。先 JSON.parse 再传。
所有图形都跑到南半球或镜像。 数据是 [lat, lng] 但按标准 GeoJSON 处理,或者相反。标准是 [lng, lat]。用 coordsToLatLng 之前先确认真实顺序,最直接的验证是打印第一个坐标看经度是否落在 -180~180、纬度是否落在 -90~90;如果两者都在范围内,只能拿一个已知地物对比。
Polygon 画成了奇怪的交叉形状。 GeoJSON 的 Polygon 要求外环闭合(首尾坐标相同),且多边形的环嵌套层级是 [ring][point][2]。少一层数组或漏了闭合点,坐标会被重新连接。手写数据时用 [ring.map(...)] 包两层。
点没有 popup,线面正常。 点默认是 Marker,bindPopup 正常可用;如果用了 pointToLayer 返回自定义图层但没有 bindPopup 实现,自然没有。另外 filter 返回 false 的要素不会进入 onEachFeature。
resetStyle 后样式没变化。 检查是否在 addData 之后又直接改了 layer.options,那会破坏快照语义。另外 resetStyle 只对带 setStyle 的图层有效,Marker 不会变。
大数据量页面直接卡死。 默认 SVG 渲染器给每个要素创建一个 <path>,几千个还可以,几万个就顶不住。改 renderer: new Canvas()(见第 16 章),并且先用 filter 砍数据、用 simplify 预处理几何。
getBounds() 返回 NaN。 有要素坐标含 null 或字符串。2.0 的 LatLng 会直接抛错,所以更可能是坐标数组长度为 1 或 2 但含 undefined。入库前用 LatLng.validate() 校验。
13.8 生产建议
- 数据超过 2000 个要素就不要一次性塞进
GeoJSON。按视野或按图层分组加载,moveend时用map.getBounds()请求后端只返回视口内的要素。 - 用
pointToLayer+CircleMarker代替默认 Marker。Marker 每个都是一个带图片的 DOM,CircleMarker 在 canvas 下只是绘制指令,几千个点的差距是数量级的。 - 把
style写成纯函数,不要在函数内部读取会变化的全局状态。resetStyle会重新调用它,状态不一致会导致“移出后颜色变了”。 onEachFeature里只做轻量绑定。给一万个要素各绑一个闭包,内存会翻倍;交互可以改成在GeoJSON层上监听事件,用e.propagatedFrom找到来源图层。- 属性里可能含有 HTML 的数据不要直接
bindPopup(feature.properties.name),用textContent或先转义。bindPopup传字符串会当 HTML 解析。 toGeoJSON的默认精度是 6 位小数,与入库精度一致;需要更高精度显式传false,但要注意体积膨胀。
13.9 本章小结
| 要点 | 结论 |
|---|---|
| 类关系 | GeoJSON 继承 FeatureGroup,成员是按几何类型生成的图层 |
| 类型映射 | Point→Marker,LineString→Polyline,Polygon→Polygon,MultiPoint→FeatureGroup |
| 坐标顺序 | GeoJSON 是 [lng, lat],Leaflet 是 [lat, lng],由 coordsToLatLng 转换 |
| addData 顺序 | filter → geometryToLayer → 快照样式 → style → onEachFeature → addLayer |
| style | 只对 Path 生效;点要换 pointToLayer 返回 CircleMarker |
| resetStyle | 从创建时快照恢复再重算 style,悬停高亮的正确还原方式 |
| filter | 创建期一次性过滤,改选项不会重新评估已加载数据 |
| 2.0 变化 | 裸几何也包装成 Feature 传回调;null 几何自动跳过 |
| 导出 | toGeoJSON(precision) 默认 6 位小数;Circle 导出为点,有损 |
| 大数据 | Canvas 渲染器 + 视口裁剪 + 几何简化,三者缺一不可 |