
智慧公交站点系统看起来是一个三维可视化项目很多团队一上来就只盯着 Cesium 的大屏效果结果做到一半发现数据采编、覆盖分析、坐标统一这些问题比三维渲染更耗时。站点数据不是一次建完就结束的公交站点的增加、迁移、废弃每天都在发生覆盖分析也不是简单画个圆它关系到站点500米范围覆盖了多少居住区、哪里是盲区、新增站点放在哪个位置更合理。只用静态数据做演示可以一旦进入日常运营就必须把数据采编、空间分析和三维展示放在同一条完整链路里解决。比较稳妥的做法是把 Cesium、OpenLayers、PostGIS 三套技术组合起来PostGIS 负责空间数据存储和覆盖计算OpenLayers 负责二维地图上的站点采编Cesium 负责三维大屏中的场景渲染。下面按照从数据建模、分析、采编到三维展示的顺序说明这套“智慧公交站点系统”案例可以怎么落地以及每个环节最容易踩的坑。1. 为什么智慧公交站点系统要把 Cesium、OpenLayers、PostGIS 放一起1.1 三种技术在项目中的分工PostGIS 是 PostgreSQL 的空间扩展。公交站点、线路、居住区这些数据本质上都是空间数据如果只用普通表存经纬度后续做距离查询、缓冲区、覆盖分析会非常吃力。PostGIS 提供 geometry 类型和 GIST 空间索引能在数据库端完成空间计算业务接口不需要把大量坐标拉到内存里算。OpenLayers 是二维 Web 地图库适合在 2D 平面地图上做站点位置修正、线路勾画和属性编辑。它的交互成本低桌面浏览器兼容性好Draw 和 Modify 这类交互已经相当成熟编辑人员上手快。Cesium 是三维地球引擎适合把站点地理位置、线路走向、覆盖区域叠加到真实地形和影像上进行展示。管理和汇报场景下三维视角更容易看出空间分布但 Cesium 不适合做精细采编直接在三维场景里逐点画线画点效率和精度都不如二维地图。三种技术的分工可以参考下表技术在系统中的职责最容易出错的地方PostGIS存储站点/线路空间数据计算覆盖、盲区、距离单位、SRID、空间索引OpenLayers二维采编、数据校验、批量修改坐标转换、绘制交互、回写Cesium三维大屏渲染、场景漫游、态势展示数据量、离线影像、特效性能1.2 数据流是一条主线不是三个孤立模块实际项目最忌讳把采编、分析、展示做成三套独立数据。推荐的数据流是PostGIS 作为主数据源。后端通过 GeoJSON 接口向外输出站点数据。OpenLayers 页面加载站点数据使用绘制和编辑交互完成新增、移动、属性修改。保存时调用后端回写接口数据进入 PostGIS。PostGIS 在保存后重新计算覆盖指标。Cesium 大屏读取同一份 GeoJSON 或分析结果进行渲染。这样编辑人员看到的数据和领导大屏上的数据来自同一个库不会出现二维改完、三维没变的情况。这条数据流也是整个项目的主线后面的环境准备、表结构设计和代码实现都围绕它展开。2. 前置准备PostGIS 扩展、空间表设计和前端工程结构2.1 环境版本与初始化不同团队的技术栈会有差异下面是一组比较常见的版本组合。如果原始项目没有明确版本落地前要先确认自己服务器的 PostgreSQL 版本和 Node 版本避免依赖冲突。组件版本建议说明PostgreSQL14 以上PostGIS 3.x 对 14/15/16 支持较好PostGIS3.3 以上提供 geometry、geography、GIST 索引Node.js18 以上前端构建与后端接口示例Vue 33.4示例采用 Vue 组合式 APIOpenLayers8 或 9模块化引入按需 importCesium1.103 以上三维地球渲染PostGIS 安装完成后先进入业务数据库启用扩展CREATE EXTENSION IF NOT EXISTS postgis; SELECT PostGIS_Version();如果CREATE EXTENSION postgis报错通常是 PostgreSQL 服务端没有安装 PostGIS 组件需要先在系统层面安装对应的 postgis 包再执行扩展命令。这一步漏掉后面所有空间函数都不能用。2.2 站点、线路和覆盖分析相关的表结构公交站点和线路先按最小可运行结构设计。站点用点线路用线居住区用面这样覆盖分析可以直接使用空间关系计算。CREATE TABLE bus_stop ( id BIGSERIAL PRIMARY KEY, code VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, address VARCHAR(255), status SMALLINT DEFAULT 1, geom GEOMETRY(Point, 4326) ); CREATE INDEX idx_bus_stop_geom ON bus_stop USING GIST (geom);CREATE TABLE bus_route ( id BIGSERIAL PRIMARY KEY, route_code VARCHAR(32) NOT NULL UNIQUE, route_name VARCHAR(128) NOT NULL, geom GEOMETRY(LineString, 4326), stop_ids BIGINT[] ); CREATE INDEX idx_bus_route_geom ON bus_route USING GIST (geom);覆盖分析还需要一个居住区或人口聚集区表用来判断站点周边覆盖了多少区域CREATE TABLE residential_area ( id BIGSERIAL PRIMARY KEY, name VARCHAR(128), area_geom GEOMETRY(Polygon, 4326) ); CREATE INDEX idx_residential_area_geom ON residential_area USING GIST (area_geom);这里把数据统一存成 4326也就是 WGS84 经纬度。这样做的原因是 PostGIS 的 geography 类型在 4326 下可以直接做米制距离计算Cesium 也能直接使用经纬度坐标OpenLayers 在加载 GeoJSON 时再做投影转换即可。2.3 前端工程目录前端部分建议把 OpenLayers 采编、Cesium 大屏、API 请求分开避免一个 vue 文件越写越长。示例目录结构如下src/ api/ stop.ts coverage.ts maps/ openlayers/ StationEditor.vue cesium/ BigScreen.vue components/ CoverStatCard.vue这个目录只用于示例实际项目可以根据团队习惯调整。核心原则是OpenLayers 采编和 Cesium 大屏不要互相引用对方的 DOM 和视图实例它们共享的只有接口数据和数据状态。3. 用 PostGIS 完成站点覆盖分析与盲区计算3.1 单位陷阱为什么“500 米缓冲区”直接写会错很多人在 PostGIS 里写ST_Buffer(geom, 500)然后发现覆盖范围大得离谱。原因是几何类型在 4326 坐标系下使用经纬度ST_Buffer的半径单位是度不是米。500 度在赤道上约等于 55000 公里这显然不是公交站点的覆盖范围。正确做法有两种第一种用 geography 类型。geography 支持以米为单位的距离计算SELECT id, name, ST_AsGeoJSON( ST_Buffer(geom::geography, 500)::geometry ) AS coverage_geojson FROM bus_stop WHERE id 1;第二种先投影到以米为单位的坐标系比如 3857再做缓冲区SELECT id, name, ST_AsGeoJSON( ST_Transform( ST_Buffer(ST_Transform(geom, 3857), 500), 4326 ) ) AS coverage_geojson FROM bus_stop WHERE id 1;对于城市级项目500 米内用 geography 计算已经足够准确。要注意的是geography 计算会比纯 geometry 慢一点但公交站点量级通常只有几百到几千个性能不是问题。3.2 覆盖范围和覆盖率查询覆盖分析常见的需求是站点 500 米范围覆盖了多少个居住区覆盖率是多少。使用ST_DWithin可以一次完成距离判断不需要先建缓冲区再做相交。SELECT (SELECT COUNT(*) FROM residential_area r WHERE EXISTS ( SELECT 1 FROM bus_stop s WHERE ST_DWithin(s.geom::geography, r.area_geom::geography, 500) ) ) AS covered_count, (SELECT COUNT(*) FROM residential_area) AS total_count;ST_DWithin和ST_Distance的区别是ST_DWithin只要判断是否在范围内可以走空间索引ST_Distance需要计算具体距离性能会更差。覆盖率计算场景优先用ST_DWithin。3.3 盲区查询与新增站点推荐查询没有在任意站点 500 米覆盖范围内的居住区可以用NOT EXISTS配合ST_DWithinSELECT r.id, r.name FROM residential_area r WHERE NOT EXISTS ( SELECT 1 FROM bus_stop s WHERE ST_DWithin(s.geom::geography, r.area_geom::geography, 500) );这个结果可以直接转成 GeoJSON叠加到 OpenLayers 地图上规划人员看到盲区后可以在地图上试画新站点再调用接口重新计算覆盖情况。3.4 分析性能建议空间数据量的增长很容易被忽略。站点少的时候全表扫描也能很快出结果但站点和居住区一旦超过几万条空间索引的作用就体现出来了。性能上需要注意几点bus_stop.geom和residential_area.area_geom都要建 GIST 索引。查询条件尽量写ST_DWithin不要写ST_Distance(...) 500。大范围数据批量分析后执行一次VACUUM ANALYZE帮助优化器更新统计信息。如果每个站点都要生成缓冲区不要在前端做数据库端生成并缓存更合理。4. OpenLayers 采编二维地图上的站点维护入口4.1 后端 API 输出 GeoJSONOpenLayers 不直接连 PostgreSQL而是通过后端 API 获取 GeoJSON。后端把 PostGIS 查询结果转换成标准 GeoJSON 结构。下面是一段 Node.js 的示例const { Pool } require(pg); const pool new Pool({ connectionString: process.env.DATABASE_URL }); app.get(/api/stops.geojson, async (req, res) { const { rows } await pool.query( SELECT id, name, code, ST_AsGeoJSON(geom)::jsonb AS geometry FROM bus_stop WHERE status 1 ); const features rows.map((row) ({ type: Feature, properties: { id: row.id, name: row.name, code: row.code }, geometry: row.geometry })); res.json({ type: FeatureCollection, features }); });这里的关键点是ST_AsGeoJSON(geom)::jsonb它把 PostGIS 几何对象直接转成 GeoJSON 格式后端不需要自己拼坐标数组。4.2 OpenLayers 加载、绘制和编辑OpenLayers 页面加载 GeoJSON 时要把 4326 数据转换到当前视图投影。下面是一个最小示例包含加载、绘制点、修改站点位置三个核心交互。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 GeoJSON from ol/format/GeoJSON; import { Draw, Modify } from ol/interaction; const source new VectorSource(); fetch(/api/stops.geojson) .then((res) res.json()) .then((data) { const features new GeoJSON().readFeatures(data, { featureProjection: EPSG:3857 }); source.addFeatures(features); }); const vectorLayer new VectorLayer({ source }); const map new Map({ target: map, layers: [ new TileLayer({ source: new OSM() }), vectorLayer ], view: new View({ center: transform([116.40, 39.90], EPSG:4326, EPSG:3857), zoom: 12 }) }); const draw new Draw({ source, type: Point }); map.addInteraction(draw); const modify new Modify({ source }); map.addInteraction(modify);绘制完成后需要把 OpenLayers 的坐标转回 4326再提交后端draw.on(drawend, (event) { const geojson new GeoJSON().writeFeatureObject(event.feature, { featureProjection: EPSG:3857, dataProjection: EPSG:4326 }); fetch(/api/stops, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(geojson) }); });GeoJSON().writeFeatureObject会在输出时自动完成投影转换后端收到的一定是 4326 经纬度坐标。修改站点位置时可以在modify的modifyend事件里做同样的事情把更新后的 feature 通过 PUT 接口回写。4.3 回写接口与属性校验采编最忌讳的是前端把任意坐标都传回来正确做法是后端重新校验业务字段和空间范围。下面是一段 POST 接口示例app.post(/api/stops, async (req, res) { const { geometry, properties } req.body; if (geometry.type ! Point) { return res.status(400).json({ message: 站点必须是点要素 }); } const [lng, lat] geometry.coordinates; if (lng 116.0 || lng 117.0) { return res.status(400).json({ message: 经度超出业务范围 }); } await pool.query( INSERT INTO bus_stop (name, code, geom) VALUES ($1, $2, ST_SetSRID(ST_MakePoint($3, $4), 4326)), [properties.name, properties.code, lng, lat] ); res.status(201).json({ message: ok }); });这里用ST_MakePoint构造点再用ST_SetSRID指定 4326。实际项目还需要加权限校验、事务、重复编码检查、日志记录和参数绑定这段代码只演示最核心的写入逻辑。5. Cesium 三维大屏站点数据如何呈现5.1 初始化 Viewer 并加载 GeoJSONCesium 端读取的可以是同一个站点 GeoJSON 接口。先用 Viewer 创建三维场景再加载数据源。import * as Cesium from cesium; const viewer new Cesium.Viewer(cesiumContainer, { baseLayerPicker: false, geocoder: false, animation: false, timeline: false }); const response await fetch(/api/stops.geojson); const geojson await response.json(); const dataSource await Cesium.GeoJsonDataSource.load(geojson, { clampToGround: true, stroke: Cesium.Color.fromCssColorString(#00E5FF), fill: Cesium.Color.fromCssColorString(#007BFF), strokeWidth: 2 }); viewer.dataSources.add(dataSource); viewer.zoomTo(dataSource);如果使用 Cesium 官方环境需要配置 Ion 令牌如果是内网项目则需要换成本地瓦片、离线地形或合规的影像服务。大屏项目不能把公网影像源作为正式依赖这一点要在项目初期就确认。5.2 站点标注与聚合GeoJsonDataSource 能直接把站点加载成点实体但默认样式往往不能满足大屏要求。更可控的做法是遍历 GeoJSON 后逐个添加带样式和文字的实体const features geojson.features; features.forEach((feature) { const [lng, lat] feature.geometry.coordinates; viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(lng, lat), point: { pixelSize: 10, color: Cesium.Color.CYAN }, label: { text: feature.properties.name, font: 12px sans-serif, pixelOffset: new Cesium.Cartesian2(0, -14) } }); });当站点数量达到几百个以上时逐个添加实体会让场景明显卡顿。建议在项目里做两层处理第一层按区域或线路分组默认只显示当前视角范围内的站点第二层利用 Cesium 的聚类能力把近距离站点合并显示数量缩放后再展开。5.3 可选大屏特效雷达扫描、流动墙面、动态光照很多三维大屏案例会加入雷达扫描、动态墙面、可视域分析等效果。这些效果在 Cesium 里可以用自定义 Material 或 Primitive 实现。例如雷达扫描通常用CallbackProperty动态修改圆的半径和角度流动墙面用自定义 shader 控制材质 UV。但要注意一个工程判断特效是增强项不是第一步。第一个可用版本应该先把站点数据和覆盖分析准确展示出来再做雷达扫描、动态光照、天际线分析这些效果。否则一旦数据渲染有问题特效反而会掩盖真实的定位和空间错误排查会更困难。6. 从采编到分析再到三维展示完整调用链路怎么串6.1 一个新增站点的完整流程站在使用者角度看新增一个站点不是只在地图上点一下而是一串连续动作。推荐按下面的流程设计OpenLayers 地图加载站点列表和盲区分布。规划人员在地图上点击新增站点填写名称、编码。前端校验必填字段后把 GeoJSON 通过 POST 接口提交。后端写入 PostGIS返回新站点 id。PostGIS 重新计算覆盖分析和盲区。Cesium 大屏通过定时刷新或 WebSocket 推送更新站点显示。如果覆盖分析涉及的数据量很大同步计算可能让接口超时。这种情况下可以把覆盖分析放到异步任务里编辑接口先返回保存成功分析完成后再通过消息推送更新大屏结果。6.2 覆盖分析结果如何驱动大屏展示覆盖分析接口返回的可以是一个统计结果加盲区数据。大屏拿到这个 JSON 后既能渲染数字也能把盲区几何叠加到三维场景里。{ totalResidentialAreas: 120, coveredResidentialAreas: 86, coverageRate: 71.67, blindAreaGeoJSON: { type: FeatureCollection, features: [] } }前端的处理逻辑是覆盖率数字直接展示在统计卡片盲区 FeatureCollection 转成 Cesium 可用的 GeoJSON 数据源用高亮颜色渲染在地球上。这样采编、分析和展示就真正对应上了。7. 常见问题排查与实践清单7.1 高频问题对照表这里把项目里最容易碰到的问题整理成表方便现场排查。问题现象常见原因处理方案CREATE EXTENSION postgis报错PostgreSQL 服务端未安装 PostGIS 组件先安装 postgis 系统包再执行扩展命令应用启动后找不到 PostGIS 函数数据库没启用扩展在目标库执行CREATE EXTENSION postgis;500 米范围在图上明显不对ST_Buffer(geom, 500)把 500 当成经纬度使用geom::geography或投影坐标系后再算站点位置偏移几百米前端把 4326 坐标直接当成 3857 使用统一用ol/proj的transform处理中文名称乱码接口响应或数据库连接字符集不对响应使用charsetutf-8连接字符串配置字符集Cesium 白屏或瓦片不显示Ion token 未配置、网络不通或影像源不可用检查 network、token 和影像服务配置大屏加载卡顿一次性加载过多实体或查询没走索引聚类、分层、分页并检查 GIST 索引7.2 从现象倒推根因的排查顺序地图类项目报错建议按固定顺序排查避免在错误层面来回试先确认数据源本身查询 PostGIS 里的坐标数值是否符合预期。再确认 SQL 是否能用上空间索引用EXPLAIN ANALYZE查看执行计划。然后确认接口返回的 GeoJSON 坐标顺序是[lng, lat]还是[lat, lng]。前端再检查投影转换OpenLayers 和 Cesium 分别使用了哪些坐标系。最后看浏览器 Network 和 Console确认请求是否成功、是否有跨域、是否有加载失败资源。这个顺序适用于大多数“点位偏移、数据不显示、覆盖分析结果不对”的问题。7.3 上线前检查清单以下是一份可以直接复用的地图功能上线前检查清单建议每个环境都过一遍[ ] 数据库已启用 PostGIS空间索引已创建[ ] 后端接口返回统一 GeoJSON 结构[ ] OpenLayers 编辑保存前做字段和范围校验[ ] Cesium 点位使用 4326 度坐标不要混入 3857 数据[ ] 覆盖分析单位已确认是米还是度[ ] 大屏数据有刷新机制和异常兜底[ ] 生产环境有日志、监控、数据备份和回滚脚本清单要根据实际项目裁剪但“坐标系统一、单位确认、空间索引”这三项不能省。8. 生产环境建设与扩展方向8.1 学习环境和生产环境的差异学习环境只要能跑通流程生产环境则需要考虑稳定性、性能和合规。两者的差异主要体现在下面这些方面方面学习 demo生产环境数据库单机 PostGIS主从、备份、连接池、监控告警接口直接返回全部数据分页、权限、限流前端影像源使用公网测试源离线瓦片或合规影像服务性能少量点位点聚合、分层分页、缓存运维本地重启日志、监控、发布和回滚脚本如果项目要长期投入建议把站点变更记录保存下来。新增、修改、删除都写入操作日志至少能回答“这个站点什么时候被谁改过”的问题。8.2 可以继续扩展的功能这套架构已经具备继续扩展的基础常见方向包括实时公交位置接入通过 WebSocket 推送车辆 GPSCesium 更新车辆位置。等时圈分析用 PostGIS 路径规划能力计算公交可达范围替代简单的圆形缓冲区。3D 站点建筑使用 3D Tiles 或 glTF 模型替换点状站点让大屏更贴近真实场景。室内地图与换乘导航在站点模型上叠加室内楼层数据支撑精细化导航。站点历史版本对站点坐标和属性的每一次变更做版本记录便于回溯和审计。这套方案的核心判断是数据采编、空间分析、三维展示三段不要各做各的先用 PostGIS 把空间数据管理起来再让 OpenLayers 和 Cesium 消费同一份数据。对初学者来说最有价值的练习不是先做大屏特效而是先把一条“站点新增、编辑、覆盖计算、三维更新”的完整链路跑通。链路通了后面的雷达扫描、动态光照、天际线分析都只是在同一条链路上叠加效果。