Skip to main content

NANAbout 7 min

Threejs 3d 地图加载器

// @ts-ignore
import * as THREE from 'three';
// @ts-ignore
import * as d3 from 'd3';
import type {Featuresjson, Mapjson, Coordinates, Coordinate} from './map_package/type';
import { geojsons } from './map_package/index';

const depth = 3 // 设置模型的厚度

let containerElement: HTMLElement;
let screenBox: HTMLElement;
const mouse = new THREE.Vector2();
const mouseRaycaster = new THREE.Raycaster();

// 事件点数组
let eventPoint: Record<string, EventPoint|undefined> = {}

// threejs 渲染器
export class ThreeMap {

    public renderer: THREE.WebGLRenderer;
    public scene: THREE.Scene;
    public camera: THREE.PerspectiveCamera;
    public height: number;
    public width: number;

    constructor(element: HTMLElement){
        containerElement = element;
        const {width, height} = element.getBoundingClientRect();
        this.height = height;
        this.width = width;

        // 创建webGL渲染器
        this.renderer = new THREE.WebGLRenderer( { antialias: true,alpha: true} );
        this.renderer.shadowMap.enabled = true; // 开启阴影
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
        this.renderer.toneMappingExposure = 1.25;   
        this.renderer.setPixelRatio( window.devicePixelRatio );
        this.renderer.setClearColor(0xffffff, 0);
        this.renderer.setSize(width, height);
    
        // 场景
        this.scene = new THREE.Scene();

        // 相机 透视相机
        this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 5000);
        this.camera.position.set(0, -40, 150);
        this.camera.lookAt(0, 0, 0);

        // 将渲染器注入到容器里
        containerElement.appendChild(this.renderer.domElement);
        containerElement.addEventListener('mousemove',(event) => Object.bind(this, this.onMouseMove(event)));
        containerElement.addEventListener('click',(event) => Object.bind(this, this.onMouseClick(event)));
        this.resizeObserver.observe(containerElement)

