在地图上做交通数据可视化展示

6,456 阅读15分钟

介绍

在现代城市管理与交通调控中,GIS数据可视化起着至关重要的作用。通过将GIS数据与先进的可视化技术相结合,不仅能够直观展现交通动态与基础设施现状,还能为决策者提供数据支撑。

那么做好数据可视化需要哪些支持?经过这几年的技术摸索和项目经验,我认为数据是基础,业务场景是框架,而视觉表现是载体。在本文中,我们将探讨如何构建一个基于公开数据的交通可视化GIS场景,从数据预处理、图层设计、前端开发,全面展示系统的构建流程。

Honeycam_2025-04-21_12-22-57.gif

页面演示地址

场景设计

香港开放数据平台(下文简称HK)提供了城市级别的交通与运输、公共设施、环境空气等数据,真的是难能可贵的数据资源。结合现有数据开放平台上已有的资源,以及高德地图开放平台这边能够提供的功能,我确定了几个可以实际落地的交通业务场景。

  • 香港城市3DTiles:以3D模型形式展现香港城市的立体景观。
    Honeycam_2025-04-22_13-52-28.gif
  • 主干道交通路网:展现城市主要道路分布及网络结构。
  • 道路拥堵情况:实时显示主要道路的拥堵等级、流量数据,便于交通调度。
    image.png
  • 路线导航:APP上的智能交通路线导航我们经常用到了,这次自己来实现3D版本的
  • 交通摄像头POI及实时画面:标记重要交通节点,点击后展示摄像头实时视频截图。
  • 公共交通:提供公共交通站点分布情况,点击后展示站点内车次,到站时间等详情。
  • 实时交通事件展示:标记城市内重要的交通事件接口,包括事故、施工、管制等信息,可以有效提供合理的出行方案、避让问题路段。该接口为收费商用接口,在本工程仅使用模拟数据做前端功能展示。

功能需求

图层需求

图层名称数据来源表现形式备注
主干道交通路网data.gov.hk矢量线静态数据,提前预处理
道路拥堵情况data.gov.hk多颜色矢量线通过实时数据接口更新状态
交通摄像头POI及实时视频截图data.gov.hk图标、弹窗展示视频截图点击POI弹出摄像头监控画面
香港城市三维模型3d.map.gov.hk3DTiles、多角度浏览3D Tiles数据体积庞大,另外部署供访问即可
公共交通站点data.gov.hkPOI需要实时接口支持数据
事故与警情数据amap api图标、热力效果、动态提示整合事故报警、施工信息等数据
天地图卫星影像地图lbs.tianditu.gov.cn/server/MapS…与GIS数据的坐标系相匹配,且更清晰的卫星影像图需要开发者自行申请token

交互需求

  1. 图层均可独立显示隐藏
  2. 图层之间共享空间深度关系,即支持互相遮挡
  3. 部分图层需要支持光标交互,即悬浮或点击时获取当前属性并展示
  4. 图层支持实时更新,比如交通路网需要实时更新数据,以更迭拥堵情况

技术选型

  1. 数据清洗与预处理

    工具:QGIS、Python

    用途:QGIS用于展示和提前验证数据的合理性;Pythone用于处理原始GeoJSON、CVX等格式数据,校验和规范属性字段,转换为前端友好的数据格式。其实我不会Python,在这个过程中,所有的脚本和执行方法都是AI教的。

  2. 地图引擎与3D渲染

    工具:高德地图JS API2.0、three.js@0.157、Cesiumlab3.0

    用途:利用高德地图进行底图展示;通过three.js实现3D模型加载(如香港城市3DTiles)与动态效果(如摄像头实时视频弹窗);Cesiumlab用于提供3dtiles服务。

  3. AI与MCP辅助技术

    工具:任何接了Deepseek-R1的LLM工具(腾讯元宝)和接了Caluad的IDE(Cursor、Trae、windsurf等)

    应用:利用AI生成Python脚本进行数据清洗、数组组装、甚至能生成可用数据;利用IDE的AI助理,接入适当等MCP,自动补全图层逻辑代码,自动生成注释及文档,提出代码优化建议等,有效提升开发效率与质量。

实现过程

