前端系列(1),mapbox 与 cesium 视野联动

2,895 阅读6分钟

背景是这样的,公司制作了 2d 和 3d 的地图数据供客户使用,要求在预览界面可以让用户自己灵活切换 2d/3d 查看地图效果。

如果有读者接触过 ol-cesium 这个库的话,没错 想要的,就是那样的效果。

因为我们项目 2d 地图是使用的 mapbox,与 ol-cesium 采用的 openlayer 不同,所以只能研究 ol-cesium 的源码,来看下其 2d/3d 丝滑切换的原理。

mapbox to cesium

我们知道决定 2d 地图视野的因素可以是:中心点和 zoom-level, 3d 视野可以是:中心点和 camera 高度。

所以从 2d 到 3d 的难点是,如何将 2d 的 zoom-level 转换成 3d 需要的 camera range。

经过一番 google, 发现目前主流的方案主要就两种:

  • 直接通过 2d zoom 转 相机高度。
  • 拿 2d 视野边界,给到 cesium,让其自动计算相机高度,这也是 cesium api 支持的。

但算法误差都比较大,实际效果较差,满足不了线上使用要求。所以才有了这篇记录,当然仁者见仁,智者见智,并不是说本篇方案就好了,只是总结下,给后来者提供一点思路。

  1. 通过 2d zoom-level 获取分辨率
  2. 根据 分辨率 求 相机高度

zoom -> resolution

resolution 顾名思义是分辨率,分辨率是屏幕坐标和世界坐标的纽带,通过它,才能知道你在屏幕上用鼠标点击的位置对应于世界地图具体的经纬度位置。

mapbox 和 openlayer 默认采用的都是墨卡托(Mercator)投影坐标系,经过投影后,整个地球是一个正方形,所能表示的地球范围为经度[-180, 180],纬度[-85, 85],单位为度。 对应的墨卡托坐标系的范围x[-20037508.3427892, 20037508.3427892],范围y同样是[-20037508.3427892, 20037508.3427892],单位为m。

求分辨率公式我直接给给出了,具体过程参考 这篇文章

  const rangeX = 20037508.3427892 - (-20037508.3427892);
  const resolution = rangeX / (256 * Math.pow(2, zoom));

另一种方法:

  getResolutionForZoom = (zoom) => {
    const defaultTileSize = 256;
    const zoomFactor = 2;
    const metersPreUnit = 1; // 默认墨卡托投影,为1,如果地图设置其他投影,这个值会变。当然 metersPreUnit 是一个很大的简化,它没有考虑椭球体/地形,这也导致此算法并不是特别准确。
    const size = (2 * Math.PI * 6370997) / metersPreUnit;
    const maxResolution = size / defaultTileSize;
    const resolution = Math.pow(Math.E, Math.log(maxResolution) - ((zoom + 1) * Math.log(zoomFactor)));
    return resolution;
  }

还有一种方法,是参考 openlayer 求的,也是我目前采用的方法:

  getResolutionForZoom = (zoom) => {
    let resolutions = this.getAllResolutions();
    const baseLevel = this.clamp(Math.floor(zoom), 0, resolutions.length - 2);
    const zoomFactor = resolutions[baseLevel] / resolutions[baseLevel + 1];
    return (resolutions[baseLevel] / Math.pow(zoomFactor, this.clamp(zoom - baseLevel, 0, 1)));
  }

  // 0-21级的分辨率列表
  getAllResolutions () {
    return [156543.033928041, 78271.51696402048, 39135.758482010235,
      19567.87924100512, 9783.93962050256, 4891.96981025128, 2445.98490512564,
      1222.99245256282, 611.49622628141, 305.7481131407048, 152.8740565703525,
      76.43702828517624, 38.21851414258813, 19.10925707129406,
      9.554628535647032, 4.777314267823516, 2.388657133911758,
      1.194328566955879, 0.5971642834779395, 0.2985821417389697,
      0.1492910708694849, 0.0746455354347424];
  }

resolution -> camera distance

有了分辨率,就可以根据 openlayer 那样直接求相机高度了,算法如下:

参数里的 latitude 是中心点的纬度,由于 metersPerUnit 不考虑纬度,但它正常应该随着纬度的增加而降低,所以这里给了补偿。

参数的 viewer 是 cesium 内的对象,就不解释了,如果遇到 canvas.clientHeight 拿不到值的情况,可以通过 document 计算 cesium 视图高度做兜底。

