第17章 - 空间分析Turf集成与插件生态
17.1 客户端空间分析的位置
MapLibre 只负责”画”和”查可见要素”,不做空间分析:没有缓冲区、没有叠加分析、没有距离量算。分析能力要么放在服务端(GeoServer、pg_tilestack、PostGIS),要么在浏览器里用 Turf 这类纯 JS 库算。
选择取决于三件事:
| 条件 | 客户端(Turf) | 服务端(PostGIS 等) |
|---|---|---|
| 数据量 | 万级要素、单次分析 | 百万级、复杂空间索引 |
| 交互频率 | 跟随鼠标实时算 | 提交表单后算 |
| 数据敏感性 | 数据已在浏览器里 | 数据不出服务端 |
| 部署成本 | 零后端 | 需要数据库与服务 |
界面上”拖一个圈看里面有啥”、”点两个点算距离”这类交互,客户端算最跟手;”整个区的用地平衡表”这类,服务端算。
17.2 queryRenderedFeatures:先学会”查”
分析的输入通常来自地图上已经渲染的要素。两个查询 API 分工明确:
| API | 输入 | 返回 | 典型用途 |
|---|---|---|---|
queryRenderedFeatures |
屏幕坐标(点或矩形)或 options | 当前渲染出来的要素(MapGeoJSONFeature[]) |
点选、框选 |
querySourceFeatures |
源 id + {sourceLayer, filter} |
源里已加载瓦片中的要素(GeoJSONFeature[]) |
按属性批量取 |
// 点选:只查指定图层,返回数组,按渲染顺序自上而下
const features = map.queryRenderedFeatures(e.point, { layers: ['buildings', 'parcels'] });
// 框选:给两个角点,顺序任意
const box = [[e.point.x - 40, e.point.y - 40], [e.point.x + 40, e.point.y + 40]];
const inBox = map.queryRenderedFeatures(box, { layers: ['parcels'] });
// 带属性过滤,省去返回后再筛
const big = map.queryRenderedFeatures(e.point, {
layers: ['parcels'],
filter: ['>', ['get', 'area'], 500]
});
// 从源里取要素:矢量源必须给 sourceLayer
const sourceFeatures = map.querySourceFeatures('parcels-src', { sourceLayer: 'parcels' });
三个容易踩空的地方:
queryRenderedFeatures只返回当前视口内、已渲染的要素。缩放到别的区域后查不到,是正常的。querySourceFeatures只返回已加载瓦片里的要素,不保证覆盖整个源;它不会为了查询去下载瓦片。- 矢量源的
sourceLayer必填,且要与瓦片里的 layer 名完全一致;GeoJSON 源忽略这个参数。
17.3 Turf 集成
Turf 7.x 是模块化包,可以整包引入,也可以只装需要的子包:
# 全量(约 1.5 MB 未压缩,适合快速原型)
npm install @turf/turf@7.4.0
# 按需(生产推荐,只引真正用到的函数)
npm install @turf/boolean-point-in-polygon@7.4.0 @turf/buffer@7.4.0 @turf/distance@7.4.0
import * as turf from '@turf/turf';
// 点在多边形内:返回 boolean,options.ignoreBoundary 默认 false
const inside = turf.booleanPointInPolygon(turf.point([116.39, 39.9]), polygonFeature);
// 距离:默认单位 kilometers,可选 miles/nauticalmiles/degrees/radians
const km = turf.distance(turf.point([116.39, 39.9]), turf.point([116.48, 39.92]), { units: 'kilometers' });
// 缓冲区:单位与 distance 一致
const buffered = turf.buffer(lineFeature, 0.5, { units: 'kilometers' });
// 相交
const clipped = turf.intersect(turf.featureCollection([polyA, polyB]));
Turf 的坐标约定与 GeoJSON 一致:[经度, 纬度],不是 [纬度, 经度]。MapLibre 的 LngLat 序列化出来是 {lng, lat} 对象,喂给 Turf 前要转数组,这是最容易出错的一步。
17.3.1 距离换算:别直接用米
map.project() 得到的是像素距离,像素与米的关系随纬度和缩放变化。要算”图上两点实际相距多少米”,用 Turf 而不是像素:
const a = map.getCenter();
const b = map.unproject([map.getCanvas().width - 10, map.getCanvas().height / 2]);
const meters = turf.distance(
turf.point([a.lng, a.lat]),
turf.point([b.lng, b.lat]),
{ units: 'meters' }
);
console.log('右边缘中心距地图中心', meters.toFixed(0), 'm');
在墨卡托投影下,越远离赤道同样的像素代表越短的实地距离。用像素反推比例尺在纬度 40° 会偏 30% 以上,这不是 MapLibre 的 bug,是投影的性质。
17.4 交互式分析:框选统计
把查询和分析接起来:框选 → 高亮 → 统计。完整示例:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>框选分析</title>
<link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; }
#map { height: 100vh; }
.stat-panel { position: absolute; top: 12px; left: 12px; z-index: 5; background: #fff;
padding: 8px 12px; border-radius: 4px; font: 12px ui-monospace, monospace; box-shadow: 0 0 0 2px rgba(0,0,0,.1); }
</style>
</head>
<body>
<div id="map"></div>
<div class="stat-panel" id="stat">按住 Shift 拖拽框选</div>
<script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@turf/turf@7.4.0/turf.min.js"></script>
<script>
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [116.39, 39.9],
zoom: 11
});
let start = null;
map.on('mousedown', (e) => {
if (!e.originalEvent.shiftKey) return;
start = e.point;
map.dragPan.disable();
});
map.on('mouseup', (e) => {
if (!start) return;
const box = [[Math.min(start.x, e.point.x), Math.min(start.y, e.point.y)],
[Math.max(start.x, e.point.x), Math.max(start.y, e.point.y)]];
start = null;
map.dragPan.enable();
const features = map.queryRenderedFeatures(box);
if (!features.length) { console.log('框内无要素'); return; }
// 用 Turf 把屏幕框换成地理多边形,再做精确判定
const sw = map.unproject(box[0]);
const ne = map.unproject(box[1]);
const bboxPolygon = turf.bboxPolygon([sw.lng, sw.lat, ne.lng, ne.lat]);
const unique = new Map();
let totalArea = 0;
for (const f of features) {
const key = f.id !== undefined ? f.id : JSON.stringify(f.properties);
if (unique.has(key)) continue;
unique.set(key, f);
if (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon') {
if (turf.booleanIntersects(f, bboxPolygon)) {
totalArea += turf.area(f) / 1e6; // m² → km²
}
}
}
document.getElementById('stat').textContent =
'要素 ' + unique.size + ' 个,相交面积 ' + totalArea.toFixed(3) + ' km²';
});
</script>
</body>
</html>
这个例子演示了完整分工:MapLibre 负责”框内有哪些要素”(屏幕空间,快),Turf 负责”是不是真的相交、面积多少”(地理空间,准)。只用其中一个都不对——queryRenderedFeatures 的矩形是屏幕矩形,倾斜视角下它对应的地面范围不是矩形;只用 Turf 遍历全量要素又太慢。
17.5 插件生态:五类,按需取
MapLibre 官方维护了一份插件清单(https://maplibre.org/maplibre-gl-js/docs/plugins/),分五类。挑常用的列一下,便于按问题找工具:
| 类别 | 代表项目 | 解决什么 |
|---|---|---|
| 界面控件 | maplibre-gl-compare、mapbox-gl-draw、Terra Draw、maplibre-gl-export、maplibre-gl-geocoder |
卷帘对比、绘制编辑、导出图片/PDF、地址搜索 |
| 渲染扩展 | deck.gl、maplibre-three-plugin、maplibre-gl-lidar、maplibre-contour |
大规模 WebGL 图层、Three.js 3D、点云、等高线 |
| 图层类型 | PMTiles for MapLibre、maplibre-cog-protocol、mapbox-gl-animated-images 类项目 |
单文件瓦片、COG 影像、动图/脉冲点 |
| 工具库 | turf、mapbox-gl-sync-move、Styl、mapbox-gl-utils |
空间分析、多图联动、样式校验、图层增删糖 |
| 框架集成 | react-map-gl、svelte-maplibre、ngx-maplibre-gl、LibreGL(Vue) |
声明式绑定 |
挑插件先看它是不是协议级扩展。用 addProtocol 接入的(PMTiles、COG、CSV/GPX)通常最稳,因为它复用了 MapLibre 的请求与缓存机制;自定义图层(CustomLayerInterface)只影响渲染,稳定性次之;直接操作私有字段的插件最脆弱,升级主版本就可能失效。
17.5.1 用 addProtocol 扩展数据格式
插件形态里最值得自己掌握的是协议扩展。比如要直接读 .csv 或 .geojson.gz:
maplibregl.addProtocol('gzjson', async (params, abortController) => {
const res = await fetch(params.url.replace('gzjson://', 'https://'), {
signal: abortController.signal
});
if (!res.ok) throw new Error(\`加载失败: \${res.status}\`);
const buf = await res.arrayBuffer();
// 浏览器原生支持 gzip 解压
const stream = new Response(buf).body.pipeThrough(new DecompressionStream('gzip'));
return { data: await new Response(stream).text() };
});
map.addSource('gzip-geojson', {
type: 'geojson',
data: 'gzjson://example.com/big.geojson.gz'
});
协议回调的返回值必须符合 AddProtocolResponseData:ArrayBuffer(瓦片)、ImageBitmap/HTMLImageElement(图片)、string 或已解析对象(JSON)。params.type 会告诉你调用方期望哪一种,返回类型不对会静默失败。
17.6 排错
| 现象 | 原因 | 修法 |
|---|---|---|
Turf 返回 NaN |
坐标顺序写反([lat, lng])或传了 LngLat 对象 |
统一转成 [lng, lat] 数组 |
booleanPointInPolygon 总是 false |
多边形环未闭合,或 GeoJSON 用 geometry 而非 Feature |
用 turf.polygon 构造;传 Feature |
queryRenderedFeatures 返回空 |
图层还没加载 / 不在视口 / 图层名拼错 | 在 idle 后调用;核对图层 id |
querySourceFeatures 漏要素 |
只查了已加载瓦片 | 需要全量时走服务端,或先 fitBounds 让瓦片加载 |
| 打包体积过大 | 整包引入 @turf/turf(117 个依赖) |
改成按函数引入子包 |
addProtocol 注册后无效 |
协议名大小写不一致,或 URL 前缀不匹配 | 协议名用纯小写,URL 前缀完全一致 |
第二、三条是空间查询里最常见的两类失败。前者错在数据形状,后者错在时序——queryRenderedFeatures 在 load 之前调用一定是空数组。
17.7 生产建议
分析代码与渲染代码分开。 把 Turf 计算放进独立的 analysis.js,输入输出都是纯 GeoJSON。这样同一套逻辑可以搬到 Node 端做批量校验,也便于写单测。
大范围分析放服务端。 浏览器里遍历 10 万个多边形做 booleanIntersects,即使有 R 树索引也要几百毫秒,主线程一卡页面就掉帧。超过约 2 万要素的分析请求,走服务端更稳。
按需引入 Turf 子包。 @turf/turf 7.4.0 有 117 个依赖,整包打入会显著增加首屏体积;@turf/distance 这类子包只有几 KB。
插件锁版本,升级先看主版本变更。 插件普遍依赖 MapLibre 内部行为,MapLibre 升主版本(如 v5→v6)时插件往往滞后。锁死 package.json 里的 maplibre-gl 版本,升级前先跑插件的示例。
别把插件当标准。 插件解决了 80% 的场景,但剩下 20% 往往要改源码或自己实现。用之前先读它的核心文件大小——一个几千行的插件,出问题时读得动;几十万行的框架,就得评估维护成本。
17.8 本章小结
| 要点 | 事实 |
|---|---|
| 查询分工 | queryRenderedFeatures 查视口已渲染要素;querySourceFeatures 查源的已加载瓦片 |
| 矢量源必填 | querySourceFeatures 的 sourceLayer 对矢量源必填,GeoJSON 源忽略 |
| Turf 版本 | @turf/turf@7.4.0,117 个依赖,生产建议按子包引入 |
| 坐标顺序 | 一律 [lng, lat],LngLat 对象要先转数组 |
| 插件分层 | 协议级(最稳)> 自定义图层 > 直接改私有字段 |
| 协议扩展 | addProtocol 返回值类型必须匹配 params.type |