开源接流:一个方法搞定3D地图双屏联动

老大提需求:一份数据,在2D地图上可编辑,在3D地图上显示高度信息,关键是两个地图得支持视图同步,末了还来句"两天时间够了吧?"我饶了饶头,内心各种问候...,代码如何下手,特X的,什么是双屏联动?

掘金地址:https://juejin.cn/spost/7395385447294369830

功能实现了,顺便开源下

npm: mapboxgl-syncto-any

github: https://github.com/cnmapos/mapboxgl-syncto-any

image.png

首页提供有完整的使用说明,各位老铁有需要就标下star,关注下呐。 以下内容主要分享mapboxgl-syncto-any如何实现以及核心原理。

image.png

mapboxgl-syncto-any基于mapbox-gl,提供和其他任意地图视图同步,支持的同步参数主要有zoom、center、pitch、bearing等。如果你嫌麻烦,默认也 提供了mapbox-gl和cesium双屏同步,调一个方法就完事。

提供的方法都有对应的demo,如果嫌啰嗦,直接运行代码得了。

mapbox-gl和cesium双屏同步

方法定义:mapboxViewSyncWithCesium(mpboxViewer, cesiumViewer, options)

options参数属性包含:

  • initFrom: 0 | 1, 初始化同步源, 0表示以mapbox地图初始化位置,1表示以其他地图位置初始化
  • direction: 0 | 1 | 2, 视图同步方向, 0为双向同步,1为mapbox->其他,2为其他->mapbox
  • mapboxAllowPitch: true|false,mapbox支持俯仰角同步
  • anyAllowPitch: true|false,其他地图支持俯仰角同步

例如下面这段代码,前两个参数传入两个地图实体mapboxMap、anyMap,第三个参数中: direction为0表示不管改变哪个地图位置,另外一个地图会自动同步;而anyAllowPitch表示改变mapbox地图俯视角,anyMap保持原始的俯视角度不变。

const mapboxMap = await mapboxSetup({ container: mboxMapEle.value });
const anyMap = await anyMapSetup({ container: anyMapEle.value });

const synchronizer = mapboxViewSyncWithCesium(
    mapboxMap, 
    anyMap, 
    { initFrom: 'mapbox', direction: 0, anyAllowPitch: false }
)

mapbox-gl和任意地图双屏同步

简单易用的同时,也得考虑更多的可能性,如何支持更多地图联动?如leaflet、openlayers、cesium、ArcGIS等等。为解决以上问题,mapboxgl-syncto-any提供了更底层的通用方法:

mapViewSync(mapboxContext, anyContext, options)

options参数和mapboxViewSyncWithCesium方法一致,mapboxContext和anyContext的类型为:

export interface AnyContext<T> {
    map: T;
    Handler: EventHandlerConstructor<T>
}

map为地图实体,而Handler为实现视图同步的处理器,高扩展性的核心也在EventHandlerConstructor<T>,其定义为:

export interface EventHandlerConstructor<T> {
    new (params: EventHandlerParams<T>): IEventHandler;
};

简单说,你需要提供一个Class,构造函数参数为 EventHandlerParams<T>,需要实现IEventHandler规定的方法,具体有:

export interface IEventHandler {
    // 当启动移动时,以当前地图为触发源
    moveStart: (e?: any) => void;
    // 当停止移动时,触发视图参数更新
    moveEnd: (e?: any) => void;
    // 对外方法,e为接收到的视图参数,方法体实现地图视角更新
    updateView(e: ViewUpdateEvent): void;   
    destroy(): void;
}

单看定义不是太好理解,这里以mapbox的Handler实现说明,代码一目了然:

import { Map, MapMouseEvent } from "mapbox-gl";
import { EventFrom, EventHandlerParams, IEventHandler, TriggerEvent, ViewUpdateEvent } from "./types";
import _ from 'lodash';
import { getElevationByZoom, getZoomByElevation } from "./utils";

/***
 * Mapbox视图同步处理器
 */
export class MapboxEventHander<T extends Map> implements IEventHandler {
    private viewer: Map;
    private onTrigger: (e: TriggerEvent) => void;
    private onUpdateView: (e: ViewUpdateEvent) => void;
    private getFrom: () => EventFrom;