基础框架

  1. 工程使用vite+vue3开发,创建工程安装各种依赖包

  2. 整个页面的逻辑比较简单,就是添加地图实例、添加数据图层、监听鼠标事件做交互

    import { fetchMockData } from '@/utils/mock.js'
    import GLlayer from '#/gl-layers/src/index'
    import * as THREE from 'three' 
    //...
    const { 
      FlowlineLayer,
      PathLayer,
      DrivingLayer,
      TilesLayer,
      LayerManager
    } = GLlayer
    
    // 高德可视化类,会用到高德自带的可视化图层
    let loca
    // 容器
    const container = ref(null)
    // 图层管理
    const layerManger = new LayerManager()
    
    const SETTING = {
      // 地图中心点
      center: [114.214033,22.318893], 
      // 各图层是否独立存在, 为true时, 图层之间不会相互影响
      alone: false, 
    }
    
    // 用于道路导航的起点终点标记
    let startMarker
    let endMarker
    
    // 地图数据提示浮层
    var normalMarker
    // 详细信息弹窗
    var infoWindow
    
    onMounted(async () => {
      // 初始化地图
      await init()
      // 初始化图层  
      await initLayers()
      // 逐帧函数
      animateFn()
    })
    
    //销毁前清除所有图层
    onBeforeUnmount(() => {
      layerManger.clear()
    })
    
  3. 初始化地图实例,增加地图事件监听

    // 初始化地图
    async function init() {
      const map = await initMap({
        viewMode: '3D',
        dom: container.value,
        showBuildingBlock: false,
        center: SETTING.center,
        zoom: 15.5,
        pitch: 42.0,
        rotation: 4.9,
        mapStyle: 'amap://styles/light',
        skyColor: '#c8edff'
      })
    
      map.on('zoomend', (e) => {
        console.log(map.getZoom())
      })
      map.on('click', (e) => {
        const { lng, lat } = e.lnglat
        console.log([lng, lat])
    
        if (normalMarker) {
          map.remove(normalMarker)
        }
      })
      // 高德的可视化实例
      loca = new Loca.Container({
        map,
      });
      // 信息提示浮层
      normalMarker = new AMap.Marker({
        offset: [70, -15],
        zooms: [1, 22]
      });
       // 详细信息弹窗
      infoWindow = new AMap.InfoWindow({
        isCustom: true,
        closeWhenClickMap: true
      });
    }
    

叠加第三方地图

  1. 由于原始GIS数据的坐标系和高德地图的火星坐标系不一致,为了减少坐标转换工作量,需要叠加一层WGS84的地图,本工程选择了天地图服务

  2. 地图集成工作相对简单,按照高德开发者平台示例做就行,需要注意参数的用途和值

    /**
     * 初始化卫星影像图层
     * 天地图
     */
     async function initWxLayer() {
        // 天地图, 企业认证
        const layer = new AMap.TileLayer.WMTS({
            url: 'https://t4.tianditu.gov.cn/img_w/wmts',
            blend: false,
            tileSize: 256,
            params: {
                Layer: 'img',
                Version: '1.0.0',
                Format: 'tiles',
                TileMatrixSet: 'w',
                STYLE: 'default',
                tk: '开发人员字自行申请的token'
            },
            visible: true
        });
    
        layer.setMap(getMap());
    
        layer.id = 'wxLayer'
        layerManger.add(layer);
    }
    
  3. 叠加第三方地图后发现天地图的清晰度也没有比高德地图高,不过好在地图整体色调比较柔和,而且在国内免费使用,就暂且用它吧。图层逻辑写好了,在具体项目可以替换为更加高清的地图。

    image 1.png

