高德地图+threejs打造智慧景区大屏

15,008 阅读17分钟

前言

本项目是一个智慧景区(商场)项目,项目主旨在于对景区的智能化管理,是对AI赋能的具象化,告别传统后台管理模式,从大屏中可以实现一个完整景区(商场)的集中化管理,可以对景区内部设施,店铺、人流量和人物画像进行精准分析。也可以配合智能相机、智慧消防对景区(商场)内的人防安全进行远程管理,现在杭州的景区公共卫生间也都实现智能化,甚至你可以在游客端(模拟的,现在所有内容都在同一个大屏)看到距离你最近的卫生间还剩几个坑儿,景区智慧大屏只是一个载体,集成的智慧体越多,大屏的功能就越强大。

本项目代码使用umi+react+高德地图amap+threejs开发的一款景区管理项目,其中模型使用svg文件并嵌入高德地图中,实现新增景区、绑定店铺、绑定点位设施等功能,使用高德地图的导航功能,可以直接导航到景区内。页面内容包含景区的创建和景区大屏展示,在大屏展示中可以对商铺和点位进行绑定,另页面中集成了coze的ai智能系统,可进行对话,绑定商铺活动或者一些可自动化生产的内容,将在视频演示中详细讲解。

视频介绍地址

源码下载

体验地址

下面跟着作者一步一步来实现吧~

技术栈

  • 高德地图AMAP
  • THREE.JS
  • indexDB
  • react + UMI

环境

  • nodejs: v18.19.0
  • umi: 4.1.8
  • react: 18.0.33
  • threejs: 0.152.2
  • @amap/amap-jsapi-loader: "^1.0.1"

高德地图部分

@amap/amap-jsapi-loader 是高德官网提供的地图JSAPI加载器,可以避免多种异步加载API的错误用法

创建高德地图实例

import AMapLoader from '@amap/amap-jsapi-loader';
export const CreateAMap = () => {
    return new Promise((resolve, reject) => {
        // 申请好的Web端开发者Key,首次调用 load 时必填
        AMapLoader.load({
            "key": "**************", 
            "version": "2.0",
             // 需要使用的的插件列表,如比例尺'AMap.Scale'等,
            "plugins": ["AMap.Walking", "AMap.Driving"],
            "Loca": {
                version: '2.0.0'
            },
          
        }).then(async (res) => {
            resolve(res)
        }).catch((error) => {
            reject(error)
        })
    })

}

在使用高德地图的时候,需要成为开发者并创建一个 Key,在构建AMAP实例还需要以下几个必要的元素,版本号2.0,plugins插件,项目中用到了AMap.Walking 步行规划,当然你可以加入自己想要的插件,比如AMap.Driving 驾驶路线规划。Loca是用于可视化内容的,比如灯光、镜头、路径等元素。

现在地图实例创建好了,那么我们开始创建地图,首先我这里选择的主题是系统默认的黑色3d主题地图,如果想要一个完全属于自己风格的地图可以在官网的 自定义地图服务中创建(需要额度)

调用

useAsyncEffect(async () => {
    AMapRef.current = await CreateAMap()
    if (containerRef.current) {
        createMap()
    }
}, [])

将AMAP实例存放在AMapRef.current中,在后面的方法中都可以使用,并且在containerRef.current存在的时候绘制地图,containerRef.current是将要绘制地图的容器,id为container

绘制地图

在实例创建好以后就该绘制地图了,绘制地图使用实例中的 AMAP.Map方法,接受两个参数,第一个是地图容器,第二个是配置项:new AMap.Map(div: (String | HTMLDivElement), opts: MapOptions)

const createMap = async () => {
    let AMap = AMapRef.current
    // 创建地图
    var map = new AMap.Map("container", {
        resizeEnable: true,
        center: [119.986, 30.2235],//地图中心点
        zoom: 17.4, //地图显示的缩放级别
        viewMode: '3D',//开启3D视图,默认为关闭
        buildingAnimation: true,//楼块出现是否带动画
        pitch: 45,
        rotation: 45,
        features: ['bg', 'building'], // 只显示建筑、道路、区域
        // showLabel: false,  // 隐藏标注信息
        mapStyle: "amap://styles/grey",
        showIndoorMap: false,
        // rotateEnable: false,
        // pitchEnable: false,
        zIndex: 9
    });
    mapRef.current = map

    var loca = new (window as any).Loca.Container({
        map,
        zIndex: 9
    });
    locaRef.current = loca

    ……
}

