diff --git a/debug/markers.html b/debug/markers.html index d1105ca03dc..4a994ab2472 100644 --- a/debug/markers.html +++ b/debug/markers.html @@ -21,6 +21,7 @@ Projection: Mercator + Globe Albers USA Equal Earth Equirectangular @@ -51,8 +52,8 @@ var w = bounds.getWest(); var e = bounds.getEast(); - var lng = w + (e - w) * (i / 3); - var lat = s + (n - s) * (j / 3); + var lng = w + (e - w) * ((i + .5) / 3); + var lat = s + (n - s) * ((j + .5) / 3); var popup = new mapboxgl.Popup().setHTML(`Pitch Alignment: ${pitchAlignment}Rotation Alignment: ${rotationAlignment}`); diff --git a/flow-typed/gl-matrix.js b/flow-typed/gl-matrix.js index 9267038324c..4dbbcde350f 100644 --- a/flow-typed/gl-matrix.js +++ b/flow-typed/gl-matrix.js @@ -41,7 +41,11 @@ declare module "gl-matrix" { lerp(T, Vec3, Vec3, number): T, transformQuat(T, Vec3, Quat): T, transformMat3(T, Vec3, Mat3): T, - transformMat4(T, Vec3, Mat4): T + transformMat4(T, Vec3, Mat4): T, + angle(Vec3, Vec3): number; + rotateX(T, Vec3, Vec3, number): T, + rotateY(T, Vec3, Vec3, number): T, + rotateZ(T, Vec3, Vec3, number): T, }; declare var vec4: { @@ -82,6 +86,8 @@ declare module "gl-matrix" { rotateX(T, Mat4, number): T, rotateY(T, Mat4, number): T, rotateZ(T, Mat4, number): T, + rotate(T, Mat4, number, Vec3): T, + fromRotation(T, number, Vec3): T, translate(T, Mat4, Vec3): T, invert(T, Mat4): T, copy(T, Mat4): T, diff --git a/src/geo/projection/far_z.js b/src/geo/projection/far_z.js index 2c1a96092db..9ced1d0fc0c 100644 --- a/src/geo/projection/far_z.js +++ b/src/geo/projection/far_z.js @@ -62,13 +62,13 @@ export function farthestPixelDistanceOnSphere(tr: Transform, pixelsPerMeter: num } else { // Background space is visible. Find distance to the point of the // globe where surface normal is parallel to the view vector - const p0 = vec3.sub([], cameraPosition, globeCenter); - const p1 = vec3.sub([], globeCenter, cameraPosition); - vec3.normalize(p1, p1); + const globeCenterToCamera = vec3.sub([], cameraPosition, globeCenter); + const cameraToGlobe = vec3.sub([], globeCenter, cameraPosition); + vec3.normalize(cameraToGlobe, cameraToGlobe); - const cameraHeight = vec3.length(p0) - globeRadius; - pixelDistance = Math.sqrt(cameraHeight * cameraHeight + 2 * globeRadius * cameraHeight); - const angle = Math.acos(pixelDistance / (globeRadius + cameraHeight)) - Math.acos(vec3.dot(forward, p1)); + const cameraHeight = vec3.length(globeCenterToCamera) - globeRadius; + pixelDistance = Math.sqrt(cameraHeight * (cameraHeight + 2 * globeRadius)); + const angle = Math.acos(pixelDistance / (globeRadius + cameraHeight)) - Math.acos(vec3.dot(forward, cameraToGlobe)); pixelDistance *= Math.cos(angle); } diff --git a/src/geo/projection/globe_util.js b/src/geo/projection/globe_util.js index 33b7aeacfa4..2797d53f9e0 100644 --- a/src/geo/projection/globe_util.js +++ b/src/geo/projection/globe_util.js @@ -10,7 +10,7 @@ import { import EXTENT from '../../data/extent.js'; import {number as interpolate} from '../../style-spec/util/interpolate.js'; import {degToRad, smoothstep, clamp} from '../../util/util.js'; -import {mat4, vec3} from 'gl-matrix'; +import {vec3, mat4} from 'gl-matrix'; import SegmentVector from '../../data/segment.js'; import {members as globeLayoutAttributes, atmosphereLayout} from '../../terrain/globe_attributes.js'; import posAttributes from '../../data/pos_attributes.js'; @@ -20,12 +20,14 @@ import LngLatBounds from '../lng_lat_bounds.js'; import type {CanonicalTileID, UnwrappedTileID} from '../../source/tile_id.js'; import type Context from '../../gl/context.js'; -import type {Mat4, Vec3} from 'gl-matrix'; +import type {Vec3, Mat4} from 'gl-matrix'; import type IndexBuffer from '../../gl/index_buffer.js'; import type VertexBuffer from '../../gl/vertex_buffer.js'; import type Transform from '../transform.js'; +import Point from '@mapbox/point-geometry'; export const GLOBE_RADIUS = EXTENT / Math.PI / 2.0; +const GLOBE_METERS_TO_ECEF = mercatorZfromAltitude(1, 0.0) * 2.0 * GLOBE_RADIUS * Math.PI; const GLOBE_NORMALIZATION_BIT_RANGE = 15; const GLOBE_NORMALIZATION_MASK = (1 << (GLOBE_NORMALIZATION_BIT_RANGE - 1)) - 1; const GLOBE_VERTEX_GRID_SIZE = 64; @@ -408,6 +410,47 @@ const POLE_RAD = degToRad(85.0); const POLE_COS = Math.cos(POLE_RAD); const POLE_SIN = Math.sin(POLE_RAD); +function cameraPositionInECEF(tr: Transform): Array { + // Here "center" is the center of the globe. We refer to transform._center + // (the surface of the map on the center of the screen) as "pivot" to avoid confusion. + const centerToPivot = latLngToECEF(tr._center.lat, tr._center.lng); + + // Set axis to East-West line tangent to sphere at pivot + const south = vec3.fromValues(0, 1, 0); + let axis = vec3.cross([], south, centerToPivot); + + // Rotate axis around pivot by bearing + const rotation = mat4.fromRotation([], -tr.angle, centerToPivot); + axis = vec3.transformMat4(axis, axis, rotation); + + // Rotate camera around axis by pitch + mat4.fromRotation(rotation, -tr._pitch, axis); + + const pivotToCamera = vec3.normalize([], centerToPivot); + vec3.scale(pivotToCamera, pivotToCamera, tr.cameraToCenterDistance / tr.pixelsPerMeter * GLOBE_METERS_TO_ECEF); + vec3.transformMat4(pivotToCamera, pivotToCamera, rotation); + + return vec3.add([], centerToPivot, pivotToCamera); +} + +// Return the angle of the normal vector of the sphere relative to the camera at a screen point. +// i.e. how much to tilt map-aligned markers. +export function globeTiltAtScreenPoint(tr: Transform, point: Point): number { + const lngLat = tr.pointLocation(point); + const centerToPoint = latLngToECEF(lngLat.lat, lngLat.lng); + const centerToCamera = cameraPositionInECEF(tr); + const pointToCamera = vec3.subtract([], centerToCamera, centerToPoint); + return vec3.angle(pointToCamera, centerToPoint); +} + +export function globeCenterToScreenPoint(tr: Transform): Point { + const pos = [0, 0, 0]; + const matrix = mat4.identity(new Float64Array(16)); + mat4.multiply(matrix, tr.pixelMatrix, tr.globeMatrix); + vec3.transformMat4(pos, pos, matrix); + return new Point(pos[0], pos[1]); +} + export class GlobeSharedBuffers { _poleNorthVertexBuffer: VertexBuffer; _poleSouthVertexBuffer: VertexBuffer; diff --git a/src/geo/projection/projection.js b/src/geo/projection/projection.js index c7b15c00fe7..40eec7d01cd 100644 --- a/src/geo/projection/projection.js +++ b/src/geo/projection/projection.js @@ -74,8 +74,8 @@ export default class Projection { return {x, y, z: 0}; } - locationPoint(tr: Transform, lngLat: LngLat): Point { - return tr._coordinatePoint(tr.locationCoordinate(lngLat), false); + locationPoint(tr: Transform, lngLat: LngLat, terrain: boolean = true): Point { + return tr._coordinatePoint(tr.locationCoordinate(lngLat), terrain); } pixelsPerMeter(lat: number, worldSize: number): number { diff --git a/src/geo/transform.js b/src/geo/transform.js index a1cb7d0d958..c5adb8314ea 100644 --- a/src/geo/transform.js +++ b/src/geo/transform.js @@ -1081,7 +1081,7 @@ class Transform { * @private */ locationPoint3D(lnglat: LngLat): Point { - return this._coordinatePoint(this.locationCoordinate(lnglat), true); + return this.projection.locationPoint(this, lnglat, true); } /** diff --git a/src/ui/map.js b/src/ui/map.js index 23a5b3aa3ca..75609f19b65 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -1050,6 +1050,17 @@ class Map extends Camera { return {name: "mercator", center:[0, 0]}; } + /** + * Returns true if map [projection](https://docs.mapbox.com/mapbox-gl-js/style-spec/projection/) has been set to globe AND the map is at a low enough zoom level that globe view is enabled. + * @private + * @returns {boolean} Returns `globe-is-active` boolean. + * @example + * if (map._usingGlobe()) { + * // do globe things here + * } + */ + _usingGlobe(): boolean { return this.transform.projection.name === 'globe'; } + /** * Sets the map's projection. If called with `null` or `undefined`, the map will reset to Mercator. * diff --git a/src/ui/marker.js b/src/ui/marker.js index edd38be0149..dfb45990569 100644 --- a/src/ui/marker.js +++ b/src/ui/marker.js @@ -5,7 +5,7 @@ import window from '../util/window.js'; import LngLat from '../geo/lng_lat.js'; import Point from '@mapbox/point-geometry'; import smartWrap from '../util/smart_wrap.js'; -import {bindAll, extend} from '../util/util.js'; +import {bindAll, extend, radToDeg} from '../util/util.js'; import {type Anchor, anchorTranslate} from './anchor.js'; import {Event, Evented} from '../util/evented.js'; import type Map from './map.js'; @@ -13,6 +13,7 @@ import type Popup from './popup.js'; import type {LngLatLike} from "../geo/lng_lat.js"; import type {MapMouseEvent, MapTouchEvent} from './events.js'; import type {PointLike} from '@mapbox/point-geometry'; +import {globeTiltAtScreenPoint, globeCenterToScreenPoint} from '../geo/projection/globe_util.js'; type Options = { element?: HTMLElement, @@ -111,7 +112,7 @@ export default class Marker extends Evented { this._state = 'inactive'; this._rotation = (options && options.rotation) || 0; this._rotationAlignment = (options && options.rotationAlignment) || 'auto'; - this._pitchAlignment = options && options.pitchAlignment && options.pitchAlignment !== 'auto' ? options.pitchAlignment : this._rotationAlignment; + this._pitchAlignment = (options && options.pitchAlignment && options.pitchAlignment) || 'auto'; this._updateMoving = () => this._update(true); if (!options || !options.element) { @@ -409,6 +410,22 @@ export default class Marker extends Evented { return this; } + _occluded(unprojected: LngLat): boolean { + const map = this._map; + if (!map) return false; + const camera = map.getFreeCameraOptions(); + if (camera.position) { + const cameraLngLat = camera.position.toLngLat(); + const shortestDistance = cameraLngLat.distanceTo(unprojected); + const distanceToMarker = cameraLngLat.distanceTo(this._lngLat); + // In globe view, we only occlude if past ~100 km from cameraLngLat (i.e. screen center). + // This fixes an issue where a marker at screen center results in very small distances, + // with the error introduced from `map.unproject(pos)` occasionally causing the marker to be incorrectly occluded. + return shortestDistance < distanceToMarker * 0.9 && (!map._usingGlobe() || distanceToMarker > 100000); + } + return false; + } + _evaluateOpacity() { const map = this._map; if (!map) return; @@ -419,25 +436,25 @@ export default class Marker extends Evented { this._clearFadeTimer(); return; } - const mapLocation = map.unproject(pos); - - let terrainOccluded = false; - if (map.transform._terrainEnabled() && map.getTerrain()) { - const camera = map.getFreeCameraOptions(); - if (camera.position) { - const cameraPos = camera.position.toLngLat(); - // the distance to the marker lat/lng + marker offset location - const offsetDistance = cameraPos.distanceTo(mapLocation); - const distance = cameraPos.distanceTo(this._lngLat); - terrainOccluded = offsetDistance < distance * 0.9; - } + let opacity = 1; + if (map._usingGlobe()) { + opacity = this._occluded(mapLocation) ? 0 : 1; + } else if (map.transform._terrainEnabled() && map.getTerrain()) { + opacity = this._occluded(mapLocation) ? TERRAIN_OCCLUDED_OPACITY : 1; } const fogOpacity = map._queryFogOpacity(mapLocation); - const opacity = (1.0 - fogOpacity) * (terrainOccluded ? TERRAIN_OCCLUDED_OPACITY : 1.0); + opacity *= (1.0 - fogOpacity); + const pointerEvents = opacity ? 'auto' : 'none'; + this._element.style.opacity = `${opacity}`; - if (this._popup) this._popup._setOpacity(`${opacity}`); + this._element.style.pointerEvents = pointerEvents; + if (this._popup) { + const container = this._popup._container; + if (container) { container.style.pointerEvents = pointerEvents; } + this._popup._setOpacity(opacity); + } this._fadeTimer = null; } @@ -451,30 +468,54 @@ export default class Marker extends Evented { _updateDOM() { const pos = this._pos; - if (!pos) { return; } + const map = this._map; + if (!pos || !map) { return; } + + const rotation = this._calculateXYTransform() + this._calculateZTransform(); const offset = this._offset.mult(this._scale); - const pitch = this._calculatePitch(); - const rotation = this._calculateRotation(); + this._element.style.transform = ` - translate(${pos.x}px, ${pos.y}px) ${anchorTranslate[this._anchor]} - rotateX(${pitch}deg) rotateZ(${rotation}deg) - translate(${offset.x}px, ${offset.y}px) + translate(${pos.x}px,${pos.y}px) ${anchorTranslate[this._anchor]} + ${rotation} + translate(${offset.x}px,${offset.y}px) `; } - _calculatePitch(): number { - if (this._pitchAlignment === "viewport" || this._pitchAlignment === "auto") { - return 0; - } if (this._map && this._pitchAlignment === "map") { - return this._map.getPitch(); + _calculateXYTransform(): string { + const pos = this._pos; + const map = this._map; + + if (this.getPitchAlignment() !== 'map' || !map || !pos) { return ''; } + if (!map._usingGlobe()) { + const pitch = map.getPitch(); + return pitch ? `rotateX(${pitch}deg)` : ''; } - return 0; + const tilt = radToDeg(globeTiltAtScreenPoint(map.transform, pos)); + const posFromCenter = pos.sub(globeCenterToScreenPoint(map.transform)); + const tiltOverDist = tilt / (Math.abs(posFromCenter.x) + Math.abs(posFromCenter.y)); + const yTilt = posFromCenter.x * tiltOverDist; + const xTilt = -posFromCenter.y * tiltOverDist; + if (!xTilt && !yTilt) { return ''; } + return `rotateX(${xTilt}deg) rotateY(${yTilt}deg)`; + } + + _calculateZTransform(): string { + const spin = this._calculateRotation(); + return spin ? `rotateZ(${spin}deg)` : ``; } _calculateRotation(): number { if (this._rotationAlignment === "viewport" || this._rotationAlignment === "auto") { return this._rotation; } if (this._map && this._rotationAlignment === "map") { + const pos = this._pos; + const map = this._map; + if (pos && map && map._usingGlobe()) { + const north = map.project(new LngLat(this._lngLat.lng, this._lngLat.lat + .001)); + const south = map.project(new LngLat(this._lngLat.lng, this._lngLat.lat - .001)); + const diff = south.sub(north); + return radToDeg(Math.atan2(diff.y, diff.x)) - 90; + } return this._rotation - this._map.getBearing(); } return 0; @@ -512,7 +553,7 @@ export default class Marker extends Evented { this._updateDOM(); } - if ((map.getTerrain() || map.getFog()) && !this._fadeTimer) { + if ((map._usingGlobe() || map.getTerrain() || map.getFog()) && !this._fadeTimer) { this._fadeTimer = setTimeout(this._evaluateOpacity.bind(this), 60); } }); @@ -729,7 +770,7 @@ export default class Marker extends Evented { * const alignment = marker.getRotationAlignment(); */ getRotationAlignment(): string { - return this._rotationAlignment; + return this._rotationAlignment === `auto` ? 'viewport' : this._rotationAlignment; } /** @@ -741,19 +782,19 @@ export default class Marker extends Evented { * marker.setPitchAlignment('map'); */ setPitchAlignment(alignment: string): this { - this._pitchAlignment = alignment && alignment !== 'auto' ? alignment : this._rotationAlignment; + this._pitchAlignment = alignment || 'auto'; this._update(); return this; } /** - * Returns the current `pitchAlignment` property of the marker. + * Returns the current `pitchAlignment` behavior of the marker. * - * @returns {string} The current pitch alignment of the marker in degrees. + * @returns {string} The current pitch alignment of the marker. * @example * const alignment = marker.getPitchAlignment(); */ getPitchAlignment(): string { - return this._pitchAlignment; + return this._pitchAlignment === `auto` ? this.getRotationAlignment() : this._pitchAlignment; } } diff --git a/src/ui/popup.js b/src/ui/popup.js index 39e0ef60e10..f4f08e7ba71 100644 --- a/src/ui/popup.js +++ b/src/ui/popup.js @@ -640,9 +640,12 @@ export default class Popup extends Evented { this.remove(); } - _setOpacity(opacity: string) { - if (this._content) this._content.style.opacity = opacity; - if (this._tip) this._tip.style.opacity = opacity; + _setOpacity(opacity: number) { + if (this._content) { + this._content.style.opacity = `${opacity}`; + this._content.style.pointerEvents = opacity ? 'auto' : 'none'; + } + if (this._tip) this._tip.style.opacity = `${opacity}`; } } diff --git a/test/unit/ui/marker.test.js b/test/unit/ui/marker.test.js index d48a7eeef7f..95670d37394 100644 --- a/test/unit/ui/marker.test.js +++ b/test/unit/ui/marker.test.js @@ -260,6 +260,32 @@ test('Marker anchors as specified by the anchor option', (t) => { t.end(); }); +test('Transform reflects default offset', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([0, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(marker.getElement().style.transform, /translate\(0px,-14px\)/); + + map.remove(); + t.end(); +}); + +test('Marker is transformed to center of screen', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([0, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(marker.getElement().style.transform, "translate(256px,256px"); + + map.remove(); + t.end(); +}); + test('Marker accepts backward-compatible constructor parameters', (t) => { const element = window.document.createElement('div'); @@ -757,13 +783,13 @@ test('Marker transforms rotation with the map', (t) => { map._domRenderTaskQueue.run(); const rotationRegex = /rotateZ\(-?([0-9]+)deg\)/; - const initialRotation = marker.getElement().style.transform.match(rotationRegex)[1]; + t.notOk(marker.getElement().style.transform.match(rotationRegex)); map.setBearing(map.getBearing() + 180); map._domRenderTaskQueue.run(); const finalRotation = marker.getElement().style.transform.match(rotationRegex)[1]; - t.notEqual(initialRotation, finalRotation); + t.same(finalRotation, 180); map.remove(); t.end(); @@ -779,13 +805,34 @@ test('Marker transforms pitch with the map', (t) => { map._domRenderTaskQueue.run(); const rotationRegex = /rotateX\(-?([0-9]+)deg\)/; - const initialPitch = marker.getElement().style.transform.match(rotationRegex)[1]; + t.notOk(marker.getElement().style.transform.match(rotationRegex)); map.setPitch(45); map._domRenderTaskQueue.run(); const finalPitch = marker.getElement().style.transform.match(rotationRegex)[1]; - t.notEqual(initialPitch, finalPitch); + t.same(finalPitch, 45); + + map.remove(); + t.end(); +}); + +test('Unset pitchAlignment default to rotationAlignment', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([0, 0]) + .addTo(map); + + t.equal(marker.getRotationAlignment(), 'viewport'); + t.equal(marker.getPitchAlignment(), 'viewport'); + + marker.setRotationAlignment('map'); + t.equal(marker.getRotationAlignment(), 'map'); + t.equal(marker.getPitchAlignment(), 'map'); + + marker.setRotationAlignment('auto'); + t.equal(marker.getRotationAlignment(), 'viewport'); + t.equal(marker.getPitchAlignment(), 'viewport'); map.remove(); t.end(); @@ -938,6 +985,202 @@ test('Marker and fog', (t) => { }); }); +test('Globe', (t) => { + test('Marker is transformed to center of screen', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([0, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(marker.getElement().style.transform, "translate(256px,256px"); + map.setProjection('globe'); + map.once('render', () => { + t.match(marker.getElement().style.transform, "translate(256px,256px"); + map.remove(); + t.end(); + }); + }); + + test('Marker is positioned on globe surface', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([90, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(marker.getElement().style.transform, " translate(384px,256px)"); + map.setProjection('globe'); + map.once('render', () => { + t.match(marker.getElement().style.transform, "translate(330px,256px)"); + t.same(marker.getElement().style.opacity, 1.0); + t.same(marker.getElement().style.pointerEvents, 'auto'); + map.remove(); + t.end(); + }); + }); + + test('Marker is occluded on the far side of the globe', (t) => { + const map = createMap(t); + const marker = new Marker() + .setLngLat([180, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(marker.getElement().style.transform, " translate(512px,256px)"); + map.setProjection('globe'); + map.once('render', () => { + t.match(marker.getElement().style.transform, "translate(256px,256px)"); + t.same(marker.getElement().style.opacity, 0); + t.same(marker.getElement().style.pointerEvents, 'none'); + map.remove(); + t.end(); + }); + }); + + function transform(marker) { return marker.getElement().style.transform; } + + function rotation(marker, dimension) { + const transform = marker.getElement().style.transform; + const reg = new RegExp(`rotate${dimension}\\(([-.e0-9]+)deg\\)`); + return +Number.parseFloat(transform.match(reg)[1]).toFixed(9); + } + + test('Globe with pitchAlignment and rotationAlingment: map, changing longitude', (t) => { + const map = createMap(t); + map.setProjection('globe'); + const marker = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([0, 0]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(transform(marker), "translate(256px,256px)"); + t.notMatch(transform(marker), "rotateX"); + t.notMatch(transform(marker), "rotateZ"); + + marker.setLngLat([90, 0]); + map.once('render', () => { + t.match(transform(marker), "translate(330px,256px)"); + t.same(rotation(marker, "X"), 0); + t.same(rotation(marker, "Y"), 88.975673489); + map.remove(); + t.end(); + }); + }); + + test('Globe with pitchAlignment and rotationAlingment: map, changing lattitude', (t) => { + const map = createMap(t); + map.setProjection('globe'); + const marker = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([0, 89]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(transform(marker), "translate(256px,182px)"); + t.same(rotation(marker, "X"), 88.975673489); + t.same(rotation(marker, "Y"), 0); + + marker.setLngLat([-45, 45]); + map.on('render', () => { + t.match(transform(marker), "translate(217px,201px)"); + t.same(rotation(marker, "X"), 38.465875602); + t.same(rotation(marker, "Y"), -27.2758027); + t.same(rotation(marker, "Z"), 38.030140844); + map.remove(); + t.end(); + }); + }); + + test('Globe with pitchAlignment and rotationAlingment: map, changing pitch', (t) => { + const map = createMap(t); + map.setProjection('globe'); + const m1 = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([0, 0]) + .addTo(map); + const m2 = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([0, 45]) + .addTo(map); + const m3 = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([0, -30]) + .addTo(map); + const m4 = new Marker({rotationAlignment: 'map', pitchAlignment: 'map'}) + .setLngLat([45, -45]) + .addTo(map); + map._domRenderTaskQueue.run(); + + t.match(transform(m1), "translate(256px,256px)"); + t.notMatch(transform(m1), "rotateX"); + t.notMatch(transform(m1), "rotateY"); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m2), "translate(256px,200px)"); + t.same(rotation(m2, "X"), 49.299382704); + t.same(rotation(m2, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m3), "translate(256px,296px)"); + t.same(rotation(m3, "X"), -32.835045377); + t.same(rotation(m3, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m4), "translate(295px,311px)"); + t.same(rotation(m4, "X"), -38.465875602); + t.same(rotation(m4, "Y"), 27.2758027); + t.same(rotation(m4, "Z"), 38.030140843); + + map.setPitch(45); + map.once('render', () => { + t.match(transform(m1), "translate(256px,256px)"); + t.same(rotation(m1, "X"), 45); + t.same(rotation(m1, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m2), "translate(256px,234px)"); + t.same(rotation(m2, "X"), 85.512269796); + t.same(rotation(m2, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m3), "translate(256px,294px)"); + t.same(rotation(m3, "X"), 11.861002077); + t.same(rotation(m3, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m4), "translate(297px,327px)"); + t.same(rotation(m4, "X"), -10.677882737); + t.same(rotation(m4, "Y"), 25.158956455); + t.same(rotation(m4, "Z"), 29.578463693); + + map.setPitch(30); + map.once('render', () => { + t.match(transform(m1), "translate(256px,256px)"); + t.same(rotation(m1, "X"), 30); + t.same(rotation(m1, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m2), "translate(256px,220px)"); + t.same(rotation(m2, "X"), 78.903346373); + t.same(rotation(m2, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m3), "translate(256px,297px)"); + t.same(rotation(m3, "X"), -2.826321362); + t.same(rotation(m3, "Y"), 0); + t.notMatch(transform(m1), "rotateZ"); + + t.match(transform(m4), "translate(296px,326px)"); + t.same(rotation(m4, "X"), -19.579260926); + t.same(rotation(m4, "Y"), 23.961063055); + t.same(rotation(m4, "Z"), 30.522423436); + + map.remove(); + t.end(); + }); + }); + }); + + t.end(); +}); + test('Snap To Pixel', (t) => { const map = createMap(t); const marker = new Marker({draggable: true})