WebGIS + OpenLayers + GeoServer + PostGIS #量算 #点聚合

发布时间:2026/10/30 8:26:35
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,前端通过接口拿结果。

七、全栈踩坑清单(血泪)

  1. 坐标系三层不一致:库 4326、GeoServer 发布的 SRS、前端 3857,任何一层没对上就偏移。建议库和 GeoServer 统一 4326,前端投影到 3857
  2. GeoServer 跨域:默认 CORS 可能没开,前端请求直接失败。配好 CORS Filter
  3. WFS 一次性加载全库:几万条addFeatures直接白屏 → 用bbox策略视口加载
  4. WMS 和 WFS 搞混:WMS 返回图片适合底图 / 影像;WFS 返回矢量适合查询 / 点击。图层多了别都用 WMS
  5. ol-ext 样式未引入:ol-ext.min.css不引,量算控件样式全乱
  6. Cluster 聚合distance调太小:聚合不明显;调太大:缩放时点位分布失真,一般 30-50
  7. 量算geodesic:false:平面量算在大范围时误差大,跨省测距误差可能到几公里,务必开geodesic:true
  8. 内存泄漏:路由切换 / 页面销毁必须map.dispose()、移除交互和监听,否则切几次页面就卡

八、小结

全栈 WebGIS 的真正难点不在前端画地图,而在空间数据的存取、发布和计算分层:

  • 存储 / 大数据计算 → PostGIS
  • 服务发布 → GeoServer
  • 渲染交互 → OpenLayers
  • 轻量量算 → ol-ext / Turf.js