交通路网

  1. 获取原始数据

    (1)交通路网图层需要实现动态的更新道路拥堵情况,这里涉及到路网的坐标数据routeData和道路的行车速度speedData两个数据,两者通过route_id关联。

    (2)routeData是静态数据,我们可以从HK.Data这里获取原始数据.kmz格式 (3)speedData是动态数据(2分钟更新一次),可以通过订阅RSS的方式获取该数据

  2. 处理数据

    道路坐标数据routeData需要转换为geojson格式才能使用,这时需要将kmz格式导入到QGIS中,再由QGIS导出geojson格式数据即可,导入导出步骤可以参考后面小节的内容。在此期间我们可以预览到数据的地理形态,如有必要还可进行数据的修正处理。

    image 2.png

  3. 创建图层PathLayer实例用于展示路网,与高德官方的交通路况组件BasicControl.Traffic不同的地方在于PathLayer可以自动控制图层样式、图层海拔高度、与其他3D图层(如城市建筑模型图层)进行深度融合。

    // 初始化交通图层
    async function initTrafficLayer() {
      const map = getMap()
      routeData = await fetchMockData('hk-centerline-kml-match.geojson')
      //给道路车速默认值
      routeData.features.forEach(item=>{    
        item.properties.speed = 60.0
      })
      
      const layer = new PathLayer({
        id: 'trafficLayer',
        map,
        data: routeData,
        styles: {
          0:{lineWidth:2, color: '#ff0000', label: '< 10'}, 
          1:{lineWidth:2, color: '#ffa500', label: '>=10'},
          2:{lineWidth:2, color: '#ffff00', label: '>=30'},
          3:{lineWidth:2, color: '#00ff00', label: '>=40'},
        },
        getStyle:(feature)=>{
          ...
        },
        altitude: 40,
        zooms: [15, 22],
        interact: true,
        alone: SETTING.alone,
      })
      
      layerManger.add(layer)
    }
    
  4. PathLayer是自己开发的图层,这里用到THREE非官方的类Line2,相比与THREE.Line,Line2可以设置线条粗细、颜色、虚线等样式,这一属性可以让道路在地图中更加显眼,或者可以用于区分道路级别。

    import Layer from '../core/Layer'
    import * as THREE from 'three'
    import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'
    import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'
    import { Line2 } from 'three/examples/jsm/lines/Line2.js'
    
    class PathLayer extends Layer {
      // 数据 [{coordinates, properties}]
      _data = []
    
      // 用于存放路径Mesh
      _group = new THREE.Group()
    
      // 材质Map {key: material}
      _materialMap = {}
    
      /**
       *  创建一个实例
       *  @param {Object}   config
       *  @param {geoJSON}  config.data 路径数据  required
       *  @param {Number}   [config.altitude=0.0] 整体海拔高度,如果数据中没有z坐标,则使用此值
       *  @param {Array}    [config.zooms=[5,14]] 显示区间
       *  @param {Object}   [config.styles={0: {color:'#000', lineWidth:1.0}}] 路径样式声明 
       *  @param {Function} [config.getStyle] 获取样式的方法
       */
      constructor(config) {
        const conf = {
          data: null,
          style: {},
          altitude: 0.0,
          sizeAttenuation: true,
          ...config
        }
    
        super(conf)
        this.initData(conf.data)
        
      }
      //...
      
      initLines() {
        const { _group, _data } = this
    
        _data.forEach((feature, index) => {
    
          const positions = []
    
          feature.coordinates.forEach(([x, y, z]) => {
            positions.push(x, y, z ?? this._conf.altitude)
          })
    
          // 使用LineGeometry
          const geometry = new LineGeometry()
          geometry.setPositions(positions)
    
          // 根据配置获取材质
          const material = this._materialMap[this._conf.getStyle(feature)]
    
          // 使用Line2 可以设定线条粗细
          const line = new Line2(geometry, material)
          line.computeLineDistances()
          line.scale.set(1, 1, 1)
    
          _group.add(line)
        })
      }
      
    }
    
  5. 实现道路的拥堵情况可视化,我们需要定义样式规则,如下图所示,道路的 speed属性值≥40,认为道路是畅通的,显示绿色材质的线;speed值小于10时认为道路非常拥堵,显示红色材质的线。

    image 3.png

    styles中每一种样式,我们在PathLayer中都需要创建对应的一种材质。

    /**
     * 据styles声明生成对应的LineMaterial
     * @private
     */
    initMaterials() {
      const { styles } = this._conf
    
      Object.keys(styles).forEach(key => {
        const { color, lineWidth } = styles[key]
        const material = new LineMaterial({
          color: color,
          linewidth: lineWidth * 0.001,
          dashed: false,// 虚线
        })
        this._materialMap[key] = material
      })
    }
    
  6. 获取道实时行车速度数据speedData,合并到routeData中

    // 获取数据
    async function getSpeedData() {
      
      const response = await fetch(`${'主要干道及道路交通数据xml地址,见上文'}`);
      const xmlText = await response.text();
      const parser = new DOMParser();
      const xmlDoc = parser.parseFromString(xmlText, "text/xml");
    
      // 解析XML数据
      const segments = xmlDoc.getElementsByTagName('segment');
      const speedData = {};
      for (let segment of segments) {
        // 添加异常处理    
        try {
          const router_id = segment.getElementsByTagName("segment_id")[0].textContent;
          const speed = parseFloat(segment.getElementsByTagName("speed")[0].textContent);
          // 确保获取到有效值
          if (router_id && !isNaN(speed)) {
            speedData[router_id] = speed;
          }
        } catch (error) {
          console.error('Error parsing segment:', error);
        }
      }
      return speedData;
    }
    
    /**
     * 更新交通图层数据
     */
    async function updateTrafficLayer() {
      const layer = layerManger.findLayerById('trafficLayer')
      
      // 获取车速数据
      const speedData = await getSpeedData()
      // 复制一份数据
      const features = routeData.features.map(feature => {
          const router_id = feature.properties['ROUTE_ID'];      
          feature.properties.speed = speedData[router_id]; 
          return feature;
      });
      console.log(features)
      layer.setData({features})
    }
    
  7. 给PathLayer图层增加了一个setData方法用于更新图层,每次获取到最新的道路速度数据后通过该方法重新绘制Lines的Mesh即可,如果想做性能提升,也可以动态更新Mesh的材质。

     /**
     * 设置数据
     * @param {geoJSON} data 数据
     */
    setData(data){
      this.initData(data)
      this.clear()
      this.initLines()
    }
    
  8. 最终我们能够得到以下的效果,只要定时调用updateTrafficLayer方法就能够实现实时更新。

    Honeycam_2025-04-19_16-30-10.gif

城市三维模型

关于如何通过THREE.js加载3Dtiles,我在之前的文章《在高德地图中实现3DTile》已经讲过了,有兴趣去翻陈年档案,在这里重点讲如何获取开放平台上的数据,进行二次处理并部署。

数据说明

香港的城市三维数据有多种形式,包括早期的无人机倾斜摄影模型,和最新的摄影建模加BIM人工修复单体化模型,可能因为数据采集时间的不同,香港岛和九龙区域的原始数据在结构目录上还有所差异,如下图所示。接下来的操作我们以九龙半岛的部分数据为例。

image 4.png

具体步骤

  1. 访问空间数据共享平台,选择瓦片下载数据,每个瓦片我们会得到一个类似Tile_+025_+013这种命名格式的文件包。 image 5.png

  2. 启动cesiumlab3,数据处理-倾斜模型切片,设置好数据路径、metadata.xml空间参考、输出路径等参数,就可以开始提交处理。 image 6.png

    在设置控件参考时,会发现少了一个模型元数据文件,我们可以在数据介绍信息页面找到这个文结构规定了当前这批模型所采用的空间参考系统和空间参考系统的原点。 image 7.png

    <?xml version="1.0" encoding="utf-8"?>
    <ModelMetadata version="1">
    	<!--Spatial Reference System-->
    	<SRS>EPSG:2326</SRS>
    	<!--Origin in Spatial Reference System-->
    	<SRSOrigin>835786,820849,0</SRSOrigin>
    	<Texture>
    		<ColorSource>Visible</ColorSource>
    	</Texture>
    </ModelMetadata>
    
  3. 处理完成后,在分发服务-3DTiles服务可以找到转换成功的模型图层,我们在服务根目录获取到tileset.json,复制一份并将初始位置置为0,命名为tilese0.json image 8.png

  4. 前端页面集成,使用TilesLayer配置好tilesURL和相关参数即可,完整模型的3DTiles的数据体积庞大,建议独立部署。

    /**
     * 初始化建筑图层
     */
    async function initBuildingLayer() {
      const map = getMap()
    
      const layer = new TilesLayer({
        id: 'buildingLayer',
        title: '城市3D建筑图层',
        alone: SETTING.alone,
        map,
        center: [114.207803, 22.319947], //重新调校后的模中心
        zooms: [4, 30],
        interact: false,
        tilesURL: 'http://localhost:9003/model/tQqeT8LGm/tileset0.json', // HK 
        needShadow: true
      })
      layerManger.add(layer)
    
      layer.on('complete', ({ scene, renderer }) => {
        // 调整模型的亮度
        const aLight = new THREE.AmbientLight(0xffffff, 1.5)
        scene.add(aLight)
      })
    
    }
    
  5. 最终效果如下,在工程中交通路网和城市模型是共享空间深度关系的,另外特意将交通路网图层的海拔提高了以避免遮挡。
    Honeycam_2025-04-19_19-16-22.gif

摄像头POI图层

  1. 获取数据来源于侦查车速摄影机箱地点。我发现实际落图中,摄像头和速度探查器的安装位置基本一致,也间接说明额摄影机兼备录屏和摄影功能。基本数据属性如下: image 9.png

  2. 在QGIS直接将数据转换为geoJSON模式,用前端开发阶段。
    image 10.png

  3. 创建图层,直接使用高德可视化图层Loca.LabelLayer就能满足需求

    sync function initCameraLayer() {
      const map = getMap()
      const data = await fetchMockData('hk-traffic-cameras.geojson')
      const geo = new Loca.GeoJSONSource({ data });
    
      var labelsLayer = new Loca.LabelsLayer({
        zooms: [10, 20],
        collision: false, // 关闭避让,让元素看上去比较密集
      })
      labelsLayer.setSource(geo);
      labelsLayer.setStyle({
        icon: {
          type: 'image',
          image: `./static/icons/ico-camera.png`,
          size: [30, 30],
          anchor: 'bottom-center',
        },
        extData: (index, feat) => {
          return feat.properties;
        }
      });
      loca.add(labelsLayer)
    
      labelsLayer.id = 'cameraLayer'
      layerManger.add(labelsLayer)
    }
    
  4. 图层交互,鼠标悬浮时和点击时分别出现概要和详情

     labelsLayer.on('complete', () => {
        var labelMarkers = labelsLayer.getLabelsLayer().getAllOverlays();
        for (let marker of labelMarkers) {
          
          marker.on('click', (e) => {
            var position = e.data.data && e.data.data.position;
            const { description, url, district } = marker.getExtData()
            if (position) {          
              infoWindow.setContent(
                `<div class="amap-info-window">
                  <div class="img-wrap"><img src=${url} /></div>
                  <p>desc: ${description}</p>
                  <p>district: ${district}</p>
                </div>`
              );
              infoWindow.setOffset(new AMap.Pixel(0, -30));
              infoWindow.open(map, position);
            }
          });
    
          marker.on('mouseover', (e) => {
            var position = e.data.data && e.data.data.position;
            const { description} = marker.getExtData()
            if (position) {
              normalMarker.setContent(
                `<div class="amap-info-tip">
                  <p>${description} </p>
                </div>`,
              );
              normalMarker.setOffset(new AMap.Pixel(0, -30))
              normalMarker.setPosition(position);
              map.add(normalMarker);
            }
          });
          marker.on('mouseout', () => {
            map.remove(normalMarker);
          });
        }
      });
    
  5. 图层效果如下,可以点击POI查看该摄像头的最新截屏 Honeycam_2025-04-19_19-52-07.gif

公交车站

数据说明

HK的公共交通情况比较复杂,由于存在多个运营商,公交站点分了好多种类型,需要分别获取原始数据,下文以电车站专线小巴站巴士路线站为例讲解数据如何接入。

具体步骤

  1. 在数据平台下载巴士路线站数据 image 11.png

  2. 打开QGIS,添加矢量图层,导入矢量数据(SHP,KML) image 12.png

    image 13.png

  3. 如果原始数据是excel,csvs等格式,则需要导入分隔文本,并手动选好坐标属性 image 14.png

    设置编码为GB2312,否则中文属性会出现乱码,将代表坐标轴的字段填入X字段和Y字段,正常情况下系统会自动识别;Z字段用于储存点位高度,M字段为度量值,这两者一般不会用到;选择合适的坐标系;点击添加即可。 image 15.png

  4. 导入成功后,验证数据合理性,比如站点位置与底图中巴士站点坐标位置是否匹配 image 16.png

  5. 验证无误后导出数据,右键图层-导出-要素另存为geoJSON备用,用同样的方法下载电车站和专线小巴站的数据,三者数据的stop_id属性具有唯一性,可以作为合并数据后的唯一标识。

  6. 创建图层,使用高德可视化图层类Loca.IconLayer即可。需要注意的点是各类交通车的到站数据API比较散乱且接口不一致(比如九龙巴士的到站数据API),建议在服务端进行预处理。

    async function initBusStopLayer(){
      const map = getMap()
      // 巴士
      const data1 = await fetchMockData('hk-bus-stops.geojson')
      // 专线
      const data2 = await fetchMockData('hk-gmb-stops.geojson')
      // 电车
      const data3 = await fetchMockData('hk-tram-stops.geojson')
    
      data1.features = data1.features.map((item) => {
        item.properties.type = 'bus'
        return item
      })
      data2.features = data2.features.map((item) => {
        item.properties.type = 'gmb'
        return item
      })
      data3.features = data3.features.map((item) => {
        item.properties.type = 'tram'
        return item 
      })
      // 合并数据
      const geo = new Loca.GeoJSONSource({ 
        data: {
          type: 'FeatureCollection',
          features: data1.features
                    .concat(data2.features) 
                    .concat(data3.features) 
        } 
      });
    
      const layer = new Loca.IconLayer({
        zooms: [10, 22],
        zIndex: 300,
        opacity: 1
      })
      
      layer.setSource(geo)
      layer.setStyle({
        icon:(index, feature)=>{
          return `./static/icons/transit/${feature.properties.type}.png`
        },
        // unit : 'meter', //该参数可控制图标是否视角远近而变化尺寸
        iconSize: [20, 20],
        anchor: 'center'
      })
    
      loca.add(layer)
      layer.id = 'stopLayer'
      layerManger.add(layer)
    
      // 点击事件
      map.on('click', async (e) => {
        const feat = layer.queryFeature(e.pixel.toArray());
        if (feat) {
          // 静态数据做测试,后续更换为服务端接口
          const res = await fetchMockData('hk-gmb-stops-detail.json')
    
          const busStopList = res.data.bus_stop.map(item => {
            return `<div class="bus-line">
              <p>路线: ${item.orig_sc}${item.dest_sc}</p>
              <p>下一站: ${item.next_station_sc} ${item.eta}分钟</p>
            </div>`
          }).join('')
    
          infoWindow.setContent(
            `<div class="amap-info-window">
              <div class="bus-list">
              ${busStopList}  
              </div>
            </div>`
          )
          infoWindow.setOffset(new AMap.Pixel(0, -30));
          infoWindow.open(map, e.lnglat);
          
        } 
      })
    }
    
  7. 图层显示效果如下,可以看到HK的巴士站还是比较密集的,目前只是接入了三种类型 Honeycam_2025-04-19_22-20-38.gif

  8. 多类型的数据会涉及到类型过滤问题,通过重新setSoure的方法实现图层的数据过滤

    /**
     * 过滤公交站点图层
     * @param type 
     */
    function filterStopLayer(type){
      // const map = getMap()
      const layer = layerManger.findLayerById('stopLayer')
      const featues = busStopData.options.data.features 
      // 根据type筛选features
      const filteredFeatures = type === 'all' 
        ? featues
        : featues.filter(feature => feature.properties.type === type)
      
      // 更新图层数据
      layer.setSource(new Loca.GeoJSONSource({
        data: {
          type: 'FeatureCollection',
          features: filteredFeatures
        }
      }))
    }
    
  9. 最终效果如下 Honeycam_2025-04-19_22-29-27.gif

实现路线导航

概要说明

由于高德前端的导航组件无法很好地与三维场景融合,我们尝试用web服务API高级路径规划功能实现获取导航结果,并自行渲染在地图场景中;本工程在高德地图上覆盖了一层WGS84的地图,在使用高德地图各种地图相关的API时要注意先把坐标转为火星坐标CGJ02,处理完之后再转回来做呈现。 screenshot_2025-04-20_11-36-53.png

具体步骤

  1. 在地图中生成两个可拖拽的点标记作为路线起点和终点,初始值是随意设置的

    function initDragPoints(){
      const map = getMap()
    
      startMarker = new AMap.Marker({
        position: [114.20415, 22.323125],
        draggable: true,
        icon: new AMap.Icon({
          size: new AMap.Size(20, 60),
          image: `./static/icons/ico-point1.svg`,
        }),
        label:{
          content: '起点',
          direction: 'top-center',
        },
        anchor: 'bottom-center' // 设置锚点
      })
      
      endMarker = new AMap.Marker({
        position: [114.212801, 22.316262], 
        draggable: true,
        icon: new AMap.Icon({
          size: new AMap.Size(20, 60),
          image: `./static/icons/ico-point2.svg`,
        }),
        label:{
          content: '终点',
          direction: 'top',
        },
        anchor: 'bottom-center' // 设置锚点
      })
      map.add([startMarker, endMarker])
    
    }
    

    显示像这样,这里将标记做得很高像针头一样的目的是可以避免城市模型图层的视觉干扰。 image 17.png

  2. 获取路径导航查询结果并进行数据整理

    // 开始路径导航查询
    async function startNavigate(){
      const map = getMap()
      
      const start = startMarker.getPosition()
      const end = endMarker.getPosition()
    
      // 获取路径规划结果
      const res = await getNavRoute(wgs84togcj02(start.lng, start.lat), wgs84togcj02(end.lng, end.lat))
      console.log('路径规划结果:', res)
    
      if (!res.success) {
        console.error('路径规划失败:', res.message)
        return
      }
    
      // 路径规划结果
      const {coordinates, distance, duration, path} = res
      const newCoordinates = coordinates.map(coord => gcj02towgs84(coord[0], coord[1]))
      console.log(newCoordinates)
    
      // 提取TMCs数据并转换为GeoJSON
      const tmcsFeatures = []
      
      // 遍历所有步骤,提取TMCs数据
      if (path && path.steps) {
        path.steps.forEach(step => {
          if (step.tmcs && step.tmcs.length > 0) {
            step.tmcs.forEach(tmc => {
              // 解析TMC的坐标点
              if (tmc.tmc_polyline) {
                const tmcPoints = tmc.tmc_polyline.split(';').map(point => {
                  const [lng, lat] = point.split(',').map(Number)
                  // 将GCJ-02坐标转换为WGS-84坐标
                  return gcj02towgs84(lng, lat)
                  // return [lng, lat]
                })
                // 创建TMC特征
                tmcsFeatures.push({
                  type: 'Feature',
                  properties: {
                    distance: tmc.tmc_distance,
                    status:  ['畅通', '缓行', '拥堵', '严重拥堵', '未知'].indexOf(tmc.tmc_status),
                  },
                  geometry: {
                    type: 'LineString',
                    coordinates: [tmcPoints]
                  }
                })
              }
            })
          }
        })
      }
    
      // 创建TMCs GeoJSON
      const pathData = {
        type: 'FeatureCollection',
        features: tmcsFeatures
      }
      console.log(pathData)
    
      //todo: 渲染图层
      //...
    }
    
    /**
     * 路径规划功能
     * @param {Array|Object} start 起点坐标,格式为 [lng, lat] 或 AMap.LngLat 对象
     * @param {Array|Object} end 终点坐标,格式为 [lng, lat] 或 AMap.LngLat 对象
     * @returns {Promise} 返回包含路径数据和坐标点的Promise
     */
    export async function getNavRoute(start, end) {
      
      // 处理起点和终点坐标
      let startLngLat, endLngLat
      
      // 处理起点坐标
      if (Array.isArray(start)) {
        startLngLat = start.join(',')
      } else if (start.lng && start.lat) {
        startLngLat = `${start.lng},${start.lat}`
      } else {
        throw new Error('起点坐标格式不正确')
      }
      
      // 处理终点坐标
      if (Array.isArray(end)) {
        endLngLat = end.join(',')
      } else if (end.lng && end.lat) {
        endLngLat = `${end.lng},${end.lat}`
      } else {
        throw new Error('终点坐标格式不正确')
      }
      
      try {
        // 构建API请求URL
        let url = `https://restapi.amap.com/v5/direction/driving?key=${AMAP_WEB_KEY}&origin=${startLngLat}&destination=${endLngLat}&show_fields=cost,navi,polyline,tmcs`
    
        // 发起请求
        const response = await fetch(url)
        const data = await response.json()
        
        // 检查API返回状态
        if (data.status === '1' && data.route && data.route.paths && data.route.paths.length > 0) {
          // 获取第一条路径
          const path = data.route.paths[0]
          
          // 解析路径坐标
          const pathCoordinates = []
          
          // 遍历所有步骤
          path.steps.forEach(step => {
            // 解析每个步骤的坐标点
            if (step.polyline) {
              const points = step.polyline.split(';')
              points.forEach(point => {
                const [lng, lat] = point.split(',')
                pathCoordinates.push([parseFloat(lng), parseFloat(lat)])
              })
            }
          })
          
          // 返回路径数据和坐标点
          return {
            success: true,
            data: data,
            path: path,
            coordinates: pathCoordinates,
            distance: path.distance,
            duration: path.duration
          }
        } else {
          return {
            success: false,
            message: data.info || '路径规划失败',
            data: data
          }
        }
      } catch (error) {
        return {
          success: false,
          message: error.message || '路径规划请求出错',
          error: error
        }
      }
    }
    
    
  3. 根据导航结果创建图层;如果已存在路径图层,更新图层数据即可。

    const layer = layerManger.findLayerById('navPathLayer')
    if (!layer) {
      // 创建路径图层
      const pathLayer = new PathLayer({
        id: 'navPathLayer',
        map,
        data: pathData,
        styles: {
          0: { lineWidth: 2, color: '#00FF00', label: '畅通' },
          1: { lineWidth: 2, color: '#FFFF00', label: '缓行' },
          2: { lineWidth: 2, color: '#FFA500', label: '拥堵' },
          3: { lineWidth: 2, color: '#FF0000', label: '严重拥堵' },
          4: { lineWidth: 2, color: '#808080', label: '未知' }
        },
        getStyle: (feature) => {
          return feature.properties.status
        },
        altitude: 45, // 设置高度,避免与地面重叠
        zooms: [3, 22],
        interact: true,
        alone: false
      })    
      // 添加到图层管理器
      layerManger.add(pathLayer)
    }else{
      // 更新路径数据
      layer.setData(pathData)
    }
    
  4. 到此路线导航效果如下,指定起点终点后会生成一条带交通拥堵情况的路线供用户参考 Honeycam_2025-04-20_12-46-37.gif

  5. 接下来我们开发自动巡航图层,即让模型车子沿着路线行驶。基本的原理之前在《高德地图实现自动巡航》中分享过了,核心方法是已知直线段的起点终端,使用tween.js的0-1过渡计算在update时重新设定模型的位置、朝向。

    趁着这次分享我做了一些改进,之前的算法是把整段过程拆成了多个直线去计算位置,需要tween.js从0-1循环多次,如今直接以整段路程为计算对象,0-1只需要执行一遍就行了,可读性大大增强。

    /**
     * 创建移动控制器
     * @private
     */
    initController () {
      // 状态记录器
      const target = { t: 0 }
      // 路线数据
      const { _PATH_COORDS, map } = this
      // 计算路径总长度
      const totalLength = this.getTotalDistance(_PATH_COORDS)
       // 获取移动时长
       const duration = totalLength / this._conf.speed * 1000
    
      this._rayController = new TWEEN.Tween(target)
        .to({ t: 1 }, duration)
        .easing(TWEEN.Easing.Linear.None) 
        .onUpdate(() => {
          const { NPC, cameraFollow } = this._conf
          // 根据当前进度计算已走距离
          const currentDistance = totalLength * target.t      
          // 累计距离
          let accumulatedDistance = 0
          // 累计分段数
          let segmentIndex = 0
          // 在当前进度下,找到当前路径段和当前路径段的进度
          let segmentT = 0    
          for(let i = 0; i < _PATH_COORDS.length - 1; i++) {
            const segmentDistance = _PATH_COORDS[i].distanceTo(_PATH_COORDS[i + 1])
            if(accumulatedDistance + segmentDistance >= currentDistance) {
              segmentIndex = i
              segmentT = (currentDistance - accumulatedDistance) / segmentDistance
              break
            }
            accumulatedDistance += segmentDistance
          }
    
          // 获取当前路径段的起点和终点
          const currentPoint = _PATH_COORDS[segmentIndex]
          const nextPoint = _PATH_COORDS[segmentIndex + 1]
    
          // 计算当前位置
          const position = new THREE.Vector3()
          position.copy(currentPoint).lerp(nextPoint, segmentT)
    
          if (NPC) {
            NPC.position.copy(position)
            // 平滑更新朝向
            const lookAtPoint = _PATH_COORDS[Math.min(segmentIndex + 2, _PATH_COORDS.length - 1)]
            NPC.lookAt(lookAtPoint)
            NPC.up.set(0, 0, 1)
          }
          // 需要镜头跟随
          if (cameraFollow) {          
            // 更新地图中心:
            const positionLngLat = this.coordsToLngLat(position)
            this.updateMapCenter(positionLngLat)
          }
    
        })
        .repeat(Infinity)  // 无限循环
        .start()
    }
    
    /**
     * 计算路径总长度
     * @param {Array} coords 路径坐标
     * @returns {Number}
     **/
    getTotalDistance (coords) {
      let totalDistance = 0
      for(let i = 0; i < coords.length - 1; i++) {
        totalDistance += coords[i].distanceTo(coords[i + 1])
      }
      return totalDistance
    }
    
  6. 除了在tween.js实例update时更新模型位置外,我们也可以设置模型位置追随,只要同步更新地图center就可以实现画面跟着模型走的效果;设置镜头追随也是同理,实时更新地图rotation。

    Honeycam 2025-04-22 19-24-58.gif

事故与警情

HK实时交通事件数据可以通过 高德交通事件API获取 ,事件信息包括事件类型、发生时间、事件描述、涉及路段等,使用官方可视化图层即可完成渲染,不过需要注意该接口为商用收费。 image 18.png

使用AI辅助开发

在数据层面使用AI助手,能够帮助我们完成很多低难度且繁琐的工作;在开发过程更不用多说,如今编程界最强大模型Calude 3.7 Sonnet简直可以成为程序员的导师,All in AI 怎么样都不亏。

数据清洗与预处理

  1. 利用AI生成的Python脚本,让它来帮我预处理数据。比如在开发交通路网时,道路数据routeData(35295)和道路测速数据speedData(4409)并不是完全匹配的,routeData数据量远远大于speedData,也就是说只有12%的道路是有速度数据的。

    因此我们可以通过Pythone脚本把routeData过滤一下仅保留存在速度数据的那部分,这样在加载图层时可以显著提高性能。把这个需求提给Deepseek,它不到十秒就写好了脚本,真的不要太方便。

    import json
    
    # 读取segment_ids到集合中
    with open('segment_ids.txt', 'r') as f:
        segment_ids = set(line.strip() for line in f)
    
    # 读取原始GeoJSON数据
    with open('hk-centerline-kml.geojson', 'r', encoding='utf-8') as f:
        geojson_data = json.load(f)
    
    # 过滤符合条件的要素
    filtered_features = [
        feature for feature in geojson_data['features']
        if str(feature.get('properties', {}).get('ROUTE_ID', '')) in segment_ids
    ]
    
    # 创建新的GeoJSON结构
    new_geojson = {
        "type": "FeatureCollection",
        "features": filtered_features
    }
    
    # 写入到新文件
    with open('hk-centerline-kml-match.geojson', 'w', encoding='utf-8') as f:
        json.dump(new_geojson, f, ensure_ascii=False, indent=4)
    
    print(f"匹配到 {len(filtered_features)} 条记录,已保存到 hk-centerline-kml-match.geojson")
    
  2. 我们也可以利用AI帮我们快速生成一些测试数据,比如在开发事件图层的时候,只要确定好几个事件发生地点和发生范围,剩下的让AI去编。 image 19.png

图层代码生成与工程优化

话说index.vue页面里50%的代码都是AI生成的,借助IDE中接入的 Claude-3.7-Sonnet模型扫描了整个工程,轻易判断开发者的意图然后生成质量极高的代码,我变成了写提示词工程师,减少了大量查API文档的时间。 image 20.png

开发协作与文档生成

AI工具协助自动生成开发文档和接口文档,确保各模块之间协同工作并降低后续维护难度。同时,借助语义分析工具自动生成注释,从而使新成员能够快速上手和理解系统架构。

总结

本文介绍的GIS数据可视化构建方案,从数据获取、清洗到多图层叠加展示,基于高德地图jsapi与three.js的高效整合,构建了包括主干道交通路网、实时拥堵、摄像头监控及香港城市3D视景等多维度交通信息展示系统。

通过AI辅助生成数据清洗脚本、自动编写代码和文档、工程优化扫描等功能,极大地提高了开发效率并降低了技术门槛。

文中演示的工程以及代码后续会整理好放到github上供学习交流,建筑模型图层文件体积太大需要自行到网盘下载部署,也欢迎大家一起来完善,今天就到这里吧。

相关链接

HK开放数据平台

高德地图交通事件API(商业接口收费)

HK城市三维模型演示地址

三维实景模型预览和下载地址