    constructor(params: EventHandlerParams<T>) {
        this.viewer = params.map;
        // onTrigger给外部通知,哪个地图源出发了视图更新
        this.onTrigger = params.onTrigger;
        this.getFrom = params.getFrom;
        this.onUpdateView = params.onUpdateView;
        this.viewer.on('mousedown', (this.moveStart = this.moveStart.bind(this)));
        this.viewer.on('move', (this.moveEnd = this.moveEnd.bind(this)));
    }

    moveStart(e: MapMouseEvent) {
        // 给外部通知mapbox触发了视图更新
        this.onTrigger({...e, eventFrom: EventFrom.Mapbox });
    }

    moveEnd() {
        if (this.getFrom() !== EventFrom.Mapbox) {
            return
        }
        // 获取当前地图视图的参数:pitch、zoom、center、elevation
        const pitch = this.viewer.getPitch();
        const { lng, lat } = this.viewer.getCenter();
        const zoom = this.viewer.getZoom();
        const elevation = getElevationByZoom(this.viewer, zoom);
        let bearing = this.viewer.getBearing();
        if (bearing < 0) {
            bearing = bearing + 360;
        }
        // 通知外部,由mapbox触发的视图更新结束
        this.onUpdateView({
            eventFrom: this.getFrom(),
            center: [lng, lat],
            zoom,
            elevation,
            pitch, 
            bearing
        });
    }

    // 外部调用,更新mapbox地图视图参数
    updateView(e: ViewUpdateEvent) {
        const { elevation, center, bearing, pitch } = e;
        if (_.isNumber(elevation)) {
            const zoom = getZoomByElevation(this.viewer, elevation);
            this.viewer.setZoom(zoom);
        }
        if (center) {
            this.viewer.setCenter(center);
        }
        if (_.isNumber(bearing)) {
            this.viewer.setBearing(bearing);
        }
        if (_.isNumber(pitch)) {
            this.viewer.setPitch(pitch);
        }
    }

    destroy() {
        this.viewer.off('mousedown', this.moveStart);
        this.viewer.off('move', this.moveEnd);
    }
}

知其所以然

2D地图和3D地图视图参数存在差异,mapbox-gl视图参数有center、zoom、bearing、pitch,而3D地图视图参数有center、elevation(海拔)、bearing、pitch,差异点在2D的zoom和3D地图的elevation,这两个参数之间如何互转?这也算整个组件最棘手的问题。

zoom -> elevation

短短几行代码,但其结果经过了几次视图换算。

export function getElevationByZoom(viewer: Map, zoom: number) {
    const circumference = 2 * Math.PI * earthHalfAxisLength;
	return circumference / Math.pow(2, zoom) / 2 / Math.tan(viewer.transform._fov / 2);
}

image.png
在三维场景中,视场角fov和视图高度height、摄像机距离elevation,三者之间的关系有

tan(fov / 2) = (height / 2) / elevation

可得出

elevation = height / 2 / tan(fov / 2)

fov可根据viewer.transform._fov获取,那关键是计算出height,height在三维中表示视图范围内垂直方向的视图长度(米)。

上述等式中 circumference / Math.pow(2, zoom)等价于height值,其中circumference表示地球周长,至于为什么` circumference / Math.pow(2, zoom)`能表示height,对我来说目前还是个难题,有懂的老大帮忙解答下

假如现在计算出elevation,要让cesium能同步至相同位置,还得进一步计算。cesium视图设置代码如下:

this.viewer.camera.lookAt(
	lookTarget,
	new HeadingPitchRange(heading, cameraPitch, range * Math.sin(((90 - pitch) * Math.PI) / 180))
);

其中除了range其他都是已知条件,range表示什么?
image.png
range表示摄像头到中心点center的距离,而pitch表示俯视角,根据三角关系可得range = elevation / sin(pitch)。到此,三维视图同步需要的参数都准备就绪。

反过来,三维视图变化通知二维更新,重点是elevation到zoom的转换,把上述流程反过来计算即可。

参考资料:

  1. Zoom Level To/From Altitude Conversions
  2. cesium-mapboxgl-syncamera

我的开源项目:

  1. react-native-mapa, react native地图组件库
  2. mapboxgl-syncto-any,三维地图双屏联动
posted @ 2024-07-26 01:53  heavi  阅读(25)  评论(0编辑  收藏  举报