要注意的是,再求 relativeCircumference 时,需要将 latitude 转为弧度,这是一个坑。

  calcDistanceForCamera = (latitude, zoom, viewer) => {
    const resolution = this.getResolutionForZoom(zoom + 1);
    const canvas = viewer.scene.canvas;
    const camera = viewer.scene.camera;
    const fovy = camera.frustum.fovy; // vertical field of view
    const metersPerUnit = 1;
    const visibleMapUnits = resolution * canvas.clientHeight;
    const relativeCircumference = Math.cos(Math.abs(Cesium.Math.toRadians(latitude)));
    const visibleMeters = visibleMapUnits * metersPerUnit * relativeCircumference;
    const requiredDistance = (visibleMeters / 2) / Math.tan(fovy / 2);
    return requiredDistance;
  }

camera#lookAt

中心点 mapbox 可以直接获取,再加上 camera distance,可以直接确定 cesium 视野了:

    const positions = [Cesium.Cartographic.fromDegrees(center[0], center[1])];
    const target = new Cesium.Cartesian3.fromDegrees(center[0], center[1], positions[0].height);
    const heading = Cesium.Math.toRadians(map_rotate);
    const pitch = Cesium.Math.toRadians(-90.0); // 这个俯仰角度目前这里写死了
    viewer.camera.lookAt(target, new Cesium.HeadingPitchRange(heading, pitch, range));
    viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);

当然也可以使用 Cesium 提供的 moveBackward 接口,也是我目前采用的:

    const carto = new Cesium.Cartographic(Cesium.Math.toRadians(center[0]),Cesium.Math.toRadians(center[1]));
    if (viewer.scene.globe) {
      const height = viewer.scene.globe.getHeight(carto);
      carto.height = height || 0;
    }

    const destination = Cesium.Ellipsoid.WGS84.cartographicToCartesian(carto);

    const orientation = {
      pitch: pitch,
      heading: heading,
      roll: undefined
    };
    viewer.camera.setView({
      destination,
      orientation
    });
    viewer.camera.moveBackward(range);

好了,2d 到 3d 的视野联动就这样了。

cesium to mapbox

从 3d 到 2d 的思路反推下即可:

  1. 根据相机高度获取分辨率
  2. 根据分辨率得到 2d zoom-level

其中主要参考了 这篇文章,这里我就简单写了:

export function updateView(viewer, viewOption = {}) {
  // Obtain Cesium's field of view center as the target point
  const ellipsoid = Cesium.Ellipsoid.WGS84;
  const scene = viewer.scene;
  const target = pickCenterPoint(scene);

  // If the center of view is not obtained, take the position of the camera as the target point
  let bestTarget = target;
  if (!bestTarget) {
    const globe = scene.globe;
    const carto = viewer.camera.positionCartographic.clone();
    const height = globe.getHeight(carto);
    carto.height = height || 0;
    bestTarget = Cesium.Ellipsoid.WGS84.cartographicToCartesian(carto);
  }

  // the distance from camera to target point
  const distance = Cesium.Cartesian3.distance(bestTarget, viewer.camera.position);
  const bestTargetCartographic = ellipsoid.cartesianToCartographic(bestTarget);

  // EPSG:4326 (WGS84)​ & ​EPSG:3857(Pseudo-Mercator)​
  // const mercatorCenter = transform([(bestTargetCartographic.longitude * 180) / Math.PI, (bestTargetCartographic.latitude * 180) / Math.PI], 'EPSG:4326', 'EPSG:3857');
  // const center = convertMercatorToLatlng(mercatorCenter);
  const center = [(bestTargetCartographic.longitude * 180) / Math.PI, (bestTargetCartographic.latitude * 180) / Math.PI];

  // Calculate the resolution of Cesium and set ol to the same resolution
  const latitude = bestTargetCartographic ? bestTargetCartographic.latitude : 0;
  const resolution = calcResolutionForDistance(distance, latitude, scene);
  const zoom = getZoomForResolution(resolution);

  // Calculate the rotation angle of Cesium
  let rotation;
  if (!target) {
    rotation = viewer.camera.heading;
  } else {
    rotation = calcRotation(viewer.camera, target, ellipsoid);
  }
  const bearing = (-rotation * 180) / Math.PI;
  return {
    center: center,
    zoom: zoom,
    bearing: bearing,
  };
}

根据相机高度计算 resolution 方法:

