WebGIS + OpenLayers + GeoServer + PostGIS #量算 #点聚合
前言
前两篇我们搞定了地图初始化和基础交互,但真实的业务地图数据都在后端:几千条管线、地块、监测点位存在数据库里,前端怎么高效拿到、怎么渲染、怎么做空间查询?
本篇讲透一套最经典的开源全栈方案:
前端 Vue + OpenLayers → HTTP → GeoServer(发布服务)→ PostgreSQL + PostGIS(空间数据库)外加两个高频组件完整代码:
- ol-ext 量算(测距、测面积、面积单位)
- 点聚合 Cluster(上万 POI 不卡顿)
工程化能直接抄,适合水利、管线、自然资源、应急项目。
一、全栈架构图
┌─────────────────────────────┐ │ 前端 Vue3 + OpenLayers │ │ ┌─ WMS 底图/影像 ────────┐ │ │ ├─ WFS 矢量(点击/查询) ──┤ │ HTTP/JSON │ ├─ ol-ext 量算 │ │ ────────────▶ GeoServer(8080) │ └─ Cluster 点聚合 │ │ │ JDBC └─────────────────────────────┘ │ PostgreSQL + PostGIS (管线/地块/监测点 空间表)分工一句话:
- PostGIS:存空间数据,做复杂空间计算(
ST_Intersects、ST_Buffer) - GeoServer:把空间表发布成标准 OGC 服务(WMS 瓦片图、WFS 矢量)
- OpenLayers:前端渲染 + 交互 + 轻量计算
大数据空间分析必须放 PostGIS,前端只做小数据量的 Turf。
二、后端准备
1. 安装 PostgreSQL + PostGIS
# Ubuntu/Debian sudo apt install postgresql postgis # 创建空间数据库 sudo -u postgres psql -c "CREATE DATABASE gisdb;" sudo -u postgres psql -d gisdb -c "CREATE EXTENSION postgis;"2. 建空间表(管线示例)
-- 开启空间扩展 CREATE EXTENSION IF NOT EXISTS postgis; -- 管线段表 CREATE TABLE pipe_line ( id SERIAL PRIMARY KEY, name VARCHAR(50), code VARCHAR(20), diameter NUMERIC(10,2), -- 管径mm geom geometry(LineString, 4326) -- 空间字段,WGS84 ); -- 监测点位表 CREATE TABLE monitor_point ( id SERIAL PRIMARY KEY, name VARCHAR(50), status INT DEFAULT 1, geom geometry(Point, 4326) ); -- 插入测试数据 INSERT INTO pipe_line (name, code, diameter, geom) VALUES ('主管道A', 'P-001', 800, ST_GeomFromText('LINESTRING(108.95 34.27, 108.97 34.28, 108.99 34.26)', 4326)), ('支管B', 'P-002', 400, ST_GeomFromText('LINESTRING(108.96 34.25, 108.98 34.24)', 4326)); INSERT INTO monitor_point (name, status, geom) VALUES ('站点1', 1, ST_SetSRID(ST_MakePoint(108.95, 34.27), 4326)), ('站点2', 0, ST_SetSRID(ST_MakePoint(108.99, 34.26), 4326)), ('站点3', 1, ST_SetSRID(ST_MakePoint(108.97, 34.28), 4326));空间字段建议都用 4326 存原始经纬度,发布时再投影,避免坐标系混乱。
3. 空间查询(PostGIS 示例,后端接口用)
-- 找某点周围500米内的监测点(注意投影换算) SELECT name FROM monitor_point WHERE ST_DWithin( geom::geography, ST_SetSRID(ST_MakePoint(108.96, 34.26), 4326)::geography, 500 ); -- 找与某管线相交的所有管线 SELECT a.name, b.name FROM pipe_line a, pipe_line b WHERE a.id = 1 AND ST_Intersects(a.geom, b.geom); -- 算面积、算长度 SELECT ST_Area(geom::geography) FROM some_polygon; SELECT ST_Length(geom::geography) FROM pipe_line WHERE id=1;4. GeoServer 发布
- 下载 GeoServer,默认 8080,
admin/geoserver - 新建工作区 workspace → 新建数据源 → PostgreSQL/PostGIS(填库名 / 账号)
- 图层里对每个表新建图层,设置SRS 为 EPSG:4326,配置样式
- 预览时即可拿到 WMS/WFS 服务地址
重点:GeoServer 默认可能跨域,用CORS Filter配置放行,否则前端报跨域。
三、前端全栈对接(Vue3 完整代码)
<template> <div class="map-wrap"> <div id="map"></div> <div class="bar"> <button @click="loadWms">加载WMS底图</button> <button @click="loadWfs">加载WFS管线</button> <button @click="queryByClick">点击查询</button> <button @click="clearVector">清除矢量</button> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import Map from 'ol/Map' import View from 'ol/View' import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer' import { TileWMS, Vector as VectorSource, Cluster } from 'ol/source' import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style' import { fromLonLat } from 'ol/proj' import GeoJSON from 'ol/format/GeoJSON' import { bbox as bboxStrategy } from 'ol/loadingstrategy' import 'ol/ol.css' let map, vectorSource // ========= 1. 初始化地图 ========= const initMap = () => { map = new Map({ target: 'map', layers: [], view: new View({ center: fromLonLat([108.96, 34.26]), zoom: 11, projection: 'EPSG:3857' }) }) } // ========= 2. 加载 WMS(GeoServer发布的底图/影像)========= const loadWms = () => { const wmsLayer = new TileLayer({ source: new TileWMS({ url: 'http://localhost:8080/geoserver/gisdb/wms', params: { LAYERS: 'gisdb:pipe_line', // workspace:layer TILED: true }, serverType: 'geoserver' }) }) map.addLayer(wmsLayer) } // ========= 3. 加载 WFS 矢量(管线,视口内按需加载)========= const loadWfs = () => { vectorSource = new VectorSource({ url: (extent) => `http://localhost:8080/geoserver/gisdb/ows?service=WFS&version=1.1.0&request=GetFeature` + `&typeName=gisdb:pipe_line&outputFormat=application/json&srsname=EPSG:3857` + `&bbox=${extent.join(',')},EPSG:3857`, format: new GeoJSON(), strategy: bboxStrategy // 只加载当前视口,平移再按需请求 }) const vectorLayer = new VectorLayer({ source: vectorSource, style: new Style({ stroke: new Stroke({ color: '#0a6cff', width: 3 }) }) }) map.addLayer(vectorLayer) } // ========= 4. 点击查询 WFS 要素属性 ========= const queryByClick = () => { map.on('singleclick', (evt) => { const feature = map.forEachFeatureAtPixel(evt.pixel, f => f) if (feature) { const p = feature.getProperties() console.log('管线属性:', { name: p.name, code: p.code, diameter: p.diameter }) alert(`管段:${p.name}\n编码:${p.code}\n管径:${p.diameter}mm`) } }) } const clearVector = () => { vectorSource?.clear() } onMounted(() => { initMap() loadWms() loadWfs() }) onUnmounted(() => { map?.dispose() }) </script>关键点:WFS 用bbox 策略+
srsname=EPSG:3857,前端只拿当前屏幕的数据,平移再自动请求,几万条也不会卡。
四、ol-ext 量算(测距 + 测面积)完整代码
原生 OpenLayers 没有量算控件,业界标准是用ol-ext(法国团队开源,插件生态好)。
npm install ol-ext<template> <div class="map-wrap"> <div id="map"></div> <div class="bar"> <button @click="startMeasure(1)">测距</button> <button @click="startMeasure(2)">测面积</button> <button @click="clearMeasure">清除</button> </div> </div> </template> <script setup> import { onMounted, onUnmounted } from 'vue' import Map from 'ol/Map' import View from 'ol/View' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' import { fromLonLat } from 'ol/proj' import 'ol/ol.css' // ol-ext 量算控件(按需引入) import Measure from 'ol-ext/control/Measure' import 'ol-ext/dist/ol-ext.min.css' let map, measureControl, measureSource const initMap = () => { measureSource = new VectorSource() map = new Map({ target: 'map', layers: [ // 这里放你的底图(天地图/GeoServer WMS) new VectorLayer({ source: measureSource }) // 量算图层 ], view: new View({ center: fromLonLat([108.96, 34.26]), zoom: 11 }) }) // 创建量算控件 measureControl = new Measure({ source: measureSource, // 测量结果画到哪个图层 maxLength: 200000, // 最大测距(米) maxArea: 100000000, // 最大测面积(㎡) geodesic: true, // 球面测距(精确,推荐) continueMeasure: true, // 连续测量 units: { length: { 'm': 1, 'km': 1000 }, area: { 'm²': 1, 'km²': 1000000, '亩': 666.667 } } }) map.addControl(measureControl) } // type: 1测距离, 2测面积 const startMeasure = (type) => { const isLength = type === 1 measureControl.getInteraction(true) // 重置当前测量 measureControl.set('type', isLength ? 'length' : 'area') measureControl.activate() } const clearMeasure = () => { measureControl.deactivate() measureSource.clear() measureControl.reset() } onMounted(initMap) onUnmounted(() => { map?.dispose() }) </script>说明:ol-ext 的 Measure 支持距离 / 面积,自带标签实时显示数值和单位,
geodesic:true用球面距离(WGS84)比平面更准,量算单位里可以加 “亩”,国内项目常用。
五、点聚合 Cluster 完整代码(上万 POI 不卡顿)
点位几千上万时直接渲染会掉帧,用 OpenLayers 自带的Cluster 聚合:近的点合成一个带数字的圆,放大自动散开。
<script setup> import { ref, onMounted } from 'vue' import Map from 'ol/Map' import View from 'ol/View' import VectorLayer from 'ol/layer/Vector' import VectorSource from 'ol/source/Vector' import Cluster from 'ol/source/Cluster' import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style' import { Point } from 'ol/geom' import Feature from 'ol/Feature' import { fromLonLat } from 'ol/proj' let map, clusterSource, vectorLayer const initCluster = () => { // 原始点位数据源(可以来自WFS/接口/GeoJSON) const pointSource = new VectorSource() // 生成5000个随机测试点(模拟真实POI) for (let i = 0; i < 5000; i++) { const lng = 108.9 + Math.random() * 0.12 const lat = 34.2 + Math.random() * 0.12 const f = new Feature({ geometry: new Point(fromLonLat([lng, lat])), name: `点位${i}` }) pointSource.addFeature(f) } // Cluster 聚合数据源:distance=像素距离,在此范围内合并 clusterSource = new Cluster({ source: pointSource, distance: 40 // 40像素内的点聚合 }) vectorLayer = new VectorLayer({ source: clusterSource, style: (feature) => { const size = feature.get('features').length // 聚合点数 let color = '#1e88e5' if (size >= 100) color = '#e53935' else if (size >= 30) color = '#fb8c00' return new Style({ image: new CircleStyle({ radius: size >= 100 ? 26 : size >= 30 ? 20 : 14, fill: new Fill({ color }), stroke: new Stroke({ color: '#fff', width: 2 }) }), text: new Text({ text: size.toString(), // 显示聚合数量 fill: new Fill({ color: '#fff' }), font: 'bold 12px sans-serif' }) }) } }) map.addLayer(vectorLayer) // 点击聚合点 → 弹层显示内部点位(可选) map.on('singleclick', (evt) => { const feature = map.forEachFeatureAtPixel(evt.pixel, f => f) if (feature) { const feats = feature.get('features') if (feats && feats.length > 1) { console.log('该聚合含', feats.length, '个点位', feats.map(f => f.get('name'))) } } }) } </script>说明:
Cluster返回的每个 feature 里带features数组,就是聚合进去的子点位。样式根据数量分色分级,一眼看出密度。真实项目点位从 WFS / 接口拿,替换掉随机生成那段即可。
六、空间计算怎么选?前端 Turf vs 后端 PostGIS
表格
| 场景 | 推荐 | 原因 |
|---|---|---|
| 两个点 / 线算距离 | Turf.js | 前端零请求,秒回 |
| 鼠标实时绘制算面积 | Turf/ol-ext | 交互期,后端来不及 |
| 点是否在面内(单个) | Turf | 轻量 |
| 几千条要素做缓冲区 / 相交 | PostGIS | 前端会卡死,SQL 秒出 |
| 全库范围检索、空间索引 | PostGIS | 数据量大必须后端 |
铁律:前端只算用户能一眼看到、量级小的;批量、全量、需要索引的交给 PostGIS,前端通过接口拿结果。
七、全栈踩坑清单(血泪)
- 坐标系三层不一致:库 4326、GeoServer 发布的 SRS、前端 3857,任何一层没对上就偏移。建议库和 GeoServer 统一 4326,前端投影到 3857
- GeoServer 跨域:默认 CORS 可能没开,前端请求直接失败。配好 CORS Filter
- WFS 一次性加载全库:几万条
addFeatures直接白屏 → 用bbox策略视口加载 - WMS 和 WFS 搞混:WMS 返回图片适合底图 / 影像;WFS 返回矢量适合查询 / 点击。图层多了别都用 WMS
- ol-ext 样式未引入:
ol-ext.min.css不引,量算控件样式全乱 - Cluster 聚合
distance调太小:聚合不明显;调太大:缩放时点位分布失真,一般 30-50 - 量算
geodesic:false:平面量算在大范围时误差大,跨省测距误差可能到几公里,务必开geodesic:true - 内存泄漏:路由切换 / 页面销毁必须
map.dispose()、移除交互和监听,否则切几次页面就卡
八、小结
全栈 WebGIS 的真正难点不在前端画地图,而在空间数据的存取、发布和计算分层:
- 存储 / 大数据计算 → PostGIS
- 服务发布 → GeoServer
- 渲染交互 → OpenLayers
- 轻量量算 → ol-ext / Turf.js