        // 添加一个屏幕空间UI盒子 用来放置一些UI元素
        screenBox = document.createElement('div')
        screenBox.style.position = 'absolute'
        screenBox.className = 'screen-space-ui'
        containerElement.appendChild(screenBox)
    }

    // 切换地图模型
    public switchMap(mapName: string){
        // 移除上一个地图对象
        const current = this.mapModleRecord[this.currentMapName]
        if(current!==undefined) {
            current.destroy()
        }

        if(geojsons[mapName]===undefined) {
            console.error('地图不存在')
            return
        }
        this.createMapModle(geojsons[mapName])
        Object.values(eventPoint).forEach(item=>{
            if(item!==undefined) item.remove()
        })
        eventPoint = {}
    }

    // 创建地图模型
    private mapModleRecord: Record<string, CreateMap> = {};
    private currentMapName: string = '';
    private createMapModle(mapJson: Mapjson){
        let MapModle = this.mapModleRecord[mapJson.name]
        if(MapModle===undefined){
            MapModle = new CreateMap(mapJson)
            this.mapModleRecord[mapJson.name] = MapModle
        }else{
            MapModle.rebuild()
        }
        this.currentMapName = mapJson.name
        this.scene.add(MapModle.Object3D as THREE.Object3D)
    }

    // 获取一个区块的中心点坐标
    private getFeatureCentroid(featureName: string){
        return this.mapModleRecord[this.currentMapName].getFeatureCentroid(featureName)
    }

    // 事件点
    setEventPoint(data: {name: string, value: number}[], template?: (name: string, value: number)=>string){
        const newEventPoint:Record<string, EventPoint|undefined> = {}
        data.forEach(item=>{
            const defaultTemplate = (name: string, value: number) =>{
                return `
                    <div class='point'></div>
                    <div class='text'>
                        <span class='name'>${name}</span>
                        <span class='value'>${value}</span>
                    </div>
                `
            }
            const ponit = this.updateEventPoint(item.name, item.value, template||defaultTemplate)
            newEventPoint[item.name] = ponit
            eventPoint[item.name] = undefined
        })
        Object.values(eventPoint).forEach(item=>{
            if(item===undefined) return
            item.remove()
        })
        eventPoint = newEventPoint
    }
    // 更新事件点
    public updateEventPoint(featureName: string, value: number, template: (name: string, value: number)=>string){
        let dom = eventPoint[featureName]
        const featureCenter = this.getFeatureCentroid(featureName)
        if(featureCenter===undefined)return undefined
        const [x, y, z] = featureCenter
        const sp = this.toScreenPosition(new THREE.Vector3(x, -y, z))
        if(dom!==undefined){
            dom.value = value
            dom.x = sp.x
            dom.y = sp.y
        }else{
            dom = new EventPoint(sp.x, sp.y, featureName, value, template)
        }
        return dom
    }
    // 获取所有事件点
    public getAllEventPoint(): EventPoint[]{
        return Object.values(eventPoint).filter(item=>item!==undefined) as EventPoint[]
    }

    // 渲染场景
    async render(){
        this.renderer.render(this.scene, this.camera);
        requestAnimationFrame(()=>this.render())
    }

    // 防抖
    private debounce?: number
    private intersectMap: boolean = false
    private onMouseMove(event: any) {
        const currentMap = this.mapModleRecord[this.currentMapName]
        // 将鼠标位置转换为归一化设备坐标 (-1 到 +1)
        const rect = containerElement.getBoundingClientRect();
        const mouseX = event.clientX - rect.left;
        const mouseY = event.clientY - rect.top;
        mouse.x = (mouseX / rect.width) * 2 - 1;
        mouse.y = -(mouseY / rect.height) * 2 + 1;
    
        // 更新射线
        mouseRaycaster.setFromCamera(mouse, this.camera);

        const intersectFeatures:CreateFeature[] = []
        const activeFeatures:CreateFeature[] = []
        if(currentMap===undefined)return
        const features = Object.values(currentMap.features)
        
        const intersectMap = mouseRaycaster.intersectObject(currentMap.Object3D as THREE.Object3D)
        if(intersectMap.length===0){
            if(this.intersectMap){
                features.filter(item=>item.isActive).forEach(feature=>{
                    feature.isActive = false
                })
            }
            this.intersectMap = false
            this.emitMoveMouseEvent(null)
            return
        }
        this.intersectMap = true
        
        features.forEach((feature)=>{
            if(feature.isActive){
                activeFeatures.push(feature)
            }
            if(feature.Object3D===null)return 
            const inter = mouseRaycaster.intersectObject(feature.Object3D)
            if(inter.length>0){
                intersectFeatures.push(feature)
            }
        })

        if(this.debounce!==undefined){
            clearTimeout(this.debounce)
            this.debounce = undefined
        }
        this.debounce = setTimeout(()=>{
            if(intersectFeatures.length>0){
                activeFeatures.filter(item=>item.name!==intersectFeatures[0].name).forEach(feature=>{
                    feature.isActive = false
                })
                const feature = intersectFeatures[0]
                feature.isActive = true
                this.emitMoveMouseEvent(feature)
                
            }
            clearTimeout(this.debounce)
            this.debounce = undefined
        }) as number
    }

    // 监听鼠标点击事件
    private onMouseClick(event: any) {
        const currentMap = this.mapModleRecord[this.currentMapName]

        const features = Object.values(currentMap.features)
        const intersectFeatures:CreateFeature[] = []

        features.forEach((feature)=>{
            if(feature.Object3D===null)return 
            const inter = mouseRaycaster.intersectObject(feature.Object3D)
            if(inter.length>0){
                intersectFeatures.push(feature)
            }
        })

        if(intersectFeatures.length>0){
            this.emitClickMouseEvent(intersectFeatures[0])
        }
    }

    // 监听鼠标移动事件
    private moveMouseEventList:Map<(feature: CreateFeature|null)=>void, {that: Object}> = new Map()
    public onMoveMouseEvent(callback: (feature: CreateFeature|null)=>void, that: Object = window){
        this.moveMouseEventList.set(callback,{that})
    }
    public removeMoveMouseEvent(callback: (feature: CreateFeature|null)=>void){
        this.moveMouseEventList.delete(callback)
    }
    private emitMoveMouseEvent(feature: CreateFeature|null){
        this.moveMouseEventList.forEach((item, callback)=>{
            callback.bind(item.that)(feature)
        })
    }
    // 监听鼠标点击事件
    private mouseClickEventList:Map<(feature: CreateFeature)=>void, {that: Object}> = new Map()
    public onClickMouseEvent(callback: (feature: CreateFeature)=>void, that: Object = window){
        this.mouseClickEventList.set(callback,{that})
    }
    public removeClickMouseEvent(callback: (feature: CreateFeature)=>void){
        this.mouseClickEventList.delete(callback)
    }
    private emitClickMouseEvent(feature: CreateFeature){
        this.mouseClickEventList.forEach((item, callback)=>{
            callback.bind(item.that)(feature)
        })
    }


    // 设置一个区块高亮状态
    setHighlightStatus(featureName: string, highlight: boolean = false) {
        const currentMap = this.mapModleRecord[this.currentMapName]
        if(currentMap===undefined)return
        currentMap.setHighlightStatus(featureName, highlight);
        
        const featureCentroid = currentMap.getFeatureCentroid(featureName);
        if(featureCentroid===undefined)return
        const [x, y, z] = featureCentroid

        const p = eventPoint[featureName]
        if(p === undefined)return
        const sp = this.toScreenPosition(new THREE.Vector3(x, -y, z));
        p.x = sp.x;
        p.y = sp.y;
    }

    // 清空其他区域的高亮
    clearHighlight() {
        const currentMap = this.mapModleRecord[this.currentMapName]
        if(currentMap===undefined)return
        Object.values(currentMap.features).forEach((feature) => {
            this.setHighlightStatus(feature.name)
        });
    }


    // 将 3D 坐标转换到屏幕空间坐标的函数
    toScreenPosition(vec3: THREE.Vector3) {
        // 将世界坐标投影到相机的裁剪空间
        vec3.project(this.camera);

        // 将裁剪空间坐标转换为屏幕空间坐标
        const rect = containerElement.getBoundingClientRect();

        const halfWidth = rect.width / 2;
        const halfHeight = rect.height / 2;

        return {
            x: (vec3.x * halfWidth) + halfWidth,
            y: -(vec3.y * halfHeight) + halfHeight
        };
    }

    resizeObserver = new ResizeObserver(entries => {
        for (let entry of entries) {
            const { width, height } = entry.contentRect;
            this.resize(width, height);
        }
    });

    // 更新地图的大小
    private resizeDebounce?: number
    resize(width: number, height: number) {
        if(this.resizeDebounce!==undefined){
            clearTimeout(this.resizeDebounce)
            this.resizeDebounce = undefined
        }
        this.resizeDebounce = setTimeout(()=>{
            if(width!==this.width||height!==this.height){
                this.camera.aspect = width / height;
                this.camera.updateProjectionMatrix();
                this.renderer.setSize(width, height);
                this.getAllEventPoint().forEach((eventPoint)=>{
                    const featureCenter = this.getFeatureCentroid(eventPoint.name)
                    if(featureCenter===undefined)return undefined
                    const [x, y] = featureCenter
                    const sp = this.toScreenPosition(new THREE.Vector3(x, -y, 2))
                    eventPoint.x = sp.x
                    eventPoint.y = sp.y
                })
            }
            clearTimeout(this.resizeDebounce)
            this.resizeDebounce = undefined
        }) as number
    }

    // 销毁整个地图
    destroy(){
        Object.values(this.mapModleRecord).forEach((map)=>{
            map.destroy()
        })
        Object.values(eventPoint).forEach((eventPoint)=>{
            eventPoint?.remove()
        })
    }
}

