尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Cesium+OpenLayers+PostGIS智慧公交站点三维可视化与空间分析实践

Cesium+OpenLayers+PostGIS智慧公交站点三维可视化与空间分析实践 平时做智慧城市相关项目时三维可视化大屏、地图数据采集编辑、空间分析这三块需求经常是绑在一起的。拿公交站点这个场景来说业务上既要有一张能直观看到站点分布、线路走向的三维大屏又要有让业务人员可以日常维护站点位置的采编工具还要能回答“某个片区的公交覆盖率到底够不够”“新增一个站点能覆盖多少小区”这类空间分析问题。我最近梳理了一套比较完整的落地案例技术栈选的是 Cesium OpenLayers PostGIS。这套组合在 GIS 项目里很常见但很多资料只讲了单个点很少有人把“采编 - 存储分析 - 三维展示”整条链路串起来。这篇文章就把完整方案拆开讲清楚从数据库设计到前端采编再到三维大屏展示和覆盖分析全部给出可复制的代码和配置适合正在做智慧交通、智慧城市大屏项目的开发者参考。1. 项目背景与整体架构1.1 这个系统到底要解决什么问题先看业务场景。一个中大型城市的公交集团每天要管理几百条线路、几千个站点。传统管理方式通常依赖 Excel 表格或者简单的 Web 管理系统站点位置靠人工录入经纬度时间一长就容易出现以下问题站点坐标与实地位置偏差大因为早期数据可能是人工粗略标注的。线路调整后站点数据更新不及时导致地图展示和实际运营脱节。规划新线路时无法快速评估站点覆盖率也不知道哪些区域存在公交盲区。管理层汇报时缺少直观的三维可视化大屏展示效果单薄。所以这个智慧公交站点系统的核心目标有三个数据采编提供一个地图交互界面业务人员可以在地图上新增、修改、删除站点不需要懂 GIS 软件。空间分析基于站点坐标做缓冲区分析、覆盖范围计算、盲区识别。三维展示将站点、线路、覆盖范围搬到三维地球或者三维场景中用于大屏展示和指挥调度。1.2 为什么选择 Cesium OpenLayers PostGIS这套技术栈不是“为了炫技”而是每个组件都承担了不可替代的职责。先说OpenLayers。它是开源 WebGIS 客户端的成熟方案基于 JavaScript 开发兼容性好API 丰富。在采编场景中我们需要在地图上绘制点、线、面需要编辑要素需要和 GeoJSON 格式无缝交互OpenLayers 做这些非常顺手。它不依赖商业授权项目中可以自由使用。然后是PostGIS。它是 PostgreSQL 的空间扩展把数据库升级成了真正的空间数据库。空间数据点、线、面可以像普通数据一样存储、查询、索引。更关键的是PostGIS 内置了大量空间分析函数比如ST_Buffer缓冲区分析、ST_Contains包含判断、ST_Distance距离计算。这些分析如果在前端硬算数据量大时性能会很差放到数据库里执行则高效得多。最后是Cesium。它擅长三维地球和三维场景渲染支持加载地形、影像、3D Tiles、GeoJSON 等多种数据。在智慧公交大屏场景中可以用 Cesium 展示城市建筑白模、公交站点标记、线路轨迹、辐射范围。Cesium 本身也支持创建动态雷达扫描效果、流动线效果这对大屏展示很有价值。1.3 系统架构总览整个系统的调用链路并不复杂可以用下面这张表来表示层级技术组件职责说明前端大屏Cesium Vue三维场景展示、站点标牌、线路轨迹、雷达扫描特效前端采编OpenLayers Vue地图数据采集、站点编辑、属性录入服务端Spring Boot / Node.js提供站点增删改查接口、空间分析接口数据层PostgreSQL PostGIS存储空间数据、执行空间分析 SQL我在做技术选型时还考虑过 Leaflet Turf.js 的方案但最后放弃了。原因很简单Turf.js 虽然能做客户端空间分析但大数据量下性能不稳定而且分析逻辑散落在前端代码里不利于维护。PostGIS 把所有分析能力集中在数据库前端只需要调用接口传参代码结构清晰很多。2. 环境准备与版本说明2.1 基础环境本文示例使用的环境如下具体版本需要根据你的项目实际情况调整操作系统Windows 10 / Ubuntu 20.04 均可。数据库PostgreSQL 14 及以上安装 postgis 扩展。后端框架Spring Boot 2.7 或 Node.js Express本文示例用 Spring Boot。前端框架Vue 3 Vite。GIS 库OpenLayers 7.xCesium 1.10x 版本系列。构建工具npm / pnpm。注意Cesium 版本更新很快不同版本的 API 可能会有调整本文示例以常见版本为准如果遇到 API 不一致优先查阅对应版本文档。2.2 安装 PostGIS在 Ubuntu 环境下的安装命令如下sudo apt update sudo apt install postgresql postgresql-contrib sudo apt install postgis postgresql-14-postgis-3安装完成后创建一个专门的空间数据库sudo -u postgres createdb bus_db sudo -u postgres psql -d bus_db -c CREATE EXTENSION IF NOT EXISTS postgis;创建扩展后可以用下面的 SQL 验证SELECT postgis_version();如果能看到类似3.3 USE_GEOS1 USE_PROJ1 USE_STATS1的输出说明 PostGIS 安装成功。2.3 Cesium 与 OpenLayers 的安装方式前端项目建议使用 Vite 创建npm create vitelatest smart-bus-front -- --template vue cd smart-bus-front npm install然后分别安装 Cesium 和 OpenLayersnpm install cesium openlayersCesium 使用时需要处理静态资源。在 Vite 中可以在vite.config.js中配置import { defineConfig } from vite import vue from vitejs/plugin-vue import cesium from vite-plugin-cesium export default defineConfig({ plugins: [vue(), cesium()] })安装vite-plugin-cesium插件后Cesium 的静态资源会自动处理避免手动配置CESIUM_BASE_URL。3. 数据库设计与 PostGIS 空间分析基础3.1 公交站点相关表结构设计设计数据库表之前先梳理业务实体。这个项目中核心的实体有站点点数据包含站点名称、站点编码、所在区域、经纬度。线路线数据包含线路名称、线路编码、途经站点顺序。线路站点关联记录某条线路经过哪些站点以及站点的顺序。空间字段统一使用geometry(Point, 4326)坐标系使用 WGS84 经纬度。这里有一个很重要的经验存储时统一用 4326 经纬度坐标系展示时由前端进行投影转换。这样可以避免坐标系混乱。创建站点表的 SQL 如下CREATE TABLE bus_station ( id SERIAL PRIMARY KEY, station_code VARCHAR(50) UNIQUE NOT NULL, station_name VARCHAR(100) NOT NULL, district VARCHAR(50), address VARCHAR(200), geom geometry(Point, 4326) NOT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_bus_station_geom ON bus_station USING GIST (geom);线路表CREATE TABLE bus_line ( id SERIAL PRIMARY KEY, line_code VARCHAR(50) UNIQUE NOT NULL, line_name VARCHAR(100) NOT NULL, start_station VARCHAR(100), end_station VARCHAR(100), geom geometry(LineString, 4326), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_bus_line_geom ON bus_line USING GIST (geom);线路站点关联表CREATE TABLE bus_line_station ( id SERIAL PRIMARY KEY, line_id INT NOT NULL REFERENCES bus_line(id), station_id INT NOT NULL REFERENCES bus_station(id), station_order INT NOT NULL );这里使用 GIST 索引至关重要。PostGIS 的空间查询在数据量大时如果没有 GIST 索引会做全表扫描性能下降严重。凡是参与空间查询的几何字段都应该建立 GIST 索引。3.2 PostGIS 常用空间函数PostGIS 的核心价值在空间分析函数。这里先介绍几个本项目会用到的函数方便后面实战代码理解。ST_GeomFromText / ST_SetSRID用于从 WKT 文本构建空间对象并指定坐标系。SELECT ST_GeomFromText(POINT(116.4074 39.9042), 4326);ST_Buffer 缓冲区分析ST_Buffer(geom, distance)计算几何对象周围指定距离的缓冲区。注意4326 坐标系单位是度直接使用米为单位计算缓冲区是不正确的。解决方法是在计算时使用地理坐标系类型的函数ST_Buffer(geography(geom), 500)或者先投影到合适的分带投影坐标系。为了方便可以使用地理类型SELECT station_name, ST_Buffer(geom::geography, 500) AS buffer_geom FROM bus_station;ST_DWithin 距离范围内判断判断点与点、点与面的距离是否在指定范围内常用于“某个坐标点是否在站点500米范围内”这种查询。SELECT * FROM bus_station WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography, 500);ST_Contains / ST_Intersects用于判断几何对象之间的包含、相交关系覆盖分析中非常常用。3.3 初始化示例数据为了方便演示先插入一些测试数据INSERT INTO bus_station (station_code, station_name, district, geom) VALUES (S001, 人民广场站, 中心区, ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)), (S002, 科技园站, 高新区, ST_SetSRID(ST_MakePoint(116.4128, 39.9167), 4326)), (S003, 火车站, 中心区, ST_SetSRID(ST_MakePoint(116.4200, 39.9000), 4326)), (S004, 市政府站, 政务区, ST_SetSRID(ST_MakePoint(116.4300, 39.9200), 4326)), (S005, 文化广场站, 中心区, ST_SetSRID(ST_MakePoint(116.3980, 39.9100), 4326));线路示例数据INSERT INTO bus_line (line_code, line_name, start_station, end_station, geom) VALUES (L001, 1路公交, 人民广场站, 文化广场站, ST_SetSRID(ST_MakeLine( ARRAY[ ST_MakePoint(116.4074, 39.9042), ST_MakePoint(116.4128, 39.9167), ST_MakePoint(116.4300, 39.9200), ST_MakePoint(116.3980, 39.9100) ] ), 4326));4. OpenLayers 采编模块实战4.1 采编模块的功能拆分OpenLayers 采编模块的思路很直接核心就是两件事一是地图展示二是要素编辑。对于地图展示需要一个底图。如果是内网离线环境可以使用本地瓦片服务如果有外网条件可以使用常见的在线瓦片。这里为了示例简单使用 OpenStreetMap 免费瓦片。实际生产项目中优先考虑合规的商用地图服务或自建瓦片服务。对于要素编辑OpenLayers 提供了Draw和Modify两个交互类。Draw用于绘制点、线、面Modify用于修改已有要素坐标。采编模块最核心的流程是从后端加载已有站点数据渲染到地图上。用户点击“新增站点”进入绘制模式在地图上点击放置站点。用户点击“编辑站点”进入修改模式可以拖动站点位置。保存时将 GeoJSON 数据提交到后端。4.2 初始化 OpenLayers 地图在 Vue 组件中创建一个StationEdit.vue核心代码框架如下template div idol-map classmap-container/div /template script setup import { onMounted, onUnmounted, ref } from vue import Map from ol/Map import View from ol/View import TileLayer from ol/layer/Tile import OSM from ol/source/OSM import VectorLayer from ol/layer/Vector import VectorSource from ol/source/Vector import { Draw, Modify, Snap } from ol/interaction import GeoJSON from ol/format/GeoJSON import { Style, Circle as CircleStyle, Fill, Stroke, Text } from ol/style import { fromLonLat, toLonLat } from ol/proj const map ref(null) const vectorSource ref(new VectorSource()) let drawInteraction null let modifyInteraction null // 初始化地图 const initMap () { map.value new Map({ target: ol-map, layers: [ new TileLayer({ source: new OSM() }), new VectorLayer({ source: vectorSource.value, style: stationStyle }) ], view: new View({ center: fromLonLat([116.4074, 39.9042]), zoom: 13 }) }) } // 站点样式 const stationStyle new Style({ image: new CircleStyle({ radius: 8, fill: new Fill({ color: #409eff }), stroke: new Stroke({ color: #ffffff, width: 2 }) }), text: new Text({ text: , offsetY: -20, fill: new Fill({ color: #333333 }), font: 14px sans-serif }) }) const loadStations async () { const response await fetch(/api/stations) const data await response.json() const geojson { type: FeatureCollection, features: data.map(item ({ type: Feature, properties: { id: item.id, stationName: item.stationName, stationCode: item.stationCode }, geometry: { type: Point, coordinates: [item.lng, item.lat] } })) } vectorSource.value.clear() const features new GeoJSON().readFeatures(geojson, { dataProjection: EPSG:4326, featureProjection: map.value.getView().getProjection() }) vectorSource.value.addFeatures(features) } /script这段代码有两点需要说明第一地图视图的投影默认是EPSG:3857也就是 Web 墨卡托投影而站点数据是EPSG:4326经纬度。在加载 GeoJSON 时必须通过dataProjection和featureProjection参数做转换否则站点位置会严重偏移。第二站点的样式包括圆形图标和文字标注。实际项目中文字标注可以显示站点名称这里为了通用先留空。如果想显示站点名需要在Text的text字段中动态传入。4.3 新增站点与编辑站点核心交互逻辑如下// 开始绘制站点 const startAddStation () { drawInteraction new Draw({ source: vectorSource.value, type: Point }) map.value.addInteraction(drawInteraction) drawInteraction.on(drawend, (evt) { const feature evt.feature const geometry feature.getGeometry() const lonLat toLonLat(geometry.getCoordinates()) // 将新站点坐标传给后端 saveStation(null, lonLat[0], lonLat[1]) }) } // 开始编辑站点 const startEditStation () { modifyInteraction new Modify({ source: vectorSource.value }) map.value.addInteraction(modifyInteraction) modifyInteraction.on(modifyend, (evt) { const features evt.features features.forEach(feature { const id feature.getProperties().id const geometry feature.getGeometry() const lonLat toLonLat(geometry.getCoordinates()) updateStation(id, lonLat[0], lonLat[1]) }) }) }注意绘制和修改交互只能同时存在一个。如果用户点“新增”时已经开启了“编辑”需要先把modifyInteraction移除否则会互相冲突。保存站点的接口调用可以这样实现const saveStation async (id, lng, lat) { const payload { id: id, stationName: 新增站点, lng: lng, lat: lat } const response await fetch(/api/stations, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }) if (response.ok) { loadStations() } }4.4 后端保存接口后端接口的作用是接收 OpenLayers 前端提交的经纬度存储到 PostGIS 表里。用 Spring Boot 实现时核心逻辑大致如下RestController RequestMapping(/api/stations) public class StationController { Autowired private JdbcTemplate jdbcTemplate; PostMapping public void save(RequestBody StationDto dto) { if (dto.getId() null) { // 新增站点 String sql INSERT INTO bus_station (station_code, station_name, district, geom) VALUES (?, ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326)); jdbcTemplate.update(sql, dto.getStationCode(), dto.getStationName(), dto.getDistrict(), dto.getLng(), dto.getLat()); } else { // 更新站点 String sql UPDATE bus_station SET station_name ?, geom ST_SetSRID(ST_MakePoint(?, ?), 4326) WHERE id ?; jdbcTemplate.update(sql, dto.getStationName(), dto.getLng(), dto.getLat(), dto.getId()); } } }这里ST_MakePoint(lng, lat)直接生成一个点配合ST_SetSRID指定坐标系。值得提醒的是ST_MakePoint的参数顺序是经度在前、纬度在后如果传反了站点会落到完全不同的位置。5. PostGIS 覆盖分析与盲区识别5.1 站点覆盖率计算业务需求通常是这样的以每个公交站点为中心按 500 米半径做缓冲区计算该区域覆盖了多少面积、多少小区、多少人口。PostGIS 计算每个站点缓冲区的 SQLSELECT station_id, station_name, ST_Area(buffer_geom::geography) AS buffer_area FROM ( SELECT id AS station_id, station_name, ST_Buffer(geom::geography, 500) AS buffer_geom FROM bus_station ) t;这里使用::geography转换是因为在 4326 坐标系下直接ST_Buffer(geom, 500)会把 500 当作“度”而不是“米”这样结果完全错误。转换为 geography 类型后缓冲区计算以米为单位。5.2 计算公交盲区盲区识别的思路是先求出所有站点 500 米缓冲区的并集然后用整个城市的研究区域减去这个并集剩下的部分就是公交盲区。SQL 如下WITH buffer_union AS ( SELECT ST_Union(ST_Buffer(geom::geography, 500)::geometry) AS geom FROM bus_station ), city_area AS ( SELECT geom FROM city_boundary WHERE city_name 示例城市 ) SELECT ST_Difference(city_area.geom, buffer_union.geom) AS blind_area FROM city_area, buffer_union;这个 SQL 中有几个细节ST_Union是聚合函数将多个多边形合并成一个多边形用来表示所有站点的覆盖范围并集。ST_Difference从城市区域中减去覆盖范围剩下的是盲区多边形。由于 geography 类型不支持所有空间运算所以计算时先把缓冲区结果 cast 回 geometry最后再处理。5.3 新增站点的最佳位置推荐当业务人员想新增站点时可以用空间查询找出“距离现有站点超过 500 米、且周边人口密集的区域”。这里需要结合人口点数据表SELECT p.id, p.name, p.geom FROM population_points p WHERE NOT EXISTS ( SELECT 1 FROM bus_station s WHERE ST_DWithin(p.geom::geography, s.geom::geography, 500) );这个查询返回所有距离最近站点超过 500 米的人口点这些点附近的区域可以作为新增站点的候选位置。这种 SQL 分析思路也可以在规划新线路时提供参考。6. Cesium 三维大屏展示6.1 创建 Cesium 三维场景Cesium 大屏部分的目标是加载三维地球或城市模型将站点以图标形式展示将线路以轨迹线形式展示并添加一些动态特效。先看最基础的 Cesium 初始化。在 Vue 组件中创建一个BusBigScreen.vuetemplate div idcesium-container classcesium-container/div /template script setup import { onMounted, ref } from vue import * as Cesium from cesium import cesium/Build/Cesium/Widgets/widgets.css const viewer ref(null) onMounted(() { // 如果使用离线环境需要配置本地影像服务地址 // 这里使用默认的 Bing 影像生产项目建议替换为自建瓦片 viewer.value new Cesium.Viewer(cesium-container, { animation: false, baseLayerPicker: false, geocoder: false, timeline: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, fullscreenButton: false, selectionIndicator: false }) }) /script style scoped .cesium-container { width: 100%; height: 100vh; } /style初始化时关闭了很多默认控件是因为大屏页面通常只需要展示地图内容不需要用户进行复杂的视图切换和操作。如果项目使用离线环境没有外网访问 Cesium 的在线影像源需要自行搭建瓦片服务然后通过WebMapServiceImageryProvider或UrlTemplateImageryProvider加载本地瓦片。6.2 加载公交站点标牌将站点数据从后端接口拉取后在 Cesium 中加载。最简单的方式是使用Cesium.GeoJsonDataSource直接加载 GeoJSON 数据const loadStationEntity async () { const response await fetch(/api/stations/geojson) const geojson await response.json() const dataSource await Cesium.GeoJsonDataSource.load(geojson, { clampToGround: true }) dataSource.entities.values.forEach(entity { // 设置站点图标样式可以换成自己的图标资源 entity.billboard new Cesium.BillboardGraphics({ image: /images/bus_stop.png, width: 32, height: 32, verticalOrigin: Cesium.VerticalOrigin.BOTTOM }) entity.label new Cesium.LabelGraphics({ text: entity.properties.stationName?.getValue() || , font: 14px sans-serif, fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -34) }) }) viewer.value.dataSources.add(dataSource) }clampToGround: true是将站点标记贴合到地面上避免因为地形起伏导致标注悬浮或陷入地下。对于大屏展示来说标注是否贴地直接影响视觉效果。如果不想使用 GeoJSON 加载也可以直接使用viewer.entities.add手动添加实体。两种方式的区别在于GeoJsonDataSource适合批量加载手动添加适合单个或少量实体。6.3 公交线路轨迹动态展示公交线路在三维大屏上一般有两种展示方式静态的线路轨迹线和动态的流动效果。静态线路用PolylineGraphics即可const addBusLine (lineGeom, lineName) { // lineGeom 是线坐标数组格式为 [[lng, lat], [lng, lat], ...] const positions lineGeom.map(coord Cesium.Cartesian3.fromDegrees(coord[0], coord[1])) viewer.value.entities.add({ name: lineName, polyline: { positions: positions, width: 4, material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.2, color: Cesium.Color.fromCssColorString(#00d4ff) }) } }) }动态流动效果稍微复杂一点需要定时更新材质偏移量或者使用PolylineTrailLinkMaterialProperty。网上有各种自定义材质实现核心原理是给 Polyline 材质传入一个随时间变化的time参数。最简单的实现方式是启动一个requestAnimationFrame循环不断更新材质属性。不过要提醒一下动态特效对渲染性能有一定消耗。如果大屏上有几十条线路同时播放流动效果需要在效果与性能之间做权衡。实际项目中可以只对重点线路启用流动效果其他线路使用静态样式。6.4 站点覆盖范围三维可视化PostGIS 分析出来的覆盖范围也就是站点 500 米缓冲区也可以在 Cesium 中叠加展示。这种面数据通常以 GeoJSON 形式输出加载方式和站点类似const loadBufferOverlay async () { const response await fetch(/api/stations/buffer-geojson?radius500) const geojson await response.json() const dataSource await Cesium.GeoJsonDataSource.load(geojson, { clampToGround: true }) dataSource.entities.values.forEach(entity { entity.polygon new Cesium.PolygonGraphics({ material: Cesium.Color.fromCssColorString(#00d4ff).withAlpha(0.3), outline: true, outlineColor: Cesium.Color.WHITE }) }) viewer.value.dataSources.add(dataSource) }需要注意的是如果缓冲区数据量很大比如一个城市几千个站点全部用GeoJsonDataSource加载到三维场景中渲染压力会很大。这时候需要考虑聚合展示或者分级加载方案。6.5 雷达扫描特效Cesium 雷达扫描效果是社区里比较热门的特效常用于大屏页面突出某个重点区域。实现一个简单的圆形雷达扫描效果可以通过绘制多个半径递增的圆环并控制透明度和旋转来实现。网上常见的方案有两种使用CallbackProperty动态修改多边形参数。使用自定义材质PolylineDashMaterialProperty或Cesium.Material扩展。下面给一个使用简单方式实现雷达波纹的示例const addRadarEffect (longitude, latitude, radius) { const center Cesium.Cartesian3.fromDegrees(longitude, latitude) const positions [] // 生成圆形边缘点 for (let i 0; i 360; i 3) { const angle Cesium.Math.toRadians(i) const x radius * Math.cos(angle) const y radius * Math.sin(angle) positions.push(new Cesium.Cartesian3(center.x x, center.y y, center.z)) } viewer.value.entities.add({ polyline: { positions: positions, width: 4, material: new Cesium.ColorMaterialProperty(Cesium.Color.CYAN.withAlpha(0.6)) } }) }这里的思路是直接以 Cartesian3 坐标计算圆形边缘位置。更精细的雷达扫描效果会涉及椭圆、旋转、透明度动画核心代码量更大本文先给出基础版本。7. 前后端联调与接口设计7.1 接口清单整个系统涉及三块主要接口这里统一整理接口方法说明/api/stationsGET获取全部站点OpenLayers 采编使用/api/stationsPOST新增或更新站点/api/stations/{id}DELETE删除站点/api/stations/geojsonGET返回 GeoJSON 格式站点数据Cesium 使用/api/stations/buffer-geojsonGET返回站点缓冲区 GeoJSON/api/analysis/blind-areaGET返回公交盲区面数据7.2 站点 GeoJSON 接口示例先看 Cesium 需要的 GeoJSON 接口。后端可以这样实现GetMapping(/geojson) public String getStationsGeoJson() { String sql SELECT id, station_name, station_code, ST_AsGeoJSON(geom) AS geojson FROM bus_station; ListMapString, Object rows jdbcTemplate.queryForList(sql); StringBuilder sb new StringBuilder(); sb.append({\type\:\FeatureCollection\,\features\:[); for (int i 0; i rows.size(); i) { MapString, Object row rows.get(i); sb.append({\type\:\Feature\,\properties\:{); sb.append(\id\:\).append(row.get(id)).append(\,); sb.append(\stationName\:\).append(row.get(station_name)).append(\,); sb.append(\stationCode\:\).append(row.get(station_code)).append(\); sb.append(},\geometry\:).append(row.get(geojson)); sb.append(}); if (i rows.size() - 1) { sb.append(,); } } sb.append(]}); return sb.toString(); }这里使用ST_AsGeoJSON将 PostGIS 几何对象直接转换成 GeoJSON 格式字符串省去了手工拼接坐标的麻烦。注意实际项目中建议使用 Jackson 等 JSON 库构建避免字符串拼接出错。7.3 缓冲区分析接口示例缓冲区分析接口需要接收两个参数radius半径值以及可选的stationId指定某个站点。GetMapping(/buffer-geojson) public String getBufferGeoJson(RequestParam(defaultValue 500) double radius) { String sql SELECT ST_AsGeoJSON(ST_Union(ST_Buffer(geom::geography, ?)::geometry)) AS geojson FROM bus_station; String geojson jdbcTemplate.queryForObject(sql, String.class, radius); return {\type\:\FeatureCollection\,\features\:[{\type\:\Feature\,\properties\:{}, \geometry\: geojson }]}; }ST_Union的作用是合并所有站点的缓冲区。如果每个站点独立展示不合并可以改成按站点点 ID 分组输出多个 feature。8. 常见问题与排查思路8.1 常见报错清单问题现象常见原因解决思路Extension postgis not availablePostgreSQL 版本与 PostGIS 插件不匹配检查 PostgreSQL 版本安装对应版本 postgis 包站点位置偏移严重坐标系转换错误检查前端dataProjection是否设置为EPSG:4326ST_MakePoint后点位置错误经纬度参数传反确认ST_MakePoint(lng, lat)顺序缓冲区面积异常大在 4326 坐标系直接用米做单位改用::geography类型或投影坐标系Cesium 加载 GeoJSON 不显示数据格式错误或缺少clampToGround打开浏览器控制台查看 JSON 解析报错OpenLayers 绘制后无法保存缺少toLonLat转换绘制坐标是投影坐标需要转经纬度再提交动态流动线路卡顿线路过多或材质更新频率过高减少动态线路数量使用静态材质替代8.2 OpenLayers 坐标转换问题详解OpenLayers 地图默认使用 Web 墨卡托投影EPSG:3857所以通过Draw交互获取的坐标点是投影坐标。如果直接把这个坐标传给后端存入 PostGIS数据库会把它当作经纬度处理结果站点位置就会乱掉。正确做法是保存时用toLonLat转回EPSG:4326import { toLonLat } from ol/proj const lonLat toLonLat(geometry.getCoordinates())加载数据时反过来用fromLonLat将经纬度转成投影坐标import { fromLonLat } from ol/proj map.getView().setCenter(fromLonLat([116.4074, 39.9042]))如果你是通过GeoJSON格式读写数据则不需要手动转换只要在readFeatures时设置好dataProjection和featureProjection即可。8.3 Cesium 瓦片离线部署说明很多政务项目运行在内网环境无法访问外部在线影像服务。这种情况下Cesium 场景中的影像底图需要自行解决。常见方案有两种使用本地的传统 Web 地图瓦片通过UrlTemplateImageryProvider加载。使用单张静态影像图叠加SingleTileImageryProvider。示例const imageryProvider new Cesium.UrlTemplateImageryProvider({ url: http://your-tile-server/tiles/{z}/{x}/{y}.png, maximumLevel: 18 }) viewer.value.imageryLayers.addImageryProvider(imageryProvider)注意如果使用本地瓦片Cesium 初始化代码中需要把默认的影像图层移除否则外网影像加载失败时会一直等待。9. 最佳实践与工程建议9.1 坐标系统一规范这是整个项目最基础也最重要的规范。在智慧公交系统中涉及多个环节的数据交换只要有一个环节坐标系不一致站点位置就会偏移。我的建议是数据库存储层统一使用EPSG:4326几何类型使用geometry(Point, 4326)。后端交换格式统一使用 GeoJSONGeoJSON 规范中坐标就是EPSG:4326经纬度。OpenLayers 展示层地图控件使用EPSG:3857在读写接口时转换。Cesium 展示层直接使用EPSG:4326经纬度内部自动处理。9.2 空间索引和性能优化PostGIS 表必须为空间字段建立 GIST 索引。随着站点数量增长不带索引的ST_DWithin查询会从毫秒级恶化到秒级甚至更慢。建立索引的 SQLCREATE INDEX idx_bus_station_geom ON bus_station USING GIST (geom);另外ST_Buffer这种计算量较大的分析不建议在每次请求时实时计算整个城市的所有站点。可以提前通过定时任务计算好结果存入独立的分析结果表前端请求时直接读取。9.3 大屏前端性能优化Cesium 大屏渲染性能是重点。建议从以下几点入手站点数据量超过几千个时使用Cesium.PrimitiveCollection替代单个 Entity或者使用 3D Tiles 批量渲染。动态特效数量控制。雷达扫描、流动线路这类动态效果非常消耗 GPU建议最多只保留 5 到 10 个。页面使用懒加载。大屏进入时先加载底图和基础站点用户点击线路详情后再加载该线路的轨迹和动态效果。如果项目开发了“前端页面数据实时刷新”考虑使用 WebSocket 推送增量更新而不是整表刷新。9.4 数据备份与权限管理生产环境中空间数据是最重要的资产。建议定期执行pg_dump备份脚本至少保留最近 7 天的备份。使用 Flyway 等数据库迁移工具管理表结构变更避免手工执行 SQL 导致生产环境不一致。编辑接口必须做权限校验只有授权用户才能新增和修改站点。大屏展示接口可以放行只读权限。删除站点操作建议采用逻辑删除增加deleted字段而不是物理删除避免误操作导致历史数据丢失。9.5 多版本 Cesium 之间的差异Cesium 的版本迭代速度很快比如viewer.entities.add这种方式虽然兼容性很好但部分 API 在新版本中会被标记为废弃。比如较新的 Cesium 版本对GeoJsonDataSource的样式设置方式有调整。如果你直接复制网上旧代码到新版本环境很可能会遇到BillboardGraphics参数不生效或者样式不一致的问题。建议在项目中锁定 Cesium 的具体版本npm install cesium1.103.0 --save-exact锁定版本后再升级时单独处理。10. 总结智慧公交站点系统是典型的“WebGIS 空间分析 三维可视化”综合项目。它用 OpenLayers 解决了数据采编的交互问题用 PostGIS 解决了空间分析的计算问题用 Cesium 解决了大屏展示的视觉问题。三个技术点各司其职组合在一起就能形成一个完整可落地的业务闭环。整套方案中最值得关注的点有三个第一PostGIS 是项目的数据中枢所有的空间计算都围绕它展开包括缓冲区分析、盲区识别、覆盖分析。把分析逻辑下沉到数据库层比在前端用第三方库硬算更可靠、更高效。第二坐标系一致性和转换贯穿了整个项目OpenLayers 的投影坐标、GeoJSON 的经纬度、PostGIS 的 4326 坐标系任何一个环节不加注意都会导致位置错误。第三Cesium 大屏展示的重点不只是“能显示”而是要在大量数据和动态效果之间找到性能平衡点。如果你正在规划类似项目可以先从站点的 CRUD 和 PostGIS 空间查询开始把数据链路跑通再逐步加上 OpenLayers 的绘制编辑和 Cesium 的三维展示。每一步都可以独立测试风险可控。后续还可以扩展的方向包括站点客流热力图、基于站点覆盖范围的人口统计、线路运行轨迹回放、与 Three.js 结合做更复杂的 3D 场景特效。空间数据可视化这条路线可以深入探索的点还有很多。
返回列表