Three.js绘制3d地图轮廓高亮和渐变柱状图

3,456 阅读3分钟

Three.js 版本:0.152.0

地图Geojson获取方式:Datav.GeoAtlas

先看效果

10月1日-min.gif

代码拆分讲解

实例化并初始化地图

class CoreMap {
    constructor(option, element) {
        this.option = option
        this.element = element
        this.textureLoader = new THREE.TextureLoader();
        this.mapGroup = new THREE.Group()   // 地图
        this.lineGroup = new THREE.Group()  // 线条
        this.regionGroup = new THREE.Group() // 区域
        this.textLabelGroup = new THREE.Group() // 名称
        this.barGroup = new THREE.Group()       // 光柱
        this.topTexture = this.textureLoader.load( './images/top-texture.png' );
        this.topTexture.colorSpace = THREE.SRGBColorSpace;
    }
    init() {
        this.initScene()     // 初始化场景
        this.initComposer()  // 后期处理
        this.drawMap()       // 绘制地图
        this.drawImage()     // 绘制底图
        this.setControls()   
        this.animate();
    }
    animate() {
        requestAnimationFrame(this.animate.bind(this));
        this.controls.update();
        this.composer.render();
        TWEEN.update()
        this.labelRenderer.render( this.scene, this.camera );
    }
    ....
}
 const option = {
    sideDepth: 8,
    sideColor: '#175684',
    topLineColor: '#fff',
    edgeColor: '#00ae9d'
}
const coreMap = new CoreMap(option, document.querySelector('#container'))
coreMap.init()

初始化场景、相机等元素

initScene() {
    this.scene = new THREE.Scene();
    const width = this.element.offsetWidth, height = this.element.offsetHeight

    // camera
    this.camera = new THREE.PerspectiveCamera( 45, width / height, 1, 10000 );
    this.camera.position.set(0, 0, 5);

    // renderer
    this.renderer = new THREE.WebGLRenderer({antialias: true});
    this.renderer.setSize( width, height);
    this.renderer.setClearColor(0xffffff, 0);
    this.element.appendChild(this.renderer.domElement);

    this.labelRenderer = new CSS2DRenderer();  // 绘制地名采用css2d的方式
    this.labelRenderer.setSize( width, height );
    this.labelRenderer.domElement.style.position = 'absolute';
    this.labelRenderer.domElement.style.top = '0px';
    this.element.appendChild( this.labelRenderer.domElement );

    // light
    const alight = new THREE.AmbientLight( 0xffffff, 4)

    this.scene.add(alight);
}

绘制地图

async drawMap() {
    const mapData = await this.loadMapData()
    mapUtil.computeLngLat(mapData, 200) // 地图工具类,主要用来处理墨卡托或世界坐标转换

    mapData.features.forEach(item => {
        const {coordinates, type} = item.geometry
        const shapeList = [] //多面数组
        coordinates.forEach(coordinate => {
            if (type === "MultiPolygon") {
                coordinate.forEach(polyon => void this.createPolyon(polyon, shapeList))
            } else if(type === "Polygon") {
                this.createPolyon(coordinate, shapeList)
            }
        })
        const mesh = this.getExtrudeMesh(shapeList)
        if(item.properties.name) {
            const textLabel = this.createTextLabel(item.properties)
            this.textLabelGroup.add(textLabel)
        }
        this.regionGroup.add(mesh)
    })
    
    this.mapGroup.add(this.regionGroup)
    this.mapGroup.add(this.lineGroup)
    this.mapGroup.add(this.textLabelGroup)
    this.updateCamera(this.mapGroup) // 根据地图大小自适应调整相机
    this.scene.add(this.mapGroup)
}
loadMapData() {
    const loader = new THREE.FileLoader()
    loader.setResponseType('json')
    return new Promise(rs => void loader.load('./city.json', data => rs(data)))
}

绘制区域多边形

createPolyon(shapeList) {
    const vector2Arr = [], linePoints = []
    for (let i = 0; i < points.length; i++) {
        const [x, y] = mapUtil.toWorld(points[i]); // 经纬度转世界坐标
        vector2Arr.push(new THREE.Vector2(x, -y))
        linePoints.push(x, -y, 0);
    }
    shapeList.push(new THREE.Shape(vector2Arr))
    this.lineGroup.add(this.getLineMesh(linePoints))
}
getExtrudeMesh(shapeList) {
    // 挤压缓冲几何体,用这个几何体的原因是可以配置"depth"(侧边形状的深度)参数
    const geometry = new THREE.ExtrudeGeometry( shapeList, {depth: this.option.sideDepth} );
    // 顶面材质
    const topMaterial = new THREE.MeshStandardMaterial({
        map: this.topTexture  // 纹理
    });
    // 侧面材质
    const extrudeMaterial = new THREE.MeshStandardMaterial({color: this.option.sideColor})  

    const mesh = new THREE.Mesh( geometry, [topMaterial, extrudeMaterial] )

    return mesh
}

绘制区域线

getLineMesh(linePoints) {
    const geometry = new LineGeometry();
    geometry.setPositions( linePoints );
    const color = new THREE.Color(this.option.topLineColor);
    const uplineMaterial = new LineMaterial({
        transparent: true,
        opacity: 0.6,
        color,
        linewidth: 1
    });
    uplineMaterial.resolution.set( this.element.offsetWidth, this.element.offsetHeight)

    const line = new Line2(geometry, uplineMaterial);
    line.position.z = this.option.sideDepth + 0.3; // 侧边深度 + 0.3  

    return line
}

绘制地名

createTextLabel(properties) {
    const z = this.option.sideDepth + 0.021
    const {name, centroid, center} = properties
    const element = document.createElement( 'div' );
    const textNode = document.createElement('div');
    const imgNode = document.createElement('div');
    textNode.textContent = name;
    textNode.style.color = '#fff';
    textNode.style.fontSize = '14px';
    textNode.style.opacity = 1
    imgNode.style.backgroundImage = 'url(./images/bg1.svg)'
    imgNode.style.height = '22px'
    imgNode.style.backgroundRepeat = 'no-repeat'
    imgNode.style.backgroundPosition = 'center'
    element.appendChild( textNode );
    element.appendChild( imgNode );

    const label = new CSS2DObject(element);
    const [x, y] = mapUtil.toWorld(centroid || center);
    label.position.set(x, -y, z);

    return label
}

相机自适应

updateCamera(obj) {
    const mapBox = new THREE.Box3().setFromObject(obj);

    const mapBoxSize = mapBox.getSize(new THREE.Vector3()).length();
    const boxCenter = mapBox.getCenter(new THREE.Vector3());
    
    // 这个函数官方案例有,可以自己去案例里搜索,简单讲就是根据物体大小计算相机与物体的距离,调整相机到适合观察物体的距离
    this.frameArea(mapBoxSize * 0.9, mapBoxSize, boxCenter);  
    obj.rotation.x = - Math.PI / 180 * 45   // 这里为了初次加载好看,绕x轴旋转-45度

    this.controls.maxDistance = mapBoxSize * 2 // 向外移动
    this.controls.maxAzimuthAngle = Math.PI / 180 * 90   // 水平旋转的角度上限
    this.controls.minAzimuthAngle = -Math.PI / 180 * 90   // 水平旋转的角度下限
    this.controls.update();
}
 frameArea(sizeToFitOnScreen, boxSize, boxCenter) {
    const halfSizeToFitOnScreen = sizeToFitOnScreen * 0.5;
    const halfFovY = THREE.MathUtils.degToRad(this.camera.fov * .5);
    const distance = halfSizeToFitOnScreen / Math.tan(halfFovY);
    // compute a unit vector that points in the direction the camera is now
    // in the xz plane from the center of the box
    const direction = (new THREE.Vector3())
        .subVectors(this.camera.position, boxCenter)
        .multiply(new THREE.Vector3(0, 0, 1))
        .normalize();

    // move the camera to a position distance units way from the center
    // in whatever direction the camera was from the center already
    this.camera.position.copy(direction.multiplyScalar(distance).add(boxCenter));

    // pick some near and far values for the frustum that
    // will contain the box.
    this.camera.near = boxSize / 100;
    this.camera.far = boxSize * 100;

    this.camera.updateProjectionMatrix();

    // point the camera to look at the center of the box
    this.camera.lookAt(boxCenter.x, boxCenter.y, boxCenter.z);
}

至此,地图区域和相机自适应大小实现完成~

底座动画

底座绘制没什么特别高深的东西,就是创建几个平面几何体,材质贴上自己想要的纹理图片即可;动画效果是通过tween.js实现的,参照下列代码.

drawImage() {
    const imgGroup = new THREE.Group()

    const bgTexture = this.textureLoader.load( './images/bg-texture.png' );
    bgTexture.colorSpace = THREE.SRGBColorSpace;

    const bgTexture1 = this.textureLoader.load( './images/t1.png' );
    bgTexture1.colorSpace = THREE.SRGBColorSpace;

    const bgTexture2 = this.textureLoader.load( './images/t2.png' );
    bgTexture2.colorSpace = THREE.SRGBColorSpace;

    const geometry = new THREE.PlaneGeometry( 400,400 );
    const material = new THREE.MeshBasicMaterial( { map: bgTexture, depthWrite: false, transparent: true} );
    const plane = new THREE.Mesh( geometry, material );

    const geometry1 = new THREE.PlaneGeometry( 200,200 );
    const material1 = new THREE.MeshBasicMaterial( { map: bgTexture1, depthWrite: false, transparent: true} );
    const plane1 = new THREE.Mesh( geometry1, material1 );

    const geometry2 = new THREE.PlaneGeometry( 180,180 );
    const material2 = new THREE.MeshBasicMaterial( { map: bgTexture2, depthWrite: false, transparent: true} );
    const plane2 = new THREE.Mesh( geometry2, material2 );

    imgGroup.add(plane, plane1, plane2)

    imgGroup.rotation.x = -Math.PI / 180 * 45

    const coords = {z: 0}
    const tween = new TWEEN.Tween(coords)
        .to({z: 360}, 25000)
        .onUpdate(() => {
            plane1.rotation.z = Math.PI / 180 * coords.z
            plane2.rotation.z = -Math.PI / 180 * coords.z
        })
        .start()
        .repeat(Infinity)



    console.log(tween)
    this.scene.add( imgGroup );
}

总结

以上代码趁着节假日时间充裕整理了一下,分享给大家,最后文章有哪里写的不对或不理解的地方,欢迎指正交流~