以上配置项都是基础需要配置的,可以参照官网上面的介绍进行配置,这里需要说明一点是featuresviewMode一个是过滤标注信息,项目中去掉了其他的元素,只展示了建筑和背景,而mapStyle则是官网提供的一个3d黑色主题的风格,当然,你用自己的自定义地图生成的地址也可以放在这里,下面从图中看一下具体创建出一个什么样的地图

绘制地图1.jpg

从图中可以看到一个类似矩形的区域,那个就是本次要做的景区——闲林埠,下一步就是要将自制的景区模型加载到高德地图中

首先说明,本人不是UI,只是一个技术死宅,对于美术上的事儿一窍不通,在做的过程中,就有一种感觉,我加入的3d景区,还不如高德原本的模型好看~,勿喷

清除多余楼块

从地图中可以看出,3d地图中已经存在了很多默认的模型,那么咱们如果将自己的模型加入进去就会互相影响,所以我们首先要做的就是清除景区区域原有的模型 使用高德提供的官方图层AMap.Buildings,在官网中可以找到围栏的功能styleOpts.areas,去除原有模块就使用这个api实现的。

export const cleanBuild = (AMap: any, map: any) => {
    // 底图楼块扣除
    var building = new AMap.Buildings({
        zIndex: 10,
    });
    building.setStyle({
        hideWithoutStyle: false,//是否隐藏设定区域外的楼块
        areas: [{
            visible: false,//是否可见
            rejectTexture: false,//是否屏蔽自定义地图的纹理
            color1: '00000000',//楼顶颜色
            color2: '00000000',//楼面颜色
            path: [ClearBuildPoint]
        }]
    });
    map.add(building);
}

需要在cleanBuild方法中传入之前我们创建的AMAP实例和地图map的实例,那么你也可以对3d楼块自定义颜色,path传入的是围栏的经纬度,ClearBuildPoint是围栏的经纬度数据。

去除楼块2.jpg

这样我们就去除了原有的模块,下面我们将在高德地图中加入threejs的3d世界,景区模型是使用svg文件绘制的,这样不需要3d设计师的介入,靠前端也可以生成一个3d模型,如果有3d模型设计师,那自然是更好的了。svg生成的模型肯定没有3d设计师绘制的模型好看,本文主要讲实现的过程,细节咱们不深究

threejs部分

首先在高德地图绘制threejs图层,所需的内容和直接绘制threejs场景是相同的,首先需要有镜头 Camera场景 Scene渲染器Render灯光Light,这些内容都可以在THREEJS的官网找到具体的api,这里不赘述

threejs内置到高德地图

项目中选择的threejs版本是0.152.2,我也尝试过160+版本,但是存在很多兼容性的问题,所以退而求其次,选择之前用过的版本,相对稳定点也熟悉点,在高德地图嵌入threejs,就离不开一个API ### AMap.GLCustomLayer 自由数据图层,所有的3d场景都将在这个layer内绘制。

init 回调

export const createScene = (AMap: any, map: any, css2dRenderDom?: any) => {
    customCoords = map.customCoords;
    const center = map.getCenter()
    customCoords.setCenter([center.lng, center.lat]);
    return new Promise((resolve, reject) => {
        var gllayer = new AMap.GLCustomLayer({
            zIndex: 110, // 图层的层级
            init: async (gl) => {
               ……
            },
            render: () => {
               ……
            },
        })
        map.add(gllayer);
    })
}

在init回调中可以用来创建镜头、渲染器、灯光等场景元素

 camera = new PerspectiveCamera(60, window.innerWidth / window.innerHeight, 100, 1 << 30);

镜头选用的是透视相机,渲染器使用的是threejs中提供的webGLRender;

 renderer = new WebGLRenderer({
    context: gl
});
                

这里着重介绍一下context,官网是这么介绍的

context - 可用于将渲染器附加到已有的渲染环境(RenderingContext)中。默认值是null

而init的回调接受一个参数gl,这个参数就是高德地图提供的canvas实例,就是说我们绘制的3d场景都将在这个高德地图的canvas中去绘制,场景中还有2d元素图层,光照等内容,这里不一一赘述,开发过程中有一个疑问,一直没找答案

render回调

render的回调中,可以对相机和渲染器进行设置,将AMAP的一些参数和自有数据图层的数据赋值到threejs的元素中。包含设置相机位置,渲染器重绘等

// 重新设置图层的渲染中心点,将模型等物体的渲染中心点重置
// 否则和 LOCA 可视化等多个图层能力使用的时候会出现物体位置偏移的问题
customCoords.setCenter([116.271363, 39.992414]);
var { near, far, fov, up, lookAt, position } = customCoords.getCameraParams();

// 2D 地图下使用的正交相机
// var { near, far, top, bottom, left, right, position, rotation } = customCoords.getCameraParams();

// 这里的顺序不能颠倒,否则可能会出现绘制卡顿的效果。
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(...position);
camera.up.set(...up);
camera.lookAt(...lookAt);
camera.updateProjectionMatrix();

renderer.render(scene, camera);
labelRenderer.render(scene, camera);

// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();

疑问

在使用DirectionalLight平行灯光时,想让画面更真实一点,在render中加入了绘制阴影的功能,然鹅在设置dLight.castShadow = true的时候会导致3d场景和高德地图场景竟然不重合了,初步推断是添加阴影后导致threejs渲染的矩阵改变了,导致绘制出现偏差,没有找到具体原因,不知道是该调整threejs的参数还是调整AMAP的参数。留着这个疑问,看看哪位大佬能协助解决一下

疑问.jpg

加载SVG

svg文件是作者用Adobe Illustrator制作的,制作成本低,它大概长这样,根据高德地图上的景区绘制的,

svg文件.jpg

这是它在高德地图中的样子

原本地图.jpg
import { SVGLoader } from 'three/examples/jsm/loaders/SVGLoader';
const svgLoader = new SVGLoader()
export function loadSVG(url: string) {
    return new Promise((res, reg) => {
        svgLoader.load(url, (data: any) => {
            res(data)
        })
    })
}

在threejs中加载svg使用SVGLoader的API,由于不是threejs内置的方法,所以要用显示引用将方法引入进来,loadSVG接受路径,并在加载后将数据使用promise的成功回调传出去,加载后得到参数类型是# 形状路径(ShapePath),接下来对路径进行加工,并使用# 挤压缓冲几何体(ExtrudeGeometry)挤出高度

export const getSVG2Model = async (url: string,back=false): Promise<THREE.Group> => {
    const svgPaths = await loadSVG(url) as { paths: ShapePath[] }

    const floorGroup = new THREE.Group()
    for (const path of svgPaths.paths) {
        console.dir(path)
        const shapes = SVGLoader.createShapes(path);
        // 获取路径
        for (const shape of shapes) {
            const mesh = paths2Mesh(shape, path.userData.node.id)
            
            ……
            
            floorGroup.add(mesh)
        }
    }

    return floorGroup

}

通过循环形状路径,得到每一条svg路径,并进行挤压,paths2Mesh方法就是根据路径创建一个挤压缓冲几何体,并将这些几何体加入到floorGroup中。

// 路径挤压为模型
const paths2Mesh = (shape: THREE.Shape | THREE.Shape[], name: string): THREE.Mesh => {
    const extrudeSettings = {
        depth: name==='floor'?8:getRandomInt(6,60),
    };

    const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
    const mesh = new THREE.Mesh(geometry, unBindStoreMaterial.clone());
    if(name === 'floor') {
        mesh.receiveShadow = true
    } else {
        mesh.castShadow = true
    }
     
    mesh.name = name
    const box3Info = getBox3Info(mesh);
    mesh.userData.box3Info = box3Info
    return mesh
}

加载svg.jpg

地图与模型的操作

开发传统threejs,是将控制器的操作绑定到场景的容器中,而在高德地图中绑定操作就方便多了,首先AMAP就提供了各种事件

点击地图

map.on('click', async (e: any) => {
    console.log(e.lnglat.lng, e.lnglat.lat);
    const lnglat = [e.lnglat.lng, e.lnglat.lat]

    map.render();
})

前面创建的mapRef对地图进行点击事件的绑定,e参数包含了经纬度、目标、事件类型等信息。

点击模型

……

const mouse = rayState.mouse.clone()
// 高德地图点击事件与3d模型的转换
mouse.x = (e.originEvent.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.originEvent.clientY / window.innerHeight) * 2 + 1;
……

mousevector2的二维向量,用来储存鼠标位置的,每次从点击事件的回调参数中获取到鼠标位置,并通过射线对3d模型的目标进行检测,如果从鼠标位置发出的射线与模型有交互,则返回模型的列表,返回值是一个数组,一般是从上往下的,光线投射官网介绍

rayState.raycaster.setFromCamera(mouse, camera);

const rallyist: THREE.Intersection<THREE.Object3D<any>>[] = rayState.raycaster.intersectObjects(floorGroupChildrenRef.current);

floorGroupChildrenRef.current 这是在加载svg时生成的挤压模型的集合 floorGroupChildrenRef.current = [...floorGroup.children],用于与射线交互的模型,如果想要过滤某些模型禁止用户点击,则可以在这里过滤出来。

rallyist便是与射线交互的模型信息,从这个数组的长度便可判断出,当前点击的位置是在高德地图上,还是在模型上,并且还知道在哪个模型上,从而进行模型的绑定。

 if (rallyList?.[0] && rallyList[0].object) {
    message.success('点击在3d模型上')
    const mesh = rallyList[0].object
    if (mesh.name !== 'floor') {
       // 点击在商铺楼层,用于绑定建筑
    } else {
       // 点击在地板上,用于绑定设施
    }

} else {
    message.success('点击在高德地图上')
}
            

以上,我们便在点击地图的时候区分点击的是高德地图还是3d模型,以便后续的功能开发

高德经纬度坐标系与three坐标系之间的转换

在开发的过程中,遇到一个很有意思的事情,那就是坐标转换,高德地图官网也有对于坐标转换的介绍,只有两种,一个是其他坐标系转高德坐标系,一般是经纬度,第二种是屏幕坐标和经纬之间的互转,那么问题来了,加入了threejs场景的向量怎么和经纬度互转呢?

想象一下这个场景,3d设计师给了你一个飞机的模型,并规划好了行动路线(vector3向量组),而你呢,需要在飞机飞行的过程中检测飞机是否经过某个建筑(经纬度标注),从而来进行一些对于飞机或者建筑的交互。

官网对坐标转换功能,没有过多的内容,只有api的介绍,我想大概是高等机密吧,这两个坐标一个是地理坐标,一个是映射坐标,或许通过常见的投影方式如墨卡托投影、高斯-克吕格投影等进行的投影变换,亦或是其他。

我们只管用,做一个api搬运工,下面做一个实验,在map的点击事件里,获取到地图经纬度beforelnglat和模型的射线交互的世界坐标rallyList[0]?.point,在世界坐标通过一系列的转换后得到的经纬度坐标和点击地图获取到的经纬度进行对比,看看两者是否有区别。。。

验证坐标转换

……

if (rallyList?.[0] && rallyList[0].object) {
    console.log('模型世界坐标', rallyList[0]?.point);
    const viewPoint = new Vector2() // 屏幕坐标
    
    getViewCp(rallyList[0]?.point, viewPoint, camera)

    console.log('世界坐标转屏幕坐标', viewPoint);

    const lnglat = new Vector2()
    coordsToLngLats(viewPoint, AMapRef.current, mapRef.current, lnglat)

    const beforelnglat = [e.lnglat.lng, e.lnglat.lat]
 
    console.log('点击获取的经纬度', beforelnglat)
    console.log('转化后的经纬度', lnglat);

……

坐标转换.jpg

从打印的数据看来,两者是完全一样的。

上面的代码用到了两个方法,一个是getViewCp世界坐标转屏幕坐标,另一个是coordsToLngLats屏幕坐标转经纬度,这个是高德地图提供的api。

屏幕坐标转经纬度

// 屏幕坐标转经纬度
export const coordsToLngLats = (point: THREE.Vector2, AMap: any, map: any, targetV2?: THREE.Vector2) => {
    const { x, y } = point
    var pixel = new AMap.Pixel(x, y);

    var lnglat = map.containerToLngLat(pixel);
    const v2 = new THREE.Vector2()
    v2.set(lnglat.lng, lnglat.lat)
    if (targetV2?.isVector2) {
        targetV2.copy(v2)
    }
    return v2

}

AMap.Pixel是专门提供给用户获取像素点的,

像素坐标,确定地图上的一个像素点。

世界坐标转屏幕坐标

// 世界坐标转屏幕坐标
export const getViewCp = (v3: THREE.Vector3, v2: THREE.Vector2, camera: THREE.Camera) => {
    var worldVector = v3.clone();
    var standardVector = worldVector.project(camera); //世界坐标转标准设备坐标
    var a = window.innerWidth / 2;
    var b = window.innerHeight / 2;
    var vx = Math.round(standardVector.x * a + a); //标准设备坐标转屏幕坐标
    var vy = Math.round(-standardVector.y * b + b); //标准设备坐标转屏幕坐标
    const p = new THREE.Vector2(vx, vy)

    v2.copy(p)
    return p
}

高德地图添加react组件的marker标记

添加商铺或者点位以后,将获取到经纬度信息和名称业态等基础信息,我们将这些信息和楼层绑定,并创建一个标记,使用高德地图提供的AMap.MarkerAPI,为了开发方便,需要再react中写一个组件,用于遍历标记信息并将组件渲染到marker中,marker中有这么一个属性opts.content - 点标记显示内容。可以是HTML要素字符串或者HTML DOM对象。content有效时,icon属性将被覆盖。,接收的是一个dom节点,我们是用react写的,所以组件是不能直接用的,所以需要转化一下。

const position = new AMap.LngLat(markData?.lnglat?.[0], markData?.lnglat?.[1]); //Marker 经纬度
const element = document.createElement('div');
const root = ReactDOM.createRoot(element);
root.render(getComponent());

const marker = new AMap.Marker({
    position: position,
    content: element, //将 html 传给 content
    offset: new AMap.Pixel(-iconStype.width / 2, -iconStype.height / 2), //以 icon 的 [center bottom] 为原点
});

map.add(marker);

ReactDOM.createRootroot.render就可以将组件解析成dom节点,在将content的值设为刚转的element,position是当前需要放置的经纬度,不过需要用AMap.LngLat格式化一下,表示这个坐标代表地图中的一个点位,类似与threejs中的vector2向量一样,只有两个数字,threejs也并不知道这组数字代表的是一个2位向量。

const getComponent = ()=>{
    return <div className="bind-store" style={{ ...iconStype }} onClick={checkMark} >
        <div className="img-main"><img src={logo} alt="" /></div>
        <p>{name}</p>
    </div>
}

mark.jpg

这样便将一个用react组件写的标记添加到高德地图中,顺便提一嘴,如果不是在AMAP中添加标记,而是在threejs的css3drender中添加标记 也是同样的道理,需要将react组件转成html标识或者dom节点,所以个人标识不喜欢用虚拟dom的框架开发threejs,太繁琐。

功能介绍

前面的所有内容都是对于模型和地图的定义和交互的设置,下面将对景区的数据进行绑定解绑等功能的开发。

由于没有后端给写服务器,所以只采用前端基于浏览器的indexDB进行数据管理,在代码中封装了对于数据的增删改查功能,

  • src\utils\indexedDB\index.ts indexDB配置文件
  • src\request\index.ts 数据请求配置文件
  • src\request\floor.ts 景区数据操作
  • src\request\point.ts 设施点位数据操作
  • src\request\store.ts 建筑数据操作

绑定数据

项目的UI使用的antd的组件库

新增/删除楼层

新增景区.gif

这里就是一个简单的像indexdb库里插入一条景区的数据,不过多赘述,起始位置是设定模型在高德地图中的位置,按理说模型路径应该是上传文件,我这里是将oss路径内置到选项中,直接选择就可以,你可以增加很多个景区,但是模型只有一个。

绑定建筑

绑定商铺.gif

前面写过的map.on来监听click事件,并通过射线获取当前点击到的模型,并存储模型的经纬度,并将数据和景区的数据绑定。

 // 弹窗确定按钮
const add = () => {
    form.validateFields().then((values) => {
        if (storeState.isEdit) {
            editStore(storeState.storeId || '', values)
        } else {
            if (props.addId) {
                addStore({
                    ...values,
                    lnglat,
                    storeId: props.addId
                })
            }
        }
    })
}

在添加点位后还需要调用refreshStore方法刷新一下模型,将刚添加的模型进行重绘和绑定。

// 添加接口
const { run: addStore } = useRequest(createStoreInfo, {
    manual: true,
    onSuccess: (res) => {
        if (res.success) {
            props?.refreshStore && props?.refreshStore(floorId)
            form.resetFields()
            message.success(res.msg)
            cancel()
        }
    }
})

绑定点位

绑定设施点位.gif

绑定设施点位逻辑与绑定店铺相同,只不过只需要存下经纬度和基础信息即可不需要记录模型信息。

AIBOT

项目中集成了coze的ai系统,并且设置了bot

coze智能导游:

aibot.jpg

商铺活动BOT:

智慧导游.jpg

从流程图中可以看到,当用户输入肯德基这个关键词,bot会执行工作流,并最终输出一条预支好的消息。

效果图:

预设回复.gif

这里做了交互,点击店铺的marker,则获取店铺的信息,并向aibot进行询问,aibot识别到关键字“肯德基”则会自动回复预设好的内容,如果输入其他内容,aibot也会回答的,如下图

其他问题.gif

另注:coze的aibot将在8月15日后进行限流访问,可以在代码中替换自己的ai智慧体,高德地图的api也是限流的,超过额度就不可使用了,尽量在项目中使用自己申请的key

历史文章

three.js——完整3d大屏展示超详细讲解

threejs——可视化风力发电车物联交互效果 内附源码

three.js——商场楼宇室内导航系统 内附源码

three.js——可视化高级涡轮效果+警报效果 内附源码

three.js 专栏