export function calcResolutionForDistance(distance, latitude, scene) {
  const canvas = scene.canvas;
  const camera = scene.camera;
  const fovy = camera.frustum.fovy;
  const metersPerUnit = METERS_PER_UNIT.m;
  const visibleMeters = 2 * distance * Math.tan(fovy / 2);
  const relativeCircumference = Math.cos(Math.abs(latitude));
  const visibleMapUnits = visibleMeters / metersPerUnit / relativeCircumference;
  const resolution = visibleMapUnits / canvas.clientHeight;
  return resolution;
}

根据分辨率计算 zoom-level 方法见下:

export function getZoomForResolution(resolution) {
  const defaultTileSize = 256;
  const zoomFactor = 2;
  const minZoom = 0;
  const size = (360 * METERS_PER_UNIT.degrees) / METERS_PER_UNIT.m;
  const defaultMaxResolution =
    size / defaultTileSize / Math.pow(zoomFactor, minZoom);
  const maxResolution = defaultMaxResolution / Math.pow(zoomFactor, minZoom);
  const zoom = Math.log(maxResolution / resolution) / Math.log(zoomFactor) - 1;
  return zoom;
}

这是计算 bearing 方法,注意 mapbox 使用的话需要转换下:

export function calcRotation(camera, target, ellipsoid) {
  const pos = camera.position;
  const targetNormal = new Cesium.Cartesian3();
  ellipsoid.geocentricSurfaceNormal(target, targetNormal);
  const targetToCamera = new Cesium.Cartesian3();
  Cesium.Cartesian3.subtract(pos, target, targetToCamera);
  Cesium.Cartesian3.normalize(targetToCamera, targetToCamera);
  const up = camera.up;
  const right = camera.right;
  const normal = new Cesium.Cartesian3(-target.y, target.x, 0);
  const heading = Cesium.Cartesian3.angleBetween(right, normal);
  const cross = Cesium.Cartesian3.cross(target, up, new Cesium.Cartesian3());
  const orientation = cross.z;
  return orientation < 0 ? heading : -heading;
}

这是涉及的工具方法:

export function convertMercatorToLatlng(mercator) {
  const lng = mercator[0] / 20037508.34 * 180;
  let mmy = mercator[1] / 20037508.34 * 180;
  const lat = 180 / Math.PI * (2 * Math.atan(Math.exp(mmy * Math.PI / 180)) - Math.PI / 2);
  return [lng, lat];
}

export function pickCenterPoint(scene) {
  const canvas = scene.canvas;
  const center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2);
  return pickOnTerrainOrEllipsoid(scene, center);
}

export function pickOnTerrainOrEllipsoid(scene, pixel) {
  const ray = scene.camera.getPickRay(pixel);
  const target = scene.globe.pick(ray, scene);
  return target || scene.camera.pickEllipsoid(pixel);
}

export const METERS_PER_UNIT = {
  // use the radius of the Normal sphere
  'radians': 6370997 / (2 * Math.PI),
  'degrees': (2 * Math.PI * 6370997) / 360,
  'ft': 0.3048,
  'm': 1,
  'us-ft': 1200 / 3937,
};

由于数据是公司的,在这里就不贴效果图了,效果和 olcesesium 基本一样,毕竟代码实现都是参考的 olcesesium 的源码。

好了,本篇就结束了。

11.01更新:

在 2D 和 3D 切换过程中,会出现cesium一跳一跳的情况,体验不好,针对这种情况进行了一版优化。

思路也很简单,解决2D -> 3D 问题,就是每次 Maobox 在 moveend 后,都要同步一下 cesium,而不是只在点击 3D 按钮才去更新cesium。

这里的布局结构,我是 Mopbox 和 Cesium 是叠着的,通过 zoom-level 控制层级,避免 dom 每次切换销毁重建,否则 map resize 后,会有闪烁问题。

而在 3D -> 2D,就没有那么麻烦了,直接使用 mapbox.jumpTo 方法即可,去掉移动动画,就会有很好的效果,但是由于 Cesium 没有实时更新状态给到 Mapbox,会导致在 Cesium 下,旋转并改变窗口大小,旋转角度会被重置的 bug,这个问题的原因就在于,窗口变化,也会导致 Mapbox 的 moveend 监听被调用,解决方案见下:

  handleMapMoveEnd = (e) => {
    if (!e.originalEvent || e.originalEvent.type === 'resize') { // move type: [resize] [originalEvent = undefined]
      return;
    }
    const center = parseMapD2dCenter(this.mapBoxContainerRef.getMapInstance());
    const zoom = this.mapBoxContainerRef.getZoom();
    const bearing = this.mapBoxContainerRef.getBearing();
    const height = this.getMapCanvasHeight();
    this.set3DCameraCenter(center, zoom, bearing, height);
  }