// 事件点虚拟dom
class EventPoint{
    dom: HTMLElement
    template:(name: string, value: number)=>string

    private _name: string = ''
    set name(value: string){
        this._name = value;
        this.dom.innerHTML = this.template(this.name, this.value)
    }
    get name(){
        return this._name
    }

    private _value: number = 0
    set value(value: number){
        this._value = value;
        this.dom.innerHTML = this.template(this.name, this.value)
    }
    get value(){
        return this._value
    }

    private _x: number = 0
    set x(value: number){
        this._x = value
        this.dom.style.left = `${value}px`
    }
    get x(){
        return this._x
    }

    private _y: number = 0
    set y(value: number){
        this._y = value
        this.dom.style.top = `${value}px`
    }
    get y(){
        return this._y
    }

    private _className: Record<string, boolean> = {}
    get className(){
        return Object.keys(this._className).filter((item)=>{
            return this._className[item]
        }).join(' ')
    }

    addClassNames(value: string){
        this._className[value] = true
        this.dom.className = this.className
    }

    removeClassNames(value: string){
        this._className[value] = false
        this.dom.className = this.className
    }


    constructor(x: number, y: number, name: string, value: number, template:(name: string, value: number)=>string){
        this.template = template
        this.dom = document.createElement('div')
        this.dom.style.position = 'absolute'
        this.dom.innerHTML = template(name, value)
        this.x = x
        this.y = y
        this.name = name
        this.value = value
        this.addClassNames('evnet-point')
        screenBox.appendChild(this.dom)
    }

    remove(){
        this.dom.remove()
    }
}



// 创建地图 模型
class CreateMap{
    Object3D: THREE.Object3D|null // 地图的3D对象
    
    readonly features: Record<string, CreateFeature> = {} // 区块

    private projection:d3.GeoProjection; // 墨卡托投影转换

    constructor(mapJson: Mapjson){
        this.Object3D = new THREE.Object3D()

        const bbox = mapJson.bbox;
        const offset = mapJson.offset;
        const rect = containerElement.getBoundingClientRect();

        this.projection = d3.geoMercator()
        .center(
            bbox===undefined?[104.0, 37.5]:
            [(bbox[0][0] + bbox[1][0]) / 2, (bbox[0][1] + bbox[1][1]) / 2]
        )
        .translate(offset===undefined? [0, 0] :[offset[0], offset[1]])
        if(bbox!==undefined){
            const bboxWidth = bbox[1][0] - bbox[0][0];
            const bboxHeight = bbox[0][1] - bbox[1][1];
            const scale = Math.min(rect.width / bboxWidth, rect.height / bboxHeight) * (mapJson.scale||6);
            this.projection = this.projection.scale(scale)
        }

        const {features} = mapJson;
        features.forEach((feature) => {
            const Feature = this.createFeature(feature)
            this.Object3D?.add(Feature.Object3D as THREE.Object3D);
        })
    }

    // 创建区块对象
    createFeature(feature: Featuresjson){
        const area  = new CreateFeature(feature, this.projection as (opt: [number, number])=> [number, number])
        this.features[feature.properties.name] = area
        return area
    }

    // 获取一个区块的质心
    getFeatureCentroid(name: string):[number, number, number]|undefined{
        const feature = this.features[name]
        if(!feature) return undefined
        const centroid = feature?.centroid
        const depth = feature.depth
        if (!centroid) return undefined
        const [x, y] = centroid
        return [x, y, depth] as [number, number, number]
    }

    // 设置一个区块高亮状态
    setHighlightStatus(featureName: string, highlight: boolean = false) {
        const feature = this.features[featureName];
        if (feature) {
            feature.isActive = highlight;
        }
    }

    // 销毁当前模型
    destroy(){
        Object.values(this.features).forEach((feature) => {
            feature.destroy()
        })
        this.Object3D?.parent?.remove(this.Object3D)
        this.Object3D === null
    }

    // 重新构建
    rebuild(){
        if(this.Object3D !== null){
            this.destroy()
        }
        this.Object3D = new THREE.Object3D()
        Object.values(this.features).forEach((feature) => {
            const FeatureObject3D = feature.rebuild();
            this.Object3D?.add(feature.Object3D as THREE.Object3D);
        })
    }
}


// 创建区块 模型
class CreateFeature{
    Object3D: THREE.Object3D|null // 区块的3D对象

    name: string // 区块的名称
    center?: [number, number] // 区块的中心点
    centroid?: [number, number] // 质心点

    is2d: boolean = false // 区块是否为2D
    shapes: THREE.Shape[] = [] // 平面图形
    geometrys: THREE.ExtrudeGeometry[] = [] // 立体模型
    mesh: THREE.Mesh[] = [] // 区块的网格模型
    
    private _isActive: boolean = false // 区块是否被选中
    // 区块是否被选中
    get isActive(){
        return this._isActive
    }
    set isActive(value: boolean){
        this._isActive = value
        this.setHighlight(value)
        this.scaleZ = value?2:1
        const eventpoint = eventPoint[this.name]
        if(eventpoint!==undefined){
            if(value){
                eventpoint.addClassNames('active')
            }else{
                eventpoint.removeClassNames('active')
            }
        }
    }

    _scaleZ: number = 1 // 区块的z轴拉伸
    get scaleZ(){
        return this._scaleZ
    }
    set scaleZ(value: number){
        this._scaleZ = value
        this.updateModelHeight(value)
    }
    get depth(){
        return depth * this.scaleZ
    }

    constructor(features: Featuresjson, projection:(opt:[number,number])=>[number,number], ){
        this.Object3D = new THREE.Object3D()

        this.name = features.properties.name
        this.is2d = features.properties.is2d || false

        if(features.properties.centroid){
            this.centroid = projection(features.properties.centroid)
        }

        if(features.properties.center){
            this.center = projection(features.properties.center)
        }

        this.generateShapes(features.geometry.coordinates, projection)
        this.generateMesh(this.depth)
    }

    // 生成区块的平面图
    generateShapes(coordinates: Coordinates, projection:(opt:[number,number])=>[number,number]){
        const innerThis = this

        // 用于递归coordinates数组
        function recursion(coordinates: Coordinates){
            coordinates.forEach((item) => {
                if(typeof (item[0] as Coordinates)[0] === 'number'){
                    buildMesh(item as Coordinate[])
                }else{
                    recursion(item as Coordinates)
                }
            })
        }

        // 用来构建模型
        function buildMesh(coordinates: Coordinate[]){
            const shape = new THREE.Shape();
            innerThis.shapes.push(shape);

            // 绘制平面图形
            for (let i = 0; i < coordinates.length; i++) {
                let [x, y] = projection(coordinates[i]);
                if (i === 0) {
                    shape.moveTo(x, -y);
                }
                shape.lineTo(x, -y);
            }
        }
        recursion(coordinates)
    }

    // 生成区块的冲压模型
    generateMesh(depth: number) {
       this.cleasMesh()

        const innerThis = this

        this.shapes.forEach((shape) => {
            const geometry = new THREE.ExtrudeGeometry(shape, {
                depth: innerThis.is2d? 0 : depth , // 模型的深度
                bevelEnabled: false, // 对挤出的形状应用是否斜角
                bevelSegments: 1, // 斜角的分段层数
                bevelThickness: 0.2 // 设置原始形状上斜角的厚度。
            })
            this.geometrys.push(geometry)
            // 创建网格对象
            const mesh = new THREE.Mesh(geometry, [
                innerThis.topMaterial,
                innerThis.verticalMaterial,
            ]);
            this.mesh.push(mesh)
            // 为模型添加描边
            // TODO 性能差,耗时太多,需要优化
            const edges =  new THREE.EdgesGeometry( geometry );
            const line = new THREE.LineSegments( edges, new THREE.LineBasicMaterial( { color: 0xffffff } ) );
            mesh.add( line );

            innerThis.Object3D?.add(mesh)
        })
    }

    private topMaterial = new THREE.ShaderMaterial({
        uniforms: {
            color: { value: new THREE.Color( 0x62797a ) },
        },
        fragmentShader: `
            uniform vec3 color;
            void main() {
                gl_FragColor = vec4( color, 1.0 );
            }
        `
    })

    private verticalMaterial = new THREE.ShaderMaterial({
        uniforms: {
            color: { value: new THREE.Color( 0x62797a ) },
        },
        fragmentShader: `
            uniform vec3 color;
            void main() {
                gl_FragColor = vec4( color, 1.0 );
            }
        `
    })

    setHighlight(isHighlight: boolean){
        if(isHighlight){
            this.topMaterial.uniforms.color.value = new THREE.Color(0x36c793)
            this.verticalMaterial.uniforms.color.value = new THREE.Color(0x36c793)
        }else{
            this.topMaterial.uniforms.color.value = new THREE.Color(0x62797a)
            this.verticalMaterial.uniforms.color.value = new THREE.Color(0x62797a)
        }
    }

    // 跟新模型的高度
    updateModelHeight(newHeight: number) {
        this.mesh.forEach(mesh => {
            mesh.scale.z = newHeight
            mesh.updateMatrix()
        })
    }

    cleasMesh(){
        this.geometrys.forEach(geometry => {
            geometry.dispose()
        })
        this.geometrys = []

        this.mesh.forEach(mesh => {
            this.Object3D?.remove(mesh)
        })
        this.mesh = []
    }

    // 销毁当前区块
    destroy(){
        this.cleasMesh()
        this.Object3D?.parent?.remove(this.Object3D)
        this.Object3D = null
    }

    rebuild(){
        if(this.Object3D!==null){
            this.destroy()
        }
        this.Object3D = new THREE.Object3D()
        this.generateMesh(depth)
    }
}