UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

459 lines (387 loc) 25.9 kB
import Point from '@mapbox/point-geometry'; import {cameraBoundsWarning, type CameraForBoxAndBearingHandlerResult, type EaseToHandlerResult, type EaseToHandlerOptions, type FlyToHandlerResult, type FlyToHandlerOptions, type ICameraHelper, type MapControlsDeltas, updateRotation, type UpdateRotationArgs, cameraForBoxAndBearing} from './camera_helper'; import {LngLat, type LngLatLike} from '../lng_lat'; import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment, globeDistanceOfLocationsPixels, interpolateLngLatForGlobe} from './globe_utils'; import {clamp, createVec3f64, differenceOfAnglesDegrees, MAX_VALID_LATITUDE, remapSaturate, rollPitchBearingEqual, scaleZoom, warnOnce, zoomScale} from '../../util/util'; import {type mat4, vec3} from 'gl-matrix'; import {normalizeCenter} from '../transform_helper'; import {interpolates} from '@maplibre/maplibre-gl-style-spec'; import type {IReadonlyTransform, ITransform} from '../transform_interface'; import type {CameraForBoundsOptions} from '../../ui/camera'; import type {LngLatBounds} from '../lng_lat_bounds'; import type {PaddingOptions} from '../edge_insets'; /** * @internal */ export class VerticalPerspectiveCameraHelper implements ICameraHelper { get useGlobeControls(): boolean { return true; } handlePanInertia(pan: Point, transform: IReadonlyTransform): { easingCenter: LngLat; easingOffset: Point; } { const panCenter = computeGlobePanCenter(pan, transform); if (Math.abs(panCenter.lng - transform.center.lng) > 180) { // If easeTo target would be over 180° distant, the animation would move // in the opposite direction that what the user intended. // Thus we clamp the movement to 179.5°. panCenter.lng = transform.center.lng + 179.5 * Math.sign(panCenter.lng - transform.center.lng); } return { easingCenter: panCenter, easingOffset: new Point(0, 0), }; } handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void { const zoomPixel = deltas.around; const zoomLoc = tr.screenPointToLocation(zoomPixel); if (deltas.bearingDelta) tr.setBearing(tr.bearing + deltas.bearingDelta); if (deltas.pitchDelta) tr.setPitch(tr.pitch + deltas.pitchDelta); if (deltas.rollDelta) tr.setRoll(tr.roll + deltas.rollDelta); const oldZoomPreZoomDelta = tr.zoom; if (deltas.zoomDelta) tr.setZoom(tr.zoom + deltas.zoomDelta); const actualZoomDelta = tr.zoom - oldZoomPreZoomDelta; if (actualZoomDelta === 0) { return; } // Problem: `setLocationAtPoint` for globe works when it is called a single time, but is a little glitchy in practice when used repeatedly for zooming. // - `setLocationAtPoint` repeatedly called at a location behind a pole will eventually glitch out // - `setLocationAtPoint` at location the longitude of which is more than 90° different from current center will eventually glitch out // But otherwise works fine at higher zooms, or when the target is somewhat near the current map center. // Solution: use a heuristic zooming in the problematic cases and interpolate to `setLocationAtPoint` when possible. // Magic numbers that control: // - when zoom movement slowing starts for cursor not on globe (avoid unnatural map movements) // - when we interpolate from exact zooming to heuristic zooming based on longitude difference of target location to current center // - when we interpolate from exact zooming to heuristic zooming based on globe being too small on screen // - when zoom movement slowing starts for globe being too small on viewport (avoids unnatural/unwanted map movements when map is zoomed out a lot) const raySurfaceDistanceForSlowingStart = 0.3; // Zoom movement slowing will start when the planet surface to ray distance is greater than this number (globe radius is 1, so 0.3 is ~2000km form the surface). const slowingMultiplier = 0.5; // The lower this value, the slower will the "zoom movement slowing" occur. const interpolateToHeuristicStartLng = 45; // When zoom location longitude is this many degrees away from map center, we start interpolating from exact zooming to heuristic zooming. const interpolateToHeuristicEndLng = 85; // Longitude difference at which interpolation to heuristic zooming ends. const interpolateToHeuristicExponent = 0.25; // Makes interpolation smoother. const interpolateToHeuristicStartRadius = 0.75; // When globe is this many times larger than the smaller viewport dimension, we start interpolating from exact zooming to heuristic zooming. const interpolateToHeuristicEndRadius = 0.35; // Globe size at which interpolation to heuristic zooming ends. const slowingRadiusStart = 0.9; // If globe is this many times larger than the smaller viewport dimension, start inhibiting map movement while zooming const slowingRadiusStop = 0.5; const slowingRadiusSlowFactor = 0.25; // How much is movement slowed when globe is too small const dLngRaw = differenceOfAnglesDegrees(tr.center.lng, zoomLoc.lng); const dLng = dLngRaw / (Math.abs(dLngRaw / 180) + 1.0); // This gradually reduces the amount of longitude change if the zoom location is very far, eg. on the other side of the pole (possible when looking at a pole). const dLat = differenceOfAnglesDegrees(tr.center.lat, zoomLoc.lat); // Slow zoom movement down if the mouse ray is far from the planet. const rayDirection = tr.getRayDirectionFromPixel(zoomPixel); const rayOrigin = tr.cameraPosition; const distanceToClosestPoint = vec3.dot(rayOrigin, rayDirection) * -1; // Globe center relative to ray origin is equal to -rayOrigin and rayDirection is normalized, thus we want to compute dot(-rayOrigin, rayDirection). const closestPoint = createVec3f64(); vec3.add(closestPoint, rayOrigin, [ rayDirection[0] * distanceToClosestPoint, rayDirection[1] * distanceToClosestPoint, rayDirection[2] * distanceToClosestPoint ]); const distanceFromSurface = vec3.length(closestPoint) - 1; const distanceFactor = Math.exp(-Math.max(distanceFromSurface - raySurfaceDistanceForSlowingStart, 0) * slowingMultiplier); // Slow zoom movement down if the globe is too small on viewport const radius = getGlobeRadiusPixels(tr.worldSize, tr.center.lat) / Math.min(tr.width, tr.height); // Radius relative to larger viewport dimension const radiusFactor = remapSaturate(radius, slowingRadiusStart, slowingRadiusStop, 1.0, slowingRadiusSlowFactor); // Compute how much to move towards the zoom location const factor = (1.0 - zoomScale(-actualZoomDelta)) * Math.min(distanceFactor, radiusFactor); const oldCenterLat = tr.center.lat; const oldZoom = tr.zoom; const heuristicCenter = new LngLat( tr.center.lng + dLng * factor, clamp(tr.center.lat + dLat * factor, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE) ); // Now compute the map center exact zoom tr.setLocationAtPoint(zoomLoc, zoomPixel); const exactCenter = tr.center; // Interpolate between exact zooming and heuristic zooming depending on the longitude difference between current center and zoom location. const interpolationFactorLongitude = remapSaturate(Math.abs(dLngRaw), interpolateToHeuristicStartLng, interpolateToHeuristicEndLng, 0, 1); const interpolationFactorRadius = remapSaturate(radius, interpolateToHeuristicStartRadius, interpolateToHeuristicEndRadius, 0, 1); const heuristicFactor = Math.pow(Math.max(interpolationFactorLongitude, interpolationFactorRadius), interpolateToHeuristicExponent); const lngExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lng, heuristicCenter.lng); const latExactToHeuristic = differenceOfAnglesDegrees(exactCenter.lat, heuristicCenter.lat); tr.setCenter(new LngLat( exactCenter.lng + lngExactToHeuristic * heuristicFactor, exactCenter.lat + latExactToHeuristic * heuristicFactor ).wrap()); tr.setZoom(oldZoom + getZoomAdjustment(oldCenterLat, tr.center.lat)); } handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, _preZoomAroundLoc: LngLat): void { if (!deltas.panDelta) { return; } // These are actually very similar to mercator controls, and should converge to them at high zooms. // We avoid using the "grab a place and move it around" approach from mercator here, // since it is not a very pleasant way to pan a globe. const oldLat = tr.center.lat; const oldZoom = tr.zoom; tr.setCenter(computeGlobePanCenter(deltas.panDelta, tr).wrap()); // Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat)); } cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform): CameraForBoxAndBearingHandlerResult { const result = cameraForBoxAndBearing(options, padding, bounds, bearing, tr); // If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds. // Get clip space bounds including padding const xLeft = (padding.left) / tr.width * 2.0 - 1.0; const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0; const yTop = (padding.top) / tr.height * -2.0 + 1.0; const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0; // Get camera bounds const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0; const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest(); const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast(); const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north... const latSouth = Math.min(bounds.getNorth(), bounds.getSouth()); // Additional vectors will be tested for the rectangle midpoints const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5; const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5; // Obtain a globe projection matrix that does not include pitch (unsupported) const clonedTr = tr.clone(); clonedTr.setCenter(result.center); clonedTr.setBearing(result.bearing); clonedTr.setPitch(0); clonedTr.setRoll(0); clonedTr.setZoom(result.zoom); const matrix = clonedTr.modelViewProjectionMatrix; // Vectors to test - the bounds' corners and edge midpoints const testVectors = [ angularCoordinatesToSurfaceVector(bounds.getNorthWest()), angularCoordinatesToSurfaceVector(bounds.getNorthEast()), angularCoordinatesToSurfaceVector(bounds.getSouthWest()), angularCoordinatesToSurfaceVector(bounds.getSouthEast()), // Also test edge midpoints angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)), angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)), angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)), angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth)) ]; const vecToCenter = angularCoordinatesToSurfaceVector(result.center); // Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space. let smallestNeededScale = Number.POSITIVE_INFINITY; for (const vec of testVectors) { if (xLeft < 0) smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft)); if (xRight > 0) smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight)); if (yTop > 0) smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop)); if (yBottom < 0) smallestNeededScale = VerticalPerspectiveCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, VerticalPerspectiveCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom)); } if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) { cameraBoundsWarning(); return undefined; } // Compute target zoom from the obtained scale. result.zoom = clonedTr.zoom + scaleZoom(smallestNeededScale); return result; } /** * Handles the zoom and center change during camera jumpTo. */ handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void { // Special zoom & center handling for globe: // Globe constrained center isn't dependent on zoom level const startingLat = tr.center.lat; const constrainedCenter = tr.getConstrained(options.center ? LngLat.convert(options.center) : tr.center, tr.zoom).center; tr.setCenter(constrainedCenter.wrap()); // Make sure to compute correct target zoom level if no zoom is specified const targetZoom = (typeof options.zoom !== 'undefined') ? +options.zoom : (tr.zoom + getZoomAdjustment(startingLat, constrainedCenter.lat)); if (tr.zoom !== targetZoom) { tr.setZoom(targetZoom); } } handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult { const startZoom = tr.zoom; const startCenter = tr.center; const startPadding = tr.padding; const startEulerAngles = {roll: tr.roll, pitch: tr.pitch, bearing: tr.bearing}; const endRoll = options.roll === undefined ? tr.roll : options.roll; const endPitch = options.pitch === undefined ? tr.pitch : options.pitch; const endBearing = options.bearing === undefined ? tr.bearing : options.bearing; const endEulerAngles = {roll: endRoll, pitch: endPitch, bearing: endBearing}; const optionsZoom = typeof options.zoom !== 'undefined'; const doPadding = !tr.isPaddingEqual(options.padding); let isZooming = false; // Globe needs special handling for how zoom should be animated. // 1) if zoom is set, ease to the given mercator zoom // 2) if neither is set, assume constant apparent zoom (constant planet size) is to be kept const preConstrainCenter = options.center ? LngLat.convert(options.center) : startCenter; const constrainedCenter = tr.getConstrained( preConstrainCenter, startZoom // zoom can be whatever at this stage, it should not affect anything if globe is enabled ).center; normalizeCenter(tr, constrainedCenter); const clonedTr = tr.clone(); clonedTr.setCenter(constrainedCenter); clonedTr.setZoom(optionsZoom ? +options.zoom : startZoom + getZoomAdjustment(startCenter.lat, preConstrainCenter.lat)); clonedTr.setBearing(options.bearing); const clampedPoint = new Point( clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) ); clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); // Find final animation targets const endCenterWithShift = (options.offset && options.offsetAsPoint.mag()) > 0 ? clonedTr.center : constrainedCenter; const endZoomWithShift = optionsZoom ? +options.zoom : startZoom + getZoomAdjustment(startCenter.lat, endCenterWithShift.lat); // Planet radius for a given zoom level differs according to latitude // Convert zooms to what they would be at equator for the given planet radius const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); const normalizedEndZoom = endZoomWithShift + getZoomAdjustment(endCenterWithShift.lat, 0); const deltaLng = differenceOfAnglesDegrees(startCenter.lng, endCenterWithShift.lng); const deltaLat = differenceOfAnglesDegrees(startCenter.lat, endCenterWithShift.lat); const finalScale = zoomScale(normalizedEndZoom - normalizedStartZoom); isZooming = (endZoomWithShift !== startZoom); const easeFunc = (k: number) => { if (!rollPitchBearingEqual(startEulerAngles, endEulerAngles)) { updateRotation({ startEulerAngles, endEulerAngles, tr, k, useSlerp: startEulerAngles.roll != endEulerAngles.roll} as UpdateRotationArgs); } if (doPadding) { tr.interpolatePadding(startPadding, options.padding,k); } if (options.around) { warnOnce('Easing around a point is not supported under globe projection.'); tr.setLocationAtPoint(options.around, options.aroundPoint); } else { const base = normalizedEndZoom > normalizedStartZoom ? Math.min(2, finalScale) : Math.max(0.5, finalScale); const speedup = Math.pow(base, 1 - k); const factor = k * speedup; // Spherical lerp might be used here instead, but that was tested and it leads to very weird paths when the interpolated arc gets near the poles. // Instead we interpolate LngLat almost directly, but taking into account that // one degree of longitude gets progressively smaller relative to latitude towards the poles. const newCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, factor); tr.setCenter(newCenter.wrap()); } if (isZooming) { const normalizedInterpolatedZoom = interpolates.number(normalizedStartZoom, normalizedEndZoom, k); const interpolatedZoom = normalizedInterpolatedZoom + getZoomAdjustment(0, tr.center.lat); tr.setZoom(interpolatedZoom); } }; return { easeFunc, isZooming, elevationCenter: endCenterWithShift, }; } handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult { const optionsZoom = typeof options.zoom !== 'undefined'; const startCenter = tr.center; const startZoom = tr.zoom; const startPadding = tr.padding; const doPadding = !tr.isPaddingEqual(options.padding); // Obtain target center and zoom const constrainedCenter = tr.getConstrained( LngLat.convert(options.center || options.locationAtOffset), startZoom ).center; const targetZoom = optionsZoom ? +options.zoom : tr.zoom + getZoomAdjustment(tr.center.lat, constrainedCenter.lat); // Compute target center that respects offset by creating a temporary transform and calling its `setLocationAtPoint`. const clonedTr = tr.clone(); clonedTr.setCenter(constrainedCenter); clonedTr.setZoom(targetZoom); clonedTr.setBearing(options.bearing); const clampedPoint = new Point( clamp(tr.centerPoint.x + options.offsetAsPoint.x, 0, tr.width), clamp(tr.centerPoint.y + options.offsetAsPoint.y, 0, tr.height) ); clonedTr.setLocationAtPoint(constrainedCenter, clampedPoint); const targetCenter = clonedTr.center; normalizeCenter(tr, targetCenter); const pixelPathLength = globeDistanceOfLocationsPixels(tr, startCenter, targetCenter); const normalizedStartZoom = startZoom + getZoomAdjustment(startCenter.lat, 0); const normalizedTargetZoom = targetZoom + getZoomAdjustment(targetCenter.lat, 0); const scaleOfZoom = zoomScale(normalizedTargetZoom - normalizedStartZoom); const optionsMinZoom = typeof options.minZoom === 'number'; let scaleOfMinZoom: number; if (optionsMinZoom) { const normalizedOptionsMinZoom = +options.minZoom + getZoomAdjustment(targetCenter.lat, 0); const normalizedMinZoomPreConstrain = Math.min(normalizedOptionsMinZoom, normalizedStartZoom, normalizedTargetZoom); const minZoomPreConstrain = normalizedMinZoomPreConstrain + getZoomAdjustment(0, targetCenter.lat); const minZoom = tr.getConstrained(targetCenter, minZoomPreConstrain).zoom; const normalizedMinZoom = minZoom + getZoomAdjustment(targetCenter.lat, 0); scaleOfMinZoom = zoomScale(normalizedMinZoom - normalizedStartZoom); } const deltaLng = differenceOfAnglesDegrees(startCenter.lng, targetCenter.lng); const deltaLat = differenceOfAnglesDegrees(startCenter.lat, targetCenter.lat); const easeFunc = (k: number, scale: number, centerFactor: number, _pointAtOffset: Point) => { const interpolatedCenter = interpolateLngLatForGlobe(startCenter, deltaLng, deltaLat, centerFactor); if (doPadding) { tr.interpolatePadding(startPadding, options.padding,k); } const newCenter = k === 1 ? targetCenter : interpolatedCenter; tr.setCenter(newCenter.wrap()); const interpolatedZoom = normalizedStartZoom + scaleZoom(scale); tr.setZoom(k === 1 ? targetZoom : (interpolatedZoom + getZoomAdjustment(0, newCenter.lat))); }; return { easeFunc, scaleOfZoom, targetCenter, scaleOfMinZoom, pixelPathLength, }; } /** * Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis. * @param vector - Position of the queried location on the surface of the unit sphere globe. * @param toCenter - Position of current transform center on the surface of the unit sphere globe. * This is needed because zooming the globe not only changes its scale, * but also moves the camera closer or further away along this vector (pitch is disregarded). * @param projection - The globe projection matrix. * @param targetDimension - The dimension in which the scaled vector must match the target value in clip space. * @param targetValue - The target clip space value in the specified dimension to which the queried vector must project. * @returns How much to scale the globe. */ private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null { // We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y). const k = targetValue; const columnXorY = targetDimension === 'x' ? [projection[0], projection[4], projection[8], projection[12]] : // X [projection[1], projection[5], projection[9], projection[13]]; // Y const columnZ = [projection[3], projection[7], projection[11], projection[15]]; const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2]; const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2]; const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2]; const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2]; // The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t". // Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C". const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ); if ( toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ || columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ ) { // The computed result is invalid. return null; } return t; } /** * Returns `newValue` if it is: * * - not null AND * - not negative AND * - smaller than `newValue`, * * ...otherwise returns `oldValue`. */ private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number { if (newValue !== null && newValue >= 0 && newValue < oldValue) { return newValue; } else { return oldValue; } } }