企业级的三维可视化地图大屏是如何实现的?

4,798 阅读11分钟

项目源码效果

前阵子测评Mapmost产品,他们赠送了2个项目源码,起初我并没有在意,想着赠品能有多好,因为我在他们官网上看到项目的演示视频,第一感觉一眼假,又拿设计稿渲染视频骗人这是:

官网宣传视频:www.mapmost.com/#/double12/ image.png image.png


但我打开编辑器跑起来后,整体的渲染效果和功能交互做的确实很好,和视频里介绍的一模一样,关键还是基于WebGL技渲染的,我先放几张图大家看看: image.png

1.初始页面-园区总览

image.png Screen-2024-12-17-102210_0006.gif image.png

2.园区态势

image.png

3.安防管理-分层分户

Screen-2024-12-17-102210_0001.gif

4.安防管理-室内查看

Screen-2024-12-17-102210_0002.gif

5.安防管理-安防预警(三维特效)

Screen-2024-12-17-102210_0003.gif

6.安防管理-逃生路径

Screen-2024-12-17-102210_0004.gif

7.安防管理-人员聚集(3D热力图)

Screen-2024-12-17-102210_0005.gif

8.设备管理-监控摄像头

Screen-2024-12-17-102210_0007.gif

9.设备管理-路灯

Screen-2024-12-17-102210_0009.gif

今天就带着大家,一点点分析上面项目的实现过程,至于产这个SDK本身怎么用,大家可以去看文档,或者看我之前写的文章。

Mapmost官网 www.mapmost.com/#/

实现过程

我以这个智慧园区为示例,和大家分析一下实现过程。

一、SDK获取

这里可以参他们考官网上的教程:

image.png

www.mapmost.com/#/?source=i…

他们项目源码里用的是CDN:

image.png

二、地图初始化

这块代码在src/components/Map.vue中,这也是入口文件:

image.png

//自定义透明矢量地图样式文件
const style_opacity = {
  version: 8,
  sources: {},
  layers: [
    {
      id: "land",
      type: "background",
      paint: {
        "background-color": {
          stops: [
            [15, "rgba(9, 30, 55, 0.6)"],
            [16, "rgba(9, 30, 55, 0.6)"],
          ],
        },
      },
    },
  ],
};

//初始化地图实例
let map = new mapmost.Map({
  container: "map-container",
  style: style_opacity,
  doubleClickZoom: false,
  center: [120.72929672369003, 31.288619767132104],
  zoom: 17.88998700147244,
  sky: "light", //天空颜色
  pitch: 64.42598133276567,
  bearing: -37.87271910936988,
  userId: "*****", //SDK 授权码
  env3D: {
    defaultLights: false, //关闭默认光源
    envMap: "./assets/data/yun(17).hdr", //环境贴图文件
    exposure: 2.64, //场景曝光度
  },
});

此时的效果是这样的,黑乎乎一片,只有一块空地图 image.png

三、加载场景模型

这里注意的是,整个场景是一个大模型,没有做拆分,包括建筑、道路、树木、路灯等。

这块代码在src/components/Map.vue

// 园区模型
let models_obj = ["yq"].map((item) => ({
  type: "glb",
  url: "./assets/models/" + item + ".mm",
  decryptWasm:
    "https://delivery.mapmost.com/cdn/b3dm_codec/0.0.2-alpha/sdk_b3dm_codec_wasm_bg_opt.wasm", 
}));

// 模型图层参数
let options = {
  id: "model_id124",
  models: models_obj,
  outline: true, // 允许轮廓高亮
  type: "model",
  funcRender: function (gl, matrix) {
    if (Layer) {
      Layer.renderMarker(gl, matrix);
    }
  },
  center: [120.73014920373011, 31.287414975761724, 0.1],
  callback: function (group, layer) {
    Layer = layer;
    // 初始化场景
    //new InitApi(map, layer, group);

    // 道路行驶车辆
    //let car = new CarApi(map);
    //car.Car();
    //let count = 0;
    //setInterval(function () {
      //每隔6秒放一次车,放5次
      //if (count < 5) {
       // car.Car();
       // count++;
      //}
    //}, 10000);
    // 获取MapApi接口
    window.mapApi = new MapApi(map, layer, group);
  },
};
// 添加模型
map.addLayer(options);

现在效果是这样的,整体来看,模型本身做了PBR处理,金属度挺高的。

image.png

四、初始化场景

其实这部分在加载模型的回调函数里已经完成了,上面为了做区分我注释掉了。主要场景灯光和太空盒样式设置,另外把车辆模型单独加载到图层上,让其运动起来

这块代码在src/components/Map.vue

// 初始化场景
new InitApi(map, layer, group);

// 道路行驶车辆
let car = new CarApi(map);
car.Car();
let count = 0;
setInterval(function () {
  //每隔6秒放一次车,放5次
  if (count < 5) {
    car.Car();
    count++;
  }
}, 10000);

初始化场景的代码在另一个单独的js中,这里是一个类,加了2个平行光以及设置了天空和大气的样式

这块代码在src/api/InitApi.js

class InitApi {
  constructor(map, layer, group) {
    this._map = map;
    this._layer = layer || null;
    this._group = group || null;
    this.Sky();
    this.Light();
    this.allFlash = [];
  }

  // 灯光
  Light() {
    // 场景灯光1
    let light1 = new mapmost.DirectionalLight({
      color: '#484b4b',
      intensity: 3.7,
      position: [291218.1880671615, -359034.8940693505, 220067.97081694918],
    });
    // 场景灯光2
    let light2 = new mapmost.DirectionalLight({
      color: '#767676',
      intensity: 0.91,
      position: [-291218.1880671615, 359034.8940693505, 220067.97081694918],
    });
    this._map.addLight(light1);
    this._map.addLight(light2);
  }

  // 天空盒
  Sky() {
    this._map.addLayer({
      id: 'sky',
      type: 'mapmost_sky',
      enableCloud: true,
      paint: {
        'sky-url': './assets/images/CubeRT_Capture_Tex_2048.png',
        'sky-angle': 0,
        'sky-exposure': 1,
        'sky-opacity': [
          'interpolate',
          ['linear'],
          ['zoom'],
          0,
          0,
          5,
          0.3,
          8,
          1,
        ],
        'sky-type': 'atmosphere',
        'sky-atmosphere-sun': [227.02725700614292, 110.95561210040023],
        'sky-atmosphere-sun-intensity': 5,
      },
    });
  }
}

export default InitApi;

另外车辆的加载一个在一个单图的js文件中处理的,这里代码比较多,我就直接截图了。

车辆运动的实现方式比较简单,这里是预置的固定路线,然后通过匹配不同车辆的模型,让其按照路线运动。

这块代码在src/api/CarApi.jsimage.png

然后再看一下,场景整体明亮了很多,天空也不再那么昏暗,另外车辆也出现了 image.png

五、交互功能

交互功能整体代码都在src/api/MapApi.js中,这里基本都是用静态数据做的,功能写的比较简单,不过影响不大,我们这里主要看实现方式和代码逻辑,后面自己可以替换为动态数据,以及扩展其他业务功能。

安防预警特效

这里我们注意到,这个预警特效是由3部分组成红色的圆圈黄色的扩散圆环黄色的旋转扇形 image.png

这部分代码在MapApi.js可以找到,由radarWarning()securityWarning()这2个方法实现

黄色的扩散圆环黄色的旋转扇形 一起组成雷达图:

//安防预警雷达
radarWarning() {
  let circle_radar = this._layer.addCircle({
    type: 'radar', 
    color: 'yellow',
    radius: 45,
    segment: 256,
    isCW: true,
    speed: 3,
    opacity: 0.8,
    center: [120.7293385335865, 31.288814045160496, 52.85],
  });
  this.polygonData.push(circle_radar);
  let waterRipple = this._layer.addCircle({
    type: 'ripple',
    color: 'yellow',
    radius: 45,
    turns: 5,
    speed: 0.05,
    opacity: 1.0,
    center: [120.7293385335865, 31.288814045160496, 52.75],
  });
  this.polygonData.push(waterRipple);
}

红色的圆圈单独实现:

// 安防预警
securityWarning() {
  let model = this._group.children[0].children[2];
  const tween = new TWEEN.Tween({ z: 0 }, false)
    .to({ z: 30 }, 1000)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .onUpdate((val) => {
      model.translateY(val.z);
    })
    .start();
  function animate(time) {
    tween.update(time);
    requestAnimationFrame(animate);
  }
  animate();

  let circle_spread = this._layer.addCircle({
    type: 'spread',
    color: 'red',
    radius: 45,
    segment: 256,
    speed: 3,
    opacity: 0.8,
    center: [120.7293385335865, 31.288814045160496, 52.85],
  });
  this.polygonData.push(circle_spread);
}

这里我看到有个叫addCircle的API被反复用到,我去他们文档上看了一下,这是模型图层实例下面的方法,根据参数type的类型来渲染不同的效果。

www.mapmost.com/mapmost_doc… image.png

至于预警通知弹窗的内容,这里用的是图片,没有真实数据,明显是偷懒了

image.png

//预警图片
warnPicture() {
  let that = this;
  let warnPoints = [
    {
      element: this.createDeviceDom(
        'yj',
        './assets/images/UI/弹窗_预警通知.png',
        '265.2px',
        '289.2px'
      ),
      position: [120.72994108596296, 31.28834145014008, 52.75],
    },
  ];
  let pictrue2 = this._layer.addMarker({
    id: 'warnSevice',
    data: warnPoints,
  });
  that.markerData.push(pictrue2);
}

我看他们封装了一个专用的createDeviceDom()方法,用来创建设备弹窗, 参数接受的就是图片:

createDeviceDom(
  id,
  imageUrl = './assets/images/device.png',
  w = '50px',
  h = '70px',
  name
) {
  let container = document.createElement('div');
  container.setAttribute('id', id);
  container.className = name || 'markerDevice';
  container.style.width = w;
  container.style.height = h;
  container.style.backgroundImage = 'url(' + imageUrl + ')';
  container.style.backgroundRepeat = 'no-repeat';
  container.style.backgroundSize = '100% 100%';
  container.style.margin = '0px';
  container.style.backgroundPosition = 'center 0';
  return container;
}

其实在这里我们可以修改createDeviceDom的传参,从服务请求后,以参数的形式传进去:

//获取服务端数据
const data = getDadaFromServer('xxxxx')

//增加参数 data
createDeviceDom(id,imageUrl,w,name,data) {
    //....
    //创建DOM渲染数据
    container.innerHTML = data.userName + data.userPhone
   //....
}



建筑模型分层

这里代码比较多,我大致说一下逻辑:

  • 先改变相机视角,调用changeViewers()方法
  • 选中要分层的模型,定义TWEEN动画,沿着Z轴平移
  • 通过定时器,展示楼层的注记图片,更改楼层模型材质

image.png

//楼层平移
floorPanning() {
  let locations = [
    {
      center: [120.72893418497381, 31.289093070022545],
      zoom: 18.47807755785181,
      bearing: -43.624945530783634,
      pitch: 49.98152219360618,
      speed: 0.2,
      curve: 1,
    },
  ];
  this.changeViewers(locations);

  let self = this;
  let model2 = this._group.children[0].children[3];

  const tween2 = new TWEEN.Tween({ z: 0 }, false)
    .to({ z: 1 }, 1000)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .onUpdate((val) => {
      model2.translateZ(val.z);
    })
    .start();

  function animate(time) {
    tween2.update(time);
    requestAnimationFrame(animate);
  }
  animate();

  setTimeout(() => {
    self.floorPicture();
    self.selectedObj = model2;
    model2.traverse(function (child) {
      if (child instanceof THREE.Mesh) {
        // 将原有材质存储到自定义属性
        child.baseMaterial = child.material;

        // 复制原有的材质并修改颜色
        child.material = child.material.clone();
        child.material.color = new THREE.Color(0xffff00);
      }
    });
  }, 2000);
}
模型选中与高亮

这里我们以查看路灯设备为例,包含3个过程

  • 移动地图视角到路灯附近,选中路灯模型,
  • 高亮模型
  • 弹窗三维面板,查看路灯信息

image.png

这里代码比较多,但是对应上面我说的三个步骤

// 路灯设备查看
lampDeviceMarker() {
  let self = this;
  let locations = [
    {
      center: [120.72972234736437, 31.28788940180162],
      zoom: 20.791587499232204,
      bearing: 15.996875838980145,
      pitch: 71.92451688228758,
      speed: 0.5, // 速度
      curve: 1, // 运动方式
    },
  ];
  this.changeViewers(locations);

  let modelLayer = this._layer;
  let modelGroup = this._group;
  this._map.on('click', (e) => {
    if (modelLayer) {
      selectObj(e.point);
    }
  });

  function selectObj(point) {
    let intersect = modelLayer.selectModel(point)[0];

    // 计算与摘取射线相交的物体
    if (intersect) {
      let nearestObject = intersect.object;
      let name = nearestObject.parent.name;
      if (name === 'lamp') {
        // 模型轮廓效果
        modelLayer.outlineModel([modelGroup.children[0].children[0]], {
          scope: 'default', // 模型轮廓范围:layer、model、default
          edgeStrength: 3.0, // 轮廓强度系数
          edgeGlow: 0.0, // 轮廓发光稀释
          edgeColor: '#00C1FF', // 轮廓颜色
          enableFillColor: true, // 轮廓内部是否填充
          fillColorOpacity: 0.2, // 轮廓内部填充颜色不透明度
        });

        // 标签
        let datas = [
          {
            id: 1,
            element: self.createDeviceDom(
              'lampInfo',
              './assets/images/device.png',
              '50px',
              '70px'
            ),
            position: [
              120.72962054029763, 31.287636866542304, 11.556247058023184,
            ],
          },
          {
            id: 2,
            element: self.createDeviceDom(
              'lamp_popup',
              './assets/images/UI/弹窗_设备信息2.png',
              '186px',
              '174px'
            ),
            position: [
              120.72960254553067, 31.28765832716174, 7.068647141749621,
            ],
          },
        ];
        let lampMarker = modelLayer.addMarker({
          id: 'marker_lamp',
          data: datas,
        });
        self.markerData.push(lampMarker);
      }
    }
  }
}

移动视角依旧是用changeViewers()方法,这里主要是如何选中模型,我看这里定义了一个selectObj()方法,里面用到了射线检测,通过鼠标点击的模型信息,来判断是不是选中了目标路灯模型。

 function selectObj(point) {
    let intersect = modelLayer.selectModel(point)[0];

    // 计算与摘取射线相交的物体
    if (intersect) {
      let nearestObject = intersect.object;
      let name = nearestObject.parent.name;
    //...

如果是目标模型,就高亮它,这里也是用到他们文档上的模型图层接口outlineModel

www.mapmost.com/mapmost_doc…

  if (name === 'lamp') {
        // 模型轮廓效果
        modelLayer.outlineModel([modelGroup.children[0].children[0]], {
          scope: 'default', // 模型轮廓范围:layer、model、default
          edgeStrength: 3.0, // 轮廓强度系数
          edgeGlow: 0.0, // 轮廓发光稀释
          edgeColor: '#00C1FF', // 轮廓颜色
          enableFillColor: true, // 轮廓内部是否填充
          fillColorOpacity: 0.2, // 轮廓内部填充颜色不透明度
        });
        //...

image.png

另外弹窗依旧是图片,和上面的安防预警一样的实现方式:

// 标签
let datas = [
  {
    id: 1,
    element: self.createDeviceDom(
      'lampInfo',
      './assets/images/device.png',
      '50px',
      '70px'
    ),
    position: [
      120.72962054029763, 31.287636866542304, 11.556247058023184,
    ],
  },

设备点位与弹窗面板

这里我们以查看监控视频为例: image.png

  • 先添加设备图标
  • 点击图标打开视频弹窗
  • 点击x号关闭视频弹窗

这里代码很长我们分开看:

// 添加监控标签
addMonitorMarker(MonitorMarkeDatas) {
  let self = this;
  let modelLayer = this._layer;
  //  标签
  let datas = new Array(MonitorMarkeDatas.length);
  var i;
  for (i = 0; i < MonitorMarkeDatas.length; i++) {
    datas[i] = {
      id: i,
      element: self.createDeviceDom(
        'cameraInfo',
        './assets/images/camera4.png',
        '30px',
        '50px',
        'cameraClass'
      ),
      position: MonitorMarkeDatas[i],
    };
  }
  self.monitorMarker = modelLayer.addMarker({
    id: 'marker_monitor',
    data: datas,
  });

  self.markerData.push(self.monitorMarker);

  self.monitorMarker.element.children.forEach((dom, index) => {
    dom.element.addEventListener('click', (e) => {
      if (self.videoMarkers.length > 0) {
        self.videoMarkers.forEach((m) => {
          if (m.remove) m.remove();
          m = null;
        });
        self.videoMarkers = [];
      }
      let dom = self.createDeviceDom(
        'videopop',
        './assets/images/UI/弹窗_实时视频2.png',
        '320px',
        '160px'
      );
      dom.style.paddingTop = '40px';
      dom.style.paddingBottom = '15px';
      dom.style.backgroundColor = 'rgba(0,0,0,0.5)';

      let closeIcon = document.createElement('div');
      closeIcon.className = 'closeVideo';

      const video = document.createElement('video');
      video.src = './assets/images/UI/videoexample.mp4';
      video.controls = false;
      video.autoplay = true;
      video.muted = true;
      video.loop = true;
      video.height = 160;
      video.width = 320;
      dom.appendChild(video);
      dom.appendChild(closeIcon);
      video.style.opacity = 1.0;
      let cameraMarker = modelLayer.addMarker({
        id: 'marker_video',
        data: [
          {
            name: 'a',
            element: dom,
            position: datas[index].position,
          },
        ],
      });
      self.videoMarkers.push(cameraMarker);
      cameraMarker.element.children[0].element.addEventListener(
        'click',
        (e) => {
          let target = e.target; // 获取当前点击的目标子元素
          if (target.className == 'closeVideo') {
            if (self.videoMarkers.length > 0) {
              self.videoMarkers.forEach((m) => {
                if (m.remove) m.remove();
                m = null;
              });
              self.videoMarkers = [];
            }
          }
        }
      );
    });
  });
}

首先添加监控设备的小图标

image.png

//  标签
let datas = new Array(MonitorMarkeDatas.length);
var i;
for (i = 0; i < MonitorMarkeDatas.length; i++) {
  datas[i] = {
    id: i,
    element: self.createDeviceDom(
      'cameraInfo',
      './assets/images/camera4.png',
      '30px',
      '50px',
      'cameraClass'
    ),
    position: MonitorMarkeDatas[i],
  };
}
self.monitorMarker = modelLayer.addMarker({
  id: 'marker_monitor',
  data: datas,
});

self.markerData.push(self.monitorMarker);

这里是批量添加的,调用的是他们文档里面的添加三维标签的APIaddMarker

www.mapmost.com/mapmost_doc… image.png

然后就是添加视频的部分,这里写的有些复杂,完全用原生js的DOM实现的:

  • 遍历每个三维标签
self.monitorMarker.element.children.forEach((dom, index) => {

   //...
  • 提前创建好弹窗和视频元素
let dom = self.createDeviceDom(
  'videopop',
  './assets/images/UI/弹窗_实时视频2.png',
  '320px',
  '160px'
);
dom.style.paddingTop = '40px';
dom.style.paddingBottom = '15px';
dom.style.backgroundColor = 'rgba(0,0,0,0.5)';
//...

const video = document.createElement('video');
video.src = './assets/images/UI/videoexample.mp4';
video.controls = false;
video.autoplay = true;
video.muted = true;
video.loop = true;
video.height = 160;
video.width = 320;
dom.appendChild(video);
dom.appendChild(closeIcon);
video.style.opacity = 1.0;
//...

  • 给每个标签绑定点击事件,根据鼠标点击的元素,选择开启或者关闭视频弹窗
let cameraMarker = modelLayer.addMarker({
  id: 'marker_video',
  data: [
    {
      name: 'a',
      element: dom,
      position: datas[index].position,
    },
  ],
});
self.videoMarkers.push(cameraMarker);
cameraMarker.element.children[0].element.addEventListener(
  'click',
  (e) => {
    let target = e.target; // 获取当前点击的目标子元素
    if (target.className == 'closeVideo') {
      if (self.videoMarkers.length > 0) {
        self.videoMarkers.forEach((m) => {
          if (m.remove) m.remove();
          m = null;
        });
        self.videoMarkers = [];
      }
    }
  }
);

只不过他们这里视频不是实时监控,而是用MP4文件代替的,不过没关系,我们也可替换为hls或者flv格式的实时视频流。

屏幕适配

这里我看他们CSS单位用的是vw百分比,当然也可以用,至于其他的适配方式,可以参考我之前的文章:

Vue+Echarts企业级大屏项目适配方案 juejin.cn/post/700908…

.top-bg{
  position: relative;
  width: 100%;
  height: 100%;
  background: url(../assets/img/人口数据@2x.png) no-repeat;
  background-size: 100% 100%;
}

.top-content{
  position: relative;
  top: 7vh;
  width: 100%;
  height: 100%;
  left: 50%;
  transform: translate(-45%);
  background: url(../assets/img/人口数据内容@2x.png) no-repeat;
  background-size: 90% 72%;
}
UI面板

UI都在src/layout里面,这里按照结构分了上下左右部分

image.png

image.png

但是里面没有图表,基本都是图片,主要是做示意吧,不过问题不大,我们自己可以加

这部分如果想替换真实的图表,也可以参考我之前的文章:juejin.cn/post/700908… image.png

其他功能

其他功能相对比较简单,项目都是基于他们已有的产品 Mapmost SDK for WebGL做的,对应的接口在文档上都有

官网地址:www.mapmost.com/#/?source=i… image.png

面板的功能触发统一都写在了LyBottom.vueimage.png

image.png

image.png

六、智慧工厂项目源码

这个和园区的实现逻辑是一样的,这里我就不展开了,大家可以自己看

image.png image.png

七、源码如何获取

目前是购买产品送的,我是买了一年教育版体验的,可以找同学或者朋友借一个邮箱,先不说产品本身,就这2个项目源码就远超99元的价值

image.png

Mapmost SDK for WebGL image.png