maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
1,789 lines • 85.7 kB
text/typescript
import {describe, beforeEach, afterEach, test, expect, vi, type MockInstance} from 'vitest';
import {createMap, beforeMapTest, waitForEvent, createTerrain, createDEM, waitForMetadataEvent, createCoverageIndex} from '../../util/test/util.ts';
import simulate from '../../../test/unit/lib/simulate_interaction.ts';
import {LngLat, earthRadius} from '../../geo/lng_lat.ts';
import {MercatorCoordinate} from '../../geo/mercator_coordinate.ts';
import {fakeServer, type FakeServer} from 'nise';
import {createMercatorTransform} from '../../geo/projection/mercator_transform.ts';
import {OverscaledTileID} from '../../tile/tile_id.ts';
import {AttributionControl, defaultAttributionControlOptions} from '../control/attribution_control.ts';
import {ImageRequest} from '../../util/image_request.ts';
import {Painter, type RTTObject} from '../../render/painter.ts';
import {MapSourceDataEvent} from '../events.ts';
import * as timeControl from '../../util/time_control.ts';
import type {Map, MapOptions} from '../map.ts';
import type {Terrain} from '../../render/terrain.ts';
import type {CustomLayerInterface} from '../../style/style_layer/custom_style_layer.ts';
import type {RasterDEMTileSource} from '../../source/raster_dem_tile_source.ts';
import type {Tile} from '../../tile/tile.ts';
/** A raster-dem source whose tiles load only when a test lands them, see {@link landDemTiles}. */
type WaitingDem = {
source: RasterDEMTileSource;
tiles: WaitingDemTile[];
};
type WaitingDemTile = {
tile: Tile;
resolve: () => void;
};
let server: FakeServer;
let map: Map;
beforeEach(() => {
beforeMapTest();
global.fetch = null;
server = fakeServer.create();
map = createMap();
});
afterEach(() => {
server.restore();
});
describe('setTerrain', () => {
afterEach(() => {
map.remove();
vi.restoreAllMocks();
});
test('warn when terrain and hillshade source identical', async () => {
server.respondWith('/source.json', JSON.stringify({
minzoom: 5,
maxzoom: 12,
attribution: 'Terrain',
tiles: ['http://example.com/{z}/{x}/{y}.pngraw'],
bounds: [-47, -7, -45, -5]
}));
await map.once('load');
map.addSource('terrainrgb', {type: 'raster-dem', url: '/source.json'});
server.respond();
map.addLayer({id: 'hillshade', type: 'hillshade', source: 'terrainrgb'});
const originalWarn = console.warn;
console.warn = vi.fn();
map.setTerrain({
source: 'terrainrgb'
});
expect(console.warn).toHaveBeenCalledTimes(1);
console.warn = originalWarn;
});
test('fires an error and does not apply terrain the spec rejects', async () => {
await map.once('style.load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256});
const errorSpy = vi.fn();
map.on('error', errorSpy);
map.setTerrain({source: 'dem', nonsense: 1} as any);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0][0].error.message).toContain('unknown property "nonsense"');
expect(map.getTerrain()).toBeNull();
});
test('applies terrain the spec accepts', async () => {
await map.once('style.load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256});
const errorSpy = vi.fn();
map.on('error', errorSpy);
map.setTerrain({source: 'dem', exaggeration: 2});
expect(errorSpy).not.toHaveBeenCalled();
expect(map.getTerrain()).toEqual({source: 'dem', exaggeration: 2});
});
test('removing terrain frees the pooled drape textures', async () => {
await map.once('style.load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256});
map.setTerrain({source: 'dem'});
const drape = map.painter.acquireRTT(512);
vi.spyOn(drape.texture, 'destroy');
map.painter.releaseRTT(drape);
map.setTerrain(null);
expect(drape.texture.destroy).toHaveBeenCalledTimes(1);
});
test('destroys the drapes a zoom out leaves unused once the map is at rest', async () => {
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
map = createMap({zoom: 14, style: {
version: 8,
sources: {
dem: {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256},
land: {type: 'geojson', data: {type: 'Feature', properties: {}, geometry: {type: 'Polygon', coordinates: [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]]}}}
},
layers: [{id: 'land', type: 'fill', source: 'land'}],
terrain: {source: 'dem'}
}});
const acquireRTT = vi.spyOn(map.painter, 'acquireRTT');
await map.once('idle');
const drapesAtZoom14 = [...new Set(acquireRTT.mock.results.map(({value}) => value as RTTObject))];
for (const drape of drapesAtZoom14) vi.spyOn(drape.texture, 'destroy');
const terrainTilesAtZoom14 = map.terrain.tileManager.getRenderableTiles().length;
map.jumpTo({zoom: 0});
await map.once('idle');
const terrainTilesAtZoom0 = map.terrain.tileManager.getRenderableTiles().length;
expect(terrainTilesAtZoom0).toBeLessThan(terrainTilesAtZoom14);
const destroyedDrapes = drapesAtZoom14.filter(({texture}) => vi.mocked(texture.destroy).mock.calls.length > 0);
expect(destroyedDrapes).toHaveLength(drapesAtZoom14.length - terrainTilesAtZoom0);
});
test('drops the previous source attribution when switching terrain to a new source', async () => {
const attribution = new AttributionControl();
map.addControl(attribution);
await map.once('style.load');
const demALoaded = waitForEvent(map, 'sourcedata', (e) => e.sourceId === 'dem-a' && e.sourceDataType === 'metadata');
const demBLoaded = waitForEvent(map, 'sourcedata', (e) => e.sourceId === 'dem-b' && e.sourceDataType === 'metadata');
map.addSource('dem-a', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256, attribution: 'DEM A'});
map.addSource('dem-b', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256, attribution: 'DEM B'});
await Promise.all([demALoaded, demBLoaded]);
map.setTerrain({source: 'dem-a'});
const terrainEvent = map.once('terrain');
map.setTerrain({source: 'dem-b'});
await terrainEvent;
const innerContainer = map.getContainer().querySelector('.maplibregl-ctrl-attrib-inner');
expect(innerContainer.innerHTML).toBe(`DEM B | ${defaultAttributionControlOptions.customAttribution}`);
});
test('invalidates cached elevation when terrain source data changes without a tile', async () => {
await map.once('load');
map.addSource('terrainrgb', {
type: 'raster-dem',
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
map.setTerrain({source: 'terrainrgb'});
if (!map.terrain) throw new Error('Expected terrain to be set');
const resetElevationCache = vi.spyOn(map.terrain, 'resetElevationCache');
map._terrainDataCallback({
dataType: 'source',
sourceId: 'terrainrgb',
sourceDataType: 'content',
source: {type: 'raster-dem'}
} as any);
expect(resetElevationCache).toHaveBeenCalledTimes(1);
});
test('invalidates terrain depth only for tiles from the terrain source', async () => {
await map.once('load');
const terrainLoaded = waitForEvent(map, 'sourcedata', (e) => e.sourceId === 'terrainrgb' && e.sourceDataType === 'metadata');
const otherLoaded = waitForEvent(map, 'sourcedata', (e) => e.sourceId === 'other' && e.sourceDataType === 'metadata');
map.addSource('terrainrgb', {
type: 'raster-dem',
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
map.addSource('other', {
type: 'raster-dem',
tiles: ['http://example.com/other/{z}/{x}/{y}.png']
});
await Promise.all([terrainLoaded, otherLoaded]);
const markTerrainDepthDirty = vi.spyOn(Painter.prototype, 'markTerrainDepthDirty');
map.setTerrain({source: 'terrainrgb'});
expect(markTerrainDepthDirty).toHaveBeenCalledTimes(1);
markTerrainDepthDirty.mockClear();
const terrainSource = map.getSource('terrainrgb');
const otherSource = map.getSource('other');
expect(terrainSource).toBeDefined();
expect(otherSource).toBeDefined();
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
const tile = {tileID};
otherSource.fire(new MapSourceDataEvent('data', {tile, coord: tileID}));
expect(markTerrainDepthDirty).not.toHaveBeenCalled();
terrainSource.fire(new MapSourceDataEvent('data', {sourceDataType: 'content'}));
expect(markTerrainDepthDirty).not.toHaveBeenCalled();
terrainSource.fire(new MapSourceDataEvent('data', {tile, coord: tileID}));
expect(markTerrainDepthDirty).toHaveBeenCalledTimes(1);
terrainSource.fire(new MapSourceDataEvent('data', {tile, coord: tileID, sourceDataType: 'content'}));
expect(markTerrainDepthDirty).toHaveBeenCalledTimes(2);
});
test('re-places symbols when terrain is set', async () => {
await map.once('load');
map.addSource('terrainrgb', {
type: 'raster-dem',
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
const triggerSymbolPlacement = vi.spyOn(map.style, 'triggerSymbolPlacement');
map.setTerrain({source: 'terrainrgb'});
expect(triggerSymbolPlacement).toHaveBeenCalledTimes(1);
});
test('re-places symbols when terrain source data changes, but not for data from another source', async () => {
await map.once('load');
map.addSource('terrainrgb', {
type: 'raster-dem',
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
map.setTerrain({source: 'terrainrgb'});
const triggerSymbolPlacement = vi.spyOn(map.style, 'triggerSymbolPlacement');
map._terrainDataCallback({
dataType: 'source',
sourceId: 'other',
sourceDataType: 'content',
source: {type: 'geojson'}
} as any);
expect(triggerSymbolPlacement).not.toHaveBeenCalled();
map._terrainDataCallback({
dataType: 'source',
sourceId: 'terrainrgb',
sourceDataType: 'content',
source: {type: 'raster-dem'}
} as any);
expect(triggerSymbolPlacement).toHaveBeenCalledTimes(1);
});
});
describe('terrainTileRevision', () => {
afterEach(() => {
map.remove();
vi.restoreAllMocks();
});
test('draws the terrain tiles of a custom layer again when it changes, and only then', async () => {
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
map = createMap({zoom: 14, style: {
version: 8,
sources: {dem: {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256}},
layers: [],
terrain: {source: 'dem'}
}});
const renderToTerrainTile = vi.fn();
const layer = {id: 'custom', type: 'custom' as const, render: () => {}, renderToTerrainTile, terrainTileRevision: 0};
await map.once('load');
map.addLayer(layer);
await map.once('idle');
const terrainTiles = map.terrain.tileManager.getRenderableTiles().length;
renderToTerrainTile.mockClear();
map.triggerRepaint();
await map.once('idle');
expect(renderToTerrainTile).not.toHaveBeenCalled();
layer.terrainTileRevision++;
map.triggerRepaint();
await map.once('idle');
expect(terrainTiles).toBeGreaterThan(0);
expect(renderToTerrainTile).toHaveBeenCalledTimes(terrainTiles);
});
test('keeps the terrain tiles of a custom layer below a layer that is not draped when one above it changes', async () => {
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
map = createMap({zoom: 14, style: {
version: 8,
sources: {dem: {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256}},
layers: [],
terrain: {source: 'dem'}
}});
const renderBelowToTerrainTile = vi.fn();
const renderAboveToTerrainTile = vi.fn();
const below = {id: 'below', type: 'custom' as const, render: () => {}, renderToTerrainTile: renderBelowToTerrainTile, terrainTileRevision: 0};
const notDraped = {id: 'not-draped', type: 'custom' as const, render: () => {}};
const above = {id: 'above', type: 'custom' as const, render: () => {}, renderToTerrainTile: renderAboveToTerrainTile, terrainTileRevision: 0};
await map.once('load');
map.addLayer(below);
map.addLayer(notDraped);
map.addLayer(above);
await map.once('idle');
const terrainTiles = map.terrain.tileManager.getRenderableTiles().length;
renderBelowToTerrainTile.mockClear();
renderAboveToTerrainTile.mockClear();
above.terrainTileRevision++;
map.triggerRepaint();
await map.once('idle');
expect(terrainTiles).toBeGreaterThan(0);
expect(renderAboveToTerrainTile).toHaveBeenCalledTimes(terrainTiles);
expect(renderBelowToTerrainTile).not.toHaveBeenCalled();
});
});
describe('renderTerrainHeightMap', () => {
afterEach(() => {
map.remove();
vi.restoreAllMocks();
});
test('draws the terrain height map into the texture of a custom layer', async () => {
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
map = createMap({zoom: 14, style: {
version: 8,
sources: {dem: {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256}},
layers: [],
terrain: {source: 'dem'}
}});
const gl = map.painter.context.gl;
const texture = gl.createTexture();
const layer: CustomLayerInterface = {
id: 'custom',
type: 'custom',
render: () => {},
prerender: (_gl, options) => options.renderTerrainHeightMap({texture, width: 64, height: 32, bounds: [0.5, 0.25, 0.75, 0.5]})
};
await map.once('load');
map.addLayer(layer);
await map.once('idle');
expect(gl.framebufferTexture2D).toHaveBeenCalledWith(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
expect(gl.viewport).toHaveBeenCalledWith(0, 0, 64, 32);
});
test('is not given to custom layers in render', async () => {
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
map = createMap({zoom: 14, style: {
version: 8,
sources: {dem: {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], tileSize: 256}},
layers: [],
terrain: {source: 'dem'}
}});
const render = vi.fn();
await map.once('load');
map.addLayer({id: 'custom', type: 'custom', render});
await map.once('idle');
expect(render).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({renderTerrainHeightMap: undefined}));
});
test('is not given to custom layers without terrain', async () => {
const prerender = vi.fn();
await map.once('load');
map.addLayer({id: 'custom', type: 'custom', render: () => {}, prerender});
await map.once('idle');
expect(prerender).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({renderTerrainHeightMap: undefined}));
});
});
describe('getTerrain', () => {
test('returns null when not set', () => {
const map = createMap();
expect(map.getTerrain()).toBeNull();
});
});
describe('getCameraTargetElevation', () => {
test('Elevation is zero without terrain, and matches any given terrain', () => {
expect(map.getCameraTargetElevation()).toBe(0);
map.terrain = {} as Terrain;
const transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
transform.setElevation(200);
transform.setCenter(new LngLat(10.0, 50.0));
transform.setZoom(14);
transform.resize(512, 512);
transform.setElevation(2000);
map._camera.transform = transform;
expect(map.getCameraTargetElevation()).toBe(2000);
});
});
describe('Gesture end on terrain', () => {
test('the center elevation after a drag is the rendered surface elevation, not the tile-zoom DEM sample', async () => {
const map = createMap({interactive: true, clickTolerance: 4});
await map.once('style.load');
map.terrain = {
...createTerrain(),
getElevationForLngLat: () => 400,
getElevationForLngLatZoom: () => 1000,
} as any as Terrain;
map._camera.terrain = map.terrain;
simulate.dragWithMove(map.getCanvas(), {x: 100, y: 100}, {x: 100, y: 150});
map._renderTaskQueue.run();
expect(map.getCenterElevation()).toBe(400);
});
test('the camera stays in place when a drag ends at pitch 85', async () => {
const map = createMap({interactive: true, zoom: 11, maxPitch: 85, pitch: 85});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png']});
map.setTerrain({source: 'dem'});
vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(400);
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 100});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 100, clientY: 150});
map.redraw();
const probe = map.unproject([100, 150]);
const probeAtRelease = map.project(probe);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 150});
map.redraw();
expect(map.project(probe).dist(probeAtRelease)).toBeLessThan(0.01);
});
});
describe('Terrain changing under and around a gesture', () => {
async function createMapOverTerrain(pitch: number): Promise<Map> {
const map = createMap({interactive: true, zoom: 11, pitch});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png']});
map.setTerrain({source: 'dem'});
return map;
}
function demTileLands(map: Map): void {
const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
map.getSource('dem').fire(new MapSourceDataEvent('data', {tile: {tileID}, coord: tileID}));
}
async function addWaitingDemSource(map: Map, id: string): Promise<WaitingDem> {
map.addSource(id, {type: 'raster-dem', tiles: [`http://example.com/${id}/{z}/{x}/{y}.png`]});
const source = map.getSource<RasterDEMTileSource>(id);
await waitForMetadataEvent(source);
const dem: WaitingDem = {source, tiles: []};
vi.spyOn(source, 'loadTile').mockImplementation(function (tile: Tile) {
return new Promise<void>(function (resolve) {
dem.tiles.push({tile, resolve});
});
});
return dem;
}
async function createMapWithWaitingDem(options: Partial<MapOptions>): Promise<{map: Map; dem: WaitingDem}> {
const map = createMap({interactive: true, ...options});
await map.once('load');
return {map, dem: await addWaitingDemSource(map, 'dem')};
}
/** Loads the waiting tiles `lands` picks with a flat DEM at `elevation`, and resolves once each has fired its data event. */
function landDemTiles(dem: WaitingDem, elevation: number, lands: (tile: Tile) => boolean): Promise<void> {
const landing = dem.tiles.filter(entry => !entry.tile.aborted && lands(entry.tile));
dem.tiles = dem.tiles.filter(entry => !landing.includes(entry));
const landed = demTilesLanded(dem.source, landing.length);
for (const {tile, resolve} of landing) {
tile.dem = createDEM(() => elevation);
tile.state = 'loaded';
resolve();
}
return landed;
}
function demTilesLanded(source: RasterDEMTileSource, count: number): Promise<void> {
return new Promise(function (resolve) {
if (count === 0) {
resolve();
return;
}
source.on('data', function onTileData(e: MapSourceDataEvent) {
if (!e.tile) return;
count--;
if (count === 0) {
source.off('data', onTileData);
resolve();
}
});
});
}
function landAllDemTiles(dem: WaitingDem, elevation: number): Promise<void> {
return landDemTiles(dem, elevation, () => true);
}
function landDemTilesAtZoom(dem: WaitingDem, elevation: number, zoom: number): Promise<void> {
return landDemTiles(dem, elevation, tile => tile.tileID.canonical.z === zoom);
}
function landDemTilesAwayFrom(dem: WaitingDem, elevation: number, lngLat: LngLat): Promise<void> {
return landDemTiles(dem, elevation, tile => !tileCovers(tile, lngLat));
}
function tileCovers(tile: Tile, lngLat: LngLat): boolean {
const {x, y} = MercatorCoordinate.fromLngLat(lngLat);
const {z, x: tileX, y: tileY} = tile.tileID.canonical;
return Math.floor(x * (1 << z)) === tileX && Math.floor(y * (1 << z)) === tileY;
}
function renderFrameAt(map: Map, now: MockInstance<() => number>, time: number): void {
now.mockReturnValue(time);
map._renderTaskQueue.run();
}
function startPitchDragThatSwitchesTerrainOn(map: Map): void {
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 130});
map._renderTaskQueue.run();
map.setTerrain({source: 'dem'});
map.redraw();
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
}
function startPitchDrag(map: Map): void {
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 130});
map._renderTaskQueue.run();
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
}
afterEach(() => {
vi.restoreAllMocks();
});
test('a pitch drag that switches terrain on takes the DEM elevation when it lands while the pointer rests, and releases where it is', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
expect(map.getCameraTargetElevation()).toBe(1000);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('a pitch drag that switches terrain on takes the elevation of a coarser DEM tile drawn under the center while the tile under the center loads', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const zoomOfACoarserDemTile = 5;
startPitchDragThatSwitchesTerrainOn(map);
await landDemTilesAtZoom(dem, 100, zoomOfACoarserDemTile);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(100);
});
test('a pitch drag that switches terrain on lowers the center elevation to a coarser DEM tile drawn below it while the tile under the center loads', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const zoomOfACoarserDemTile = 5;
startPitchDragThatSwitchesTerrainOn(map);
await landDemTilesAtZoom(dem, -400, zoomOfACoarserDemTile);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(-400);
});
test('a pitch drag that switches terrain on keeps its zoom when it ends over a coarser DEM tile before the tile under the center loads', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const zoomOfACoarserDemTile = 5;
startPitchDragThatSwitchesTerrainOn(map);
await landDemTilesAtZoom(dem, 1000, zoomOfACoarserDemTile);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('a pitch drag that switches terrain on takes the DEM elevation of the tile under the center when it lands after a coarser tile', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const zoomOfACoarserDemTile = 5;
const zoomOfTheDrawnDemTiles = 15;
startPitchDragThatSwitchesTerrainOn(map);
await landDemTilesAtZoom(dem, 1000, zoomOfACoarserDemTile);
await landDemTilesAtZoom(dem, 500, zoomOfTheDrawnDemTiles);
expect(map.getCameraTargetElevation()).toBe(500);
});
test('a drag that switches terrain on takes the DEM elevation once the center moves over a tile that has landed, and holds it when the last tile lands lower', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 100});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 105, clientY: 105});
map._renderTaskQueue.run();
map.setTerrain({source: 'dem'});
map.redraw();
simulate.mousemove(window.document.body, {buttons: 1, clientX: 110, clientY: 110});
map._renderTaskQueue.run();
await landDemTilesAwayFrom(dem, 1000, map.getCenter());
simulate.mousemove(window.document.body, {buttons: 1, clientX: 40, clientY: 40});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
await landAllDemTiles(dem, 500);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 150, clientY: 150});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a drag over terrain that starts before its DEM lands keeps the ground under the pointer once the DEM lands', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
map.setTerrain({source: 'dem'});
map.redraw();
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 60, clientY: 100});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 70, clientY: 100});
map._renderTaskQueue.run();
await landAllDemTiles(dem, 1000);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 80, clientY: 100});
map._renderTaskQueue.run();
const groundUnderThePointer = map.unproject([80, 100]);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 90, clientY: 100});
map._renderTaskQueue.run();
expect(map.project(groundUnderThePointer).x).toBeCloseTo(90, 0);
});
test('a drag over terrain that starts before its DEM lands keeps the ground under the pointer once the DEM lands below sea level', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 14, pitch: 0});
map.setTerrain({source: 'dem'});
map.redraw();
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 60, clientY: 100});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 70, clientY: 100});
map._renderTaskQueue.run();
await landAllDemTiles(dem, -400);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 80, clientY: 100});
map._renderTaskQueue.run();
const groundUnderThePointer = map.unproject([80, 100]);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 90, clientY: 100});
map._renderTaskQueue.run();
expect(map.project(groundUnderThePointer).x).toBeCloseTo(90, 0);
});
test('a pitch drag that switches terrain on and ends before the DEM lands takes it during its inertia', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 145});
renderFrameAt(map, now, 16);
map.setTerrain({source: 'dem'});
map.redraw();
for (let move = 1; move <= 10; move++) {
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 145 - 5 * move});
renderFrameAt(map, now, 16 + 16 * move);
}
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 95});
renderFrameAt(map, now, 192);
await landAllDemTiles(dem, 1000);
renderFrameAt(map, now, 256);
expect(map.isMoving()).toBe(true);
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a flyTo with freezeElevation takes the DEM elevation once it lands under the center', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 30});
map.setTerrain({source: 'dem'});
map.redraw();
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.flyTo({center: [0.001, 0], duration: 1000, freezeElevation: true, easing: k => k});
await landAllDemTiles(dem, 1000);
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a drag that starts before the DEM lands keeps its zoom when it ends over a tile whose DEM has not landed', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0, center: [0.0005, -0.0001]});
map.setTerrain({source: 'dem'});
map.redraw();
const whereTheDragEnds = new LngLat(0.0005, 0.0003);
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 60});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 100, clientY: 64});
map._renderTaskQueue.run();
await landDemTilesAwayFrom(dem, 50, whereTheDragEnds);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 100, clientY: 125});
map._renderTaskQueue.run();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 125});
map._renderTaskQueue.run();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('a drag over loaded terrain that switches off and on keeps its zoom when it ends over a tile whose DEM has not landed again', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0, center: [0.0005, -0.0001]});
map.setTerrain({source: 'dem'});
map.redraw();
await landAllDemTiles(dem, 1000);
map.redraw();
const whereTheDragEnds = new LngLat(0.0005, 0.0003);
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 60});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 100, clientY: 64});
map._renderTaskQueue.run();
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
map.redraw();
await landDemTilesAwayFrom(dem, 50, whereTheDragEnds);
simulate.mousemove(window.document.body, {buttons: 1, clientX: 100, clientY: 125});
map._renderTaskQueue.run();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 125});
map._renderTaskQueue.run();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('an easeTo with freezeElevation that takes the DEM keeps the camera out of the terrain when it ends over a tile whose DEM has not landed', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 30, center: [0.0005, -0.0012]});
map.setTerrain({source: 'dem'});
map.redraw();
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.easeTo({center: [0.0005, 0.0005], duration: 1000, freezeElevation: true, easing: k => k});
now.mockReturnValue(100);
map.redraw();
await landDemTilesAwayFrom(dem, 1000, new LngLat(0.0005, 0.0005));
now.mockReturnValue(200);
map.redraw();
now.mockReturnValue(1000);
map.redraw();
expect(map._camera.transform.getCameraAltitude()).toBeGreaterThanOrEqual(1000);
});
test('a pitch drag with the center not clamped to the ground keeps the center elevation when the terrain switches off before it ends', async () => {
const {map} = await createMapWithWaitingDem({zoom: 17, pitch: 0, centerClampedToGround: false});
map.setTerrain({source: 'dem'});
map.redraw();
map.setCenterElevation(3000);
startPitchDrag(map);
map.setTerrain(null);
map.redraw();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(3000);
});
test('an easeTo with freezeElevation stopped after the DEM lands and before its next frame ends at its zoom', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 30});
map.setTerrain({source: 'dem'});
map.redraw();
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.easeTo({center: [0.001, 0], duration: 1000, freezeElevation: true, easing: k => k});
now.mockReturnValue(100);
map.redraw();
await landAllDemTiles(dem, 1000);
map.stop();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('an easeTo after an easeTo with freezeElevation that ended with the terrain switched off does not jump to the DEM elevation when it lands', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.setTerrain({source: 'dem'});
map.redraw();
map.easeTo({center: [0.0001, 0], duration: 1000, freezeElevation: true, easing: k => k});
map.setTerrain(null);
now.mockReturnValue(1000);
map.redraw();
map.setTerrain({source: 'dem'});
map.redraw();
map.easeTo({center: [0.001, 0], duration: 1000, easing: k => k});
await landAllDemTiles(dem, 100);
now.mockReturnValue(1100);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(0);
});
test('a pitch drag over loaded terrain puts the center on the flat terrain drawn after the terrain switches off and on, until its DEM lands again', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
map.setTerrain({source: 'dem'});
map.redraw();
await landAllDemTiles(dem, 1000);
map.redraw();
startPitchDrag(map);
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
map.redraw();
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(0);
});
test('a pitch drag over loaded terrain takes the DEM elevation that lands after the terrain switches off and on', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
map.setTerrain({source: 'dem'});
map.redraw();
await landAllDemTiles(dem, 1000);
map.redraw();
startPitchDrag(map);
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
map.redraw();
await landAllDemTiles(dem, 500);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(500);
});
test('a pitch drag that switches terrain on takes the DEM elevation of another DEM source the terrain switches to when it lands', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const otherDem = await addWaitingDemSource(map, 'otherDem');
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
map.setTerrain({source: 'otherDem'});
map.redraw();
await landAllDemTiles(otherDem, 500);
expect(map.getCameraTargetElevation()).toBe(500);
});
test('a pitch drag that switches terrain on keeps its zoom when the field of view changes and the terrain switches to another DEM source after the DEM landed', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
await addWaitingDemSource(map, 'otherDem');
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
map.setVerticalFieldOfView(40);
map.setTerrain({source: 'otherDem'});
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
expect(map.getZoom()).toBeCloseTo(17, 6);
});
test('a pitch drag that switches terrain on keeps the elevation it took when the terrain switches off and on while a hillshade layer keeps its DEM loaded', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const zoomOfDemTilesNotDrawn = 16;
map.addLayer({id: 'hillshade', type: 'hillshade', source: 'dem'});
startPitchDragThatSwitchesTerrainOn(map);
await landDemTilesAtZoom(dem, 1010, zoomOfDemTilesNotDrawn);
await landAllDemTiles(dem, 1000);
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a pitch drag that switches terrain on keeps the elevation it took when the terrain exaggeration changes', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
map.setTerrain({source: 'dem', exaggeration: 1.1});
map.redraw();
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a pitch drag that switches terrain on keeps the elevation it took once the center is not clamped to the ground when the terrain switches off and on', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
map.setCenterClampedToGround(false);
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('an easeTo after a pitch drag that switches terrain on does not jump to the DEM elevation when the terrain switches off and on during it and the DEM lands', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 17, pitch: 0});
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
startPitchDragThatSwitchesTerrainOn(map);
await landAllDemTiles(dem, 1000);
now.mockReturnValue(1000);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 110});
map._renderTaskQueue.run();
map.easeTo({center: [0.001, 0.001], duration: 1000, easing: k => k});
map.setTerrain(null);
map.redraw();
map.setTerrain({source: 'dem'});
map.redraw();
await landAllDemTiles(dem, 500);
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('an easeTo with freezeElevation that starts before the DEM lands ends at its zoom when the style reloads with the same terrain source after the DEM landed', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 15, pitch: 40});
map.setTerrain({source: 'dem'});
map.redraw();
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.easeTo({center: [0.002, 0.001], zoom: 16, pitch: 50, duration: 1000, freezeElevation: true, easing: k => k});
now.mockReturnValue(200);
map.redraw();
await landAllDemTiles(dem, 1000);
map.setStyle({version: 8, sources: {dem: {type: 'raster-dem', tiles: ['http://example.com/dem/{z}/{x}/{y}.png']}}, layers: [], terrain: {source: 'dem'}}, {diff: false});
await map.once('style.load');
now.mockReturnValue(1000);
map.redraw();
expect(map.getZoom()).toBeCloseTo(16, 6);
});
test('an easeTo with freezeElevation that starts before the DEM lands takes the DEM elevation of another DEM source the terrain switches to after a field of view change', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 15, pitch: 40});
const otherDem = await addWaitingDemSource(map, 'otherDem');
map.setTerrain({source: 'dem'});
map.redraw();
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
map.easeTo({center: [0.002, 0.001], zoom: 16, pitch: 50, duration: 1000, freezeElevation: true, easing: k => k});
now.mockReturnValue(200);
map.redraw();
await landAllDemTiles(dem, 1000);
map.setVerticalFieldOfView(40);
map.setTerrain({source: 'otherDem'});
map.redraw();
await landAllDemTiles(otherDem, 500);
now.mockReturnValue(300);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(500);
});
test('a rotate drag holds the center elevation until it ends and turns the same bearing per pixel while the terrain under the center rises', async () => {
const map = await createMapOverTerrain(60);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
const bearingAtStart = map.getBearing();
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 40, clientY: 180});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 50, clientY: 180});
map._renderTaskQueue.run();
const bearingAfterFirstMove = map.getBearing();
terrainElevation.mockReturnValue(3000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(0);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 60, clientY: 180});
map._renderTaskQueue.run();
const bearingAfterSecondMove = map.getBearing();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 60, clientY: 180});
map._renderTaskQueue.run();
expect(bearingAfterSecondMove - bearingAfterFirstMove).toBeCloseTo(bearingAfterFirstMove - bearingAtStart, 5);
expect(map.getCameraTargetElevation()).toBe(3000);
});
test('a drag around the pointer holds the center elevation until it ends', async () => {
const map = await createMapOverTerrain(60);
map.dragRotate.enable({around: 'pointer'});
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 40, clientY: 180});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 50, clientY: 180});
map.redraw();
terrainElevation.mockReturnValue(3000);
map.redraw();
const elevationDuringDrag = map.getCameraTargetElevation();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 50, clientY: 180});
map.redraw();
expect(elevationDuringDrag).toBe(0);
expect(map.getCameraTargetElevation()).toBe(3000);
});
test('a DEM tile landing while a pan drag is in flight leaves the camera where the drag put it, and the release re-solves the zoom onto the new terrain without moving it', async () => {
const map = await createMapOverTerrain(0);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
terrainElevation.mockReturnValue(1000);
demTileLands(map);
expect(map.getCameraTargetElevation()).toBe(0);
expect(map.getZoom()).toBe(11);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1000);
expect(map.getZoom()).toBeCloseTo(11.131812, 5);
});
test('after easeTo or flyTo the center elevation follows the terrain again on the next frame', async () => {
const map = await createMapOverTerrain(0);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
map.easeTo({center: [2, 2], zoom: 12, duration: 0});
terrainElevation.mockReturnValue(2000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(2000);
expect(map.getZoom()).toBe(12);
map.flyTo({center: [3, 3], zoom: 13, animate: false});
terrainElevation.mockReturnValue(3000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(3000);
expect(map.getZoom()).toBe(13);
});
test('the gesture after terrain changes at rest starts from the camera as rendered, after a click, a DEM tile landing and the per-frame clamp moving it further', async () => {
const map = await createMapOverTerrain(0);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 110, clientY: 150});
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
terrainElevation.mockReturnValue(1000);
demTileLands(map);
expect(map.getCameraTargetElevation()).toBe(1000);
terrainElevation.mockReturnValue(1500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1500);
simulate.mousedown(map.getCanvas(), {buttons: 1, button: 0, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 1, clientX: 110, clientY: 150});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(1500);
});
test('easeTo after a pitch limit change starts from the camera as rendered, after a DEM tile landing', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
map.setMaxPitch(70);
terrainElevation.mockReturnValue(1000);
demTileLands(map);
map.easeTo({center: [1, 1], duration: 1000, easing: k => k});
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('easeTo and flyTo ease the center elevation to the terrain under the destination, and the frame after the animation leaves it there', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation((lnglat: LngLat) => lnglat.lat > 2.5 ? 3000 : lnglat.lat > 0.5 ? 1000 : 0);
map.easeTo({center: [1, 1], zoom: 12, duration: 1000, easing: k => k});
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(500);
now.mockReturnValue(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
map.flyTo({center: [3, 3], zoom: 13, duration: 1000, easing: k => k});
now.mockReturnValue(1500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(2000);
now.mockReturnValue(2000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(3000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(3000);
});
test('jumpTo keeps the center elevation while no DEM data covers the new center, and moves the camera once its DEM lands', async () => {
const map = await createMapOverTerrain(60);
const startElevation = 3750;
const elevationForWantOfDem = 0;
const landedElevation = 4800;
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(startElevation);
map.redraw();
terrainElevation.mockReturnValue(elevationForWantOfDem);
map.jumpTo({center: [1, 1]});
map.redraw();
expect(map.getCenterElevation()).toBe(startElevation);
demTileLands(map);
expect(map.getCenterElevation()).toBe(startElevation);
terrainElevation.mockReturnValue(landedElevation);
demTileLands(map);
expect(map.getCenterElevation()).toBe(landedElevation);
});
test('jumpTo puts the center at sea level when the new center lies outside the DEM source bounds', async () => {
const map = createMap({interactive: true, zoom: 11, pitch: 60});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], bounds: [-1, -1, 0.5, 0.5]});
await waitForMetadataEvent(map.getSource<RasterDEMTileSource>('dem'));
map.setTerrain({source: 'dem'});
const startElevation = 3750;
const elevationOutsideTheDem = 0;
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(startElevation);
map.redraw();
terrainElevation.mockReturnValue(elevationOutsideTheDem);
map.jumpTo({center: [1, 1]});
map.redraw();
expect(map.getCenterElevation()).toBe(elevationOutsideTheDem);
});
test('jumpTo puts the center at sea level when the map is zoomed out below the DEM source minzoom', async () => {
const map = createMap({interactive: true, zoom: 5, pitch: 60});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'], minzoom: 8});
await waitForMetadataEvent(map.getSource<RasterDEMTileSource>('dem'));
map.setTerrain({source: 'dem'});
const startElevation = 3750;
const elevationWithoutDem = 0;
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(startElevation);
map.redraw();
terrainElevation.mockReturnValue(elevationWithoutDem);
map.jumpTo({center: [1, 1]});
map.redraw();
expect(map.getCenterElevation()).toBe(elevationWithoutDem);
});
test('easeTo keeps the center elevation while no DEM data covers the destination', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
const startElevation = 1000;
const elevationForWantOfDem = 0;
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation((lnglat: LngLat) => lnglat.lat > 0.5 ? elevationForWantOfDem : startElevation);
map.redraw();
map.easeTo({center: [1, 1], duration: 1000, easing: k => k});
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(startElevation);
now.mockReturnValue(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(startElevation);
});
test('flyTo keeps the center elevation while no DEM data covers the destination', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
const startElevation = 1000;
const elevationForWantOfDem = 0;
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation((lnglat: LngLat) => lnglat.lat > 0.5 ? elevationForWantOfDem : startElevation);
map.redraw();
map.flyTo({center: [1, 1], duration: 1000, easing: k => k});
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(startElevation);
now.mockReturnValue(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(startElevation);
});
test('easeTo leaves the center elevation alone when the center is not clamped to the ground', async () => {
const map = await createMapOverTerrain(60);
map.setCenterClampedToGround(false);
map.setCenterElevation(1500);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(1000);
map.easeTo({center: [1, 1], duration: 1000});
now.mockReturnValue(1000);
map.redraw();
expect(map.getCenterElevation()).toBe(1500);
});
test('flyTo leaves the center elevation alone when the center is not clamped to the ground', async () => {
const map = await createMapOverTerrain(60);
map.setCenterClampedToGround(false);
map.setCenterElevation(1500);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(1000);
map.flyTo({center: [1, 1], duration: 1000});
now.mockReturnValue(1000);
map.redraw();
expect(map.getCenterElevation()).toBe(1500);
});
test('jumpTo leaves the center elevation alone when the center is not clamped to the ground', async () => {
const map = await createMapOverTerrain(60);
map.setCenterClampedToGround(false);
map.setCenterElevation(1500);
vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(1000);
map.jumpTo({center: [1, 1]});
expect(map.getCenterElevation()).toBe(1500);
});
test('a DEM minimum below sea level does not keep the map rendering when the center is not clamped to the ground', async () => {
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
const map = await createMapOverTerrain(60);
map.setCenterClampedToGround(false);
vi.spyOn(map.terrain, 'getMinTileElevationForLngLatZoom').mockReturnValue(-455.5);
const frameLength = 16;
const settleFrames = 110;
const watchedFrames = 20;
for (let frame = 0; frame < settleFrames; frame++) {
now.mockReturnValue(frame * frameLength);
map.redraw();
}
const repaint = vi.spyOn(map, 'triggerRepaint');
for (let frame = settleFrames; frame < settleFrames + watchedFrames; frame++) {
now.mockReturnValue(frame * frameLength);
map.redraw();
}
expect(repaint).not.toHaveBeenCalled();
});
test('easeTo around a point, as a double-click zoom does, eases the center elevation to the terrain under the center it ends on', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation((lnglat: LngLat) => lnglat.lat > 0.75 ? 2000 : lnglat.lat > 0.25 ? 1000 : 0);
map.easeTo({zoom: 12, around: [1, 1], duration: 1000, easing: k => k});
now.mockReturnValue(500);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(500);
now.mockReturnValue(1000);
map.redraw();
expect(map.getCenter().lat).toBeCloseTo(0.5, 3);
expect(map.getCameraTargetElevation()).toBe(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('easeTo and flyTo with an offset, each turning the bearing, ease the center elevation to the terrain under the center, not under the offset point', async () => {
const map = await createMapOverTerrain(60);
const now = vi.spyOn(timeControl, 'now').mockReturnValue(0);
const terrainElevation = vi.spyOn(map.terrain, 'getElevationForLngLat');
const offsetBelowCenter: [number, number] = [0, 100];
terrainElevation.mockImplementation((lnglat: LngLat) => lnglat.lng > 1.01 ? 1000 : lnglat.lat > 0.5 ? 500 : 0);
map.easeTo({center: [1, 1], zoom: 12, bearing: 90, offset: offsetBelowCenter, duration: 1000, easing: k => k});
now.mockReturnValue(900);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(900);
now.mockReturnValue(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(1000);
terrainElevation.mockImplementation((lnglat: LngLat) => lnglat.lat > 2.995 ? 2000 : lnglat.lat > 2.5 ? 3000 : 1000);
map.flyTo({center: [3, 3], zoom: 13, bearing: 180, offset: offsetBelowCenter, duration: 1000, easing: k => k});
now.mockReturnValue(1900);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(2800);
now.mockReturnValue(2000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(3000);
map.redraw();
expect(map.getCameraTargetElevation()).toBe(3000);
});
type TerrainHeight = (lng: number, lat: number) => number;
const frameMs = 1000 / 60;
const metersPerDegree = 2 * Math.PI * earthRadius / 360;
const wheelNotch = 25 * simulate.magicWheelZoomDelta;
const framesOfTheWheelEasing = 13;
const framesUntilTheZoomEnds = 15;
const framesPerWheelNotch = 4;
const movesPerDrag = 40;
function cameraMove(from: [number, number, number], to: [number, number, number]): number {
return Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2]);
}
function cameraPosition(map: Map): [number, number, number] {
const {lng, lat} = map._camera.transform.getCameraLngLat();
return [lng * metersPerDegree, lat * metersPerDegree, map._camera.transform.getCameraAltitude()];
}
function renderFrames(frame: () => void, count: number): void {
for (let i = 0; i < count; i++) frame();
}
/** Scrolls the wheel by `deltaY` at `point` `notches` times, {@link framesPerWheelNotch} frames after each, and returns how far the camera moved in each frame. */
function wheelNotches(map: Map, frame: () => void, deltaY: number, point: [number, number], notches: number): number[] {
const moves: number[] = [];
simulate.wheelNotches(map.getCanvas(), deltaY, point, notches, () => moves.push(...cameraMovesOverFrames(map, frame, framesPerWheelNotch)));
return moves;
}
/** Renders `count` frames and returns how far the camera moved in each. */
function cameraMovesOverFrames(map: Map, frame: () => void, count: number): number[] {
const moves: number[] = [];
for (let i = 0; i < count; i++) {
const before = cameraPosition(map);
frame();
moves.push(cameraMove(before, cameraPosition(map)));
}
return moves;
}
/** Renders `count` frames and returns how far south the camera moved in each; negative is north. */
function southwardCameraMovesOverFrames(map: Map, frame: () => void, count: number): number[] {
const moves: number[] = [];
for (let i = 0; i < count; i++) {
const before = cameraPosition(map);
frame();
moves.push(before[1] - cameraPosition(map)[1]);
}
return moves;
}
function createMapOverFlatTerrain(options: Partial<MapOptions>, elevation: number): Promise<{map: Map; frame: () => void}> {
return createMapOverShapedTerrain(options, () => elevation, elevation, elevation);
}
async function createMapOverShapedTerrain(options: Partial<MapOptions>, height: TerrainHeight, min: number, max: number): Promise<{map: Map; frame: () => void}> {
const timeControlNow = vi.spyOn(timeControl, 'now');
let now = 1555555555555;
timeControlNow.mockReturnValue(now);
const map = createMap({interactive: true, ...options});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png']});
map.setTerrain({source: 'dem'});
setTerrainHeight(map, height, min, max);
map.redraw();
vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
return {map, frame: () => {
now += frameMs;
timeControlNow.mockReturnValue(now);
vi.advanceTimersByTime(frameMs);
map.redraw();
}};
}
async function createMapUnderATerrainFloorAt20000Meters(options: Partial<MapOptions>): Promise<Map> {
const map = createMap({interactive: true, zoom: 11, pitch: 45, maxPitch: 85, ...options});
await map.once('load');
map.addSource('dem', {type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png']});
map.setTerrain({source: 'dem'});
vi.spyOn(map.terrain, 'getElevationForLngLat').mockReturnValue(0);
vi.spyOn(map.terrain, 'getElevationForLngLatZoom').mockReturnValue(20000);
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(createCoverageIndex(() => 20000, 20000, 20000));
map.redraw();
return map;
}
function lowestNearPlaneAltitude(map: Map): number {
return Math.min(...map._camera.transform.getCameraFrustum().points.slice(0, 4).map(corner => corner[2]));
}
function setTerrainHeight(map: Map, height: TerrainHeight, min: number, max: number): void {
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation(lngLat => height(lngLat.lng, lngLat.lat));
vi.spyOn(map.terrain, 'getElevationForLngLatZoom').mockImplementation(lngLat => height(lngLat.lng, lngLat.lat));
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(createCoverageIndex(height, min, max));
}
test('a pitch drag that would put the camera into the terrain lifts the center, and the camera with it, and keeps the pitch', async () => {
const map = await createMapUnderATerrainFloorAt20000Meters({});
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getPitch()).toBe(70);
expect(map.getCameraTargetElevation()).toBeCloseTo(16183.0, 1);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(20000, 0);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 80});
map._renderTaskQueue.run();
expect(map.getPitch()).toBe(80);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(20000, 0);
const cameraAltitude = map._camera.transform.getCameraAltitude();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 80});
map._renderTaskQueue.run();
expect(map.getPitch()).toBeCloseTo(80, 0);
expect(map._camera.transform.getCameraAltitude()).toBeGreaterThanOrEqual(cameraAltitude);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(20000, -1);
});
test('a pitch drag lifted out of the terrain keeps the elevation the DEM lands under the center once that puts the camera clear', async () => {
const {map, dem} = await createMapWithWaitingDem({zoom: 11, pitch: 45, maxPitch: 85});
map.setTerrain({source: 'dem'});
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(createCoverageIndex(() => 20000, 20000, 20000));
map.redraw();
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(20000, 0);
await landAllDemTiles(dem, 25000);
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 90});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(25000);
});
test('a pitch drag that brings a narrow ridge under the middle of the near clipping plane lifts the plane over the ridge', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 12, pitch: 55, maxPitch: 85}, 0);
const atTheDragsEnd = map._camera.transform.clone();
atTheDragsEnd.setPitch(60);
const corners = atTheDragsEnd.getCameraFrustum().points.slice(0, 4);
const middle = new MercatorCoordinate(corners.reduce((sum, c) => sum + c[0], 0) / 4, corners.reduce((sum, c) => sum + c[1], 0) / 4).toLngLat();
const middleAltitude = corners.reduce((sum, c) => sum + c[2], 0) / 4;
const ridgeAboveTheMiddle = 8;
const halfWidthInDegrees = 10 / metersPerDegree;
setTerrainHeight(map, (_lng, lat) => {
const d = (lat - middle.lat) / halfWidthInDegrees;
return Math.abs(d) < 1 ? (middleAltitude + ridgeAboveTheMiddle) * (1 - d * d) : 0;
}, 0, middleAltitude + ridgeAboveTheMiddle);
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 140});
frame();
expect(map.getPitch()).toBeCloseTo(60, 6);
expect(map.getCameraTargetElevation()).toBeCloseTo(ridgeAboveTheMiddle, 3);
});
test('a pitch drag held over a floor that ends looking where no terrain is drawn leaves the camera on the floor', async () => {
const map = await createMapUnderATerrainFloorAt20000Meters({});
vi.spyOn(map.terrain, 'getElevationForLngLat').mockImplementation((lngLat: LngLat) => lngLat.lat < -0.001 ? 10000 : 0);
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(null);
map.redraw();
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
map.redraw();
expect(map._camera.transform.getCameraAltitude()).toBeCloseTo(10000, 1);
});
test('a pitch drag over terrain whose DEM has not loaded keeps the near clipping plane above the flat surface drawn in its place', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 17, pitch: 30, maxPitch: 85}, -500);
const drawnWhileItsDemLoads = createCoverageIndex(() => 0, 0, 0);
drawnWhileItsDemLoads.samplerPerTile.set('0/0/0/0', null);
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(drawnWhileItsDemLoads);
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
frame();
expect(map.getPitch()).toBeCloseTo(55, 6);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(0, 1);
});
test('a transformCameraUpdate that sets the center elevation during a pitch drag into the terrain leaves the camera and its near clipping plane above it', async () => {
const map = await createMapUnderATerrainFloorAt20000Meters({transformCameraUpdate: () => ({elevation: 0})});
simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 100, clientY: 150});
simulate.mousemove(window.document.body, {buttons: 2, clientX: 100, clientY: 100});
map._renderTaskQueue.run();
expect(map.getCameraTargetElevation()).toBe(0);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(20000, 0);
});
test('a rotate drag that swings the camera over a hill and past it lowers the camera again, to where it started', async () => {
const hill: TerrainHeight = (lng, lat) => {
const d = Math.hypot(lng * metersPerDegree + 600, lat * metersPerDegree + 300) / 250;
return d < 1 ? 450 * (1 - d * d) : 0;
};
const {map, frame} = await createMapOverShapedTerrain({zoom: 15, pitch: 70, maxPitch: 85}, hill, 0, 450);
const start = cameraPosition(map);
simulate.dragInMoves(map.getCanvas(), [40, 100], [160, 100], movesPerDrag, frame, 2);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 160, clientY: 100});
renderFrames(frame, 90);
expect(map.getZoom()).toBeCloseTo(15, 6);
expect(map._camera.transform.getCameraAltitude()).toBeCloseTo(start[2], 1);
});
test('an easeTo that would end with the camera inside the terrain ends with the camera and its near clipping plane above it', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 13, pitch: 60}, 0);
const cliffSouthEdge = -0.01;
setTerrainHeight(map, (_lng, lat) => lat < cliffSouthEdge ? 5000 : 0, 0, 5000);
map.easeTo({pitch: 70, duration: 500});
renderFrames(frame, 45);
expect(map.getCameraTargetElevation()).toBe(0);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(5000, 0);
});
test('a jumpTo whose camera would be inside the terrain raises the camera and its near clipping plane above it, where the camera was', async () => {
const {map} = await createMapOverFlatTerrain({zoom: 13, pitch: 45, maxPitch: 85}, 0);
const cliffSouthEdge = -0.01;
setTerrainHeight(map, (_lng, lat) => lat < cliffSouthEdge ? 3000 : 0, 0, 3000);
const asked = map._camera.transform.clone();
asked.setZoom(13.5);
asked.setPitch(60);
map.jumpTo({zoom: 13.5, pitch: 60});
expect(map.getCameraTargetElevation()).toBe(0);
expect(map.getZoom()).toBeCloseTo(12.7034, 4);
expect(map.getPitch()).toBeCloseTo(29.9058, 4);
expect(lowestNearPlaneAltitude(map)).toBeCloseTo(3000, 1);
expect(map._camera.transform.getCameraLngLat().lat).toBeCloseTo(asked.getCameraLngLat().lat, 9);
});
test('a pitch drag past 90 degrees over terrain that rests before its release leaves the camera where it rested', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 14, pitch: 60, maxPitch: 110}, 500);
simulate.dragInMoves(map.getCanvas(), [100, 190], [100, 0], movesPerDrag, frame, 2);
renderFrames(frame, 10);
const camera = cameraPosition(map);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 100, clientY: 0});
renderFrames(frame, 30);
expect(cameraMove(camera, cameraPosition(map))).toBeLessThan(0.01);
});
function ridges(list: Array<{y: number; height: number; width: number}>): TerrainHeight {
return (_lng, lat) => list.reduce((elevation, ridge) => {
const d = (lat * metersPerDegree - ridge.y) / ridge.width;
return Math.abs(d) < 1 ? elevation + ridge.height * (1 - d * d) : elevation;
}, 0);
}
async function cameraMovesZoomingOutOver(height: TerrainHeight, max: number): Promise<number[]> {
const {map, frame} = await createMapOverShapedTerrain({zoom: 16.2, pitch: 60, maxZoom: 18}, height, 0, max);
const moves = wheelNotches(map, frame, wheelNotch, [111, 124], 4);
vi.useRealTimers();
return moves;
}
test('a wheel zoom over terrain that rose under the held center moves the center onto that terrain, and its end leaves the camera and the zoom where they were', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 12, pitch: 70, maxZoom: 18}, 0);
simulate.wheel(map.getCanvas(), {deltaY: -4 * wheelNotch, clientX: 100, clientY: 100});
frame();
setTerrainHeight(map, () => 1000, 1000, 1000);
wheelNotches(map, frame, -4 * wheelNotch, [100, 100], 12);
renderFrames(frame, framesOfTheWheelEasing);
expect(map.isMoving()).toBe(true);
expect(map.getCameraTargetElevation()).toBe(1000);
const zoom = map.getZoom();
const camera = cameraPosition(map);
renderFrames(frame, framesUntilTheZoomEnds);
expect(map.isMoving()).toBe(false);
expect(map.getCameraTargetElevation()).toBe(1000);
expect(map.getZoom()).toBe(zoom);
expect(cameraMove(camera, cameraPosition(map))).toBeLessThan(0.01);
});
test('a wheel zoom-out over a crest between the camera and the center moves the camera as it would over flat ground at the center\'s elevation', async () => {
const crest = ridges([{y: -157.7, height: 878.4, width: 318.2}]);
const overTheCrest = await cameraMovesZoomingOutOver(crest, 880);
const centerElevation = crest(0, 0);
const overFlatGround = await cameraMovesZoomingOutOver(() => centerElevation, centerElevation);
expect(Math.max(...overTheCrest.map((move, i) => Math.abs(move - overFlatGround[i])))).toBeLessThan(1e-5);
});
test('a wheel zoom whose screen center slips over a crest never speeds up more than 2.3 times from one frame to the next', async () => {
const terrain = ridges([{y: -423.8, height: 652.7, width: 363.3}, {y: 8.26, height: 876.5, width: 301.4}, {y: -852.2, height: 382.6, width: 300.4}]);
const {map, frame} = await createMapOverShapedTerrain({zoom: 14.6, pitch: 70, bearing: 30, maxPitch: 85, maxZoom: 18}, terrain, 0, 2020);
const moves = wheelNotches(map, frame, -wheelNotch, [23, 79], 13);
const speedUps = moves.slice(1).map((move, i) => moves[i] > 1 ? move / moves[i] : 0);
expect(Math.max(...speedUps)).toBeLessThan(2.3);
});
test('a wheel zoom around a point over terrain that falls away under the center follows it down, and its end leaves the camera where it was', async () => {
const fallingSouthward: TerrainHeight = (_lng, lat) => 1000 + 0.2 * lat * metersPerDegree;
const {map, frame} = await createMapOverShapedTerrain({zoom: 15, pitch: 60, maxZoom: 18}, fallingSouthward, 0, 2000);
wheelNotches(map, frame, -wheelNotch, [100, 170], 6);
renderFrames(frame, framesOfTheWheelEasing);
expect(map.isMoving()).toBe(true);
expect(map.getCameraTargetElevation()).toBeCloseTo(fallingSouthward(map.getCenter().lng, map.getCenter().lat), 1);
const camera = cameraPosition(map);
renderFrames(frame, framesUntilTheZoomEnds);
expect(map.isMoving()).toBe(false);
expect(cameraMove(camera, cameraPosition(map))).toBeLessThan(0.001);
});
test('a wheel zoom-in toward terrain nearer than maxZoom allows never moves the camera back', async () => {
const blockSouthEdge = -0.06, blockNorthEdge = -0.04;
const block: TerrainHeight = (_lng, lat) => lat > blockSouthEdge && lat < blockNorthEdge ? 500 : 0;
const {map, frame} = await createMapOverShapedTerrain({center: [0.1758, -0.03], zoom: 12, pitch: 80, maxPitch: 85, maxZoom: 12.5}, block, 0, 500);
simulate.wheel(map.getCanvas(), {deltaY: -wheelNotch, clientX: 100, clientY: 100});
const southwardMoves = southwardCameraMovesOverFrames(map, frame, framesOfTheWheelEasing + framesUntilTheZoomEnds);
expect(map.isMoving()).toBe(false);
expect(Math.max(...southwardMoves)).toBeLessThanOrEqual(0);
});
test('a drag at maxZoom toward a slope ends with the camera where the drag left it', async () => {
const slopeRisingNorth: TerrainHeight = (_lng, lat) => Math.max(0, 0.5 * (lat * metersPerDegree - 20));
const {map, frame} = await createMapOverShapedTerrain({zoom: 18, pitch: 70, maxPitch: 85, maxZoom: 18}, slopeRisingNorth, 0, 1000);
simulate.dragInMoves(map.getCanvas(), [100, 50], [100, 150], movesPerDrag, frame);
renderFrames(frame, 10);
const cameraAtRelease = cameraPosition(map);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 150});
renderFrames(frame, 30);
const cameraMoveAfterRelease = cameraMove(cameraAtRelease, cameraPosition(map));
expect(cameraMoveAfterRelease).toBeLessThan(0.01);
});
test('a flick at maxZoom toward a slope never moves the camera back, through the inertia and its end', async () => {
const slopeRisingNorth: TerrainHeight = (_lng, lat) => Math.max(0, 0.5 * (lat * metersPerDegree - 20));
const {map, frame} = await createMapOverShapedTerrain({zoom: 18, pitch: 70, maxPitch: 85, maxZoom: 18}, slopeRisingNorth, 0, 1000);
simulate.dragInMoves(map.getCanvas(), [100, 50], [100, 150], 6, frame);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 150});
const southwardMoves = southwardCameraMovesOverFrames(map, frame, 90);
const largestMoveBack = Math.max(...southwardMoves);
expect(map.isMoving()).toBe(false);
expect(largestMoveBack).toBeLessThanOrEqual(0);
});
test('a rotate drag below maxZoom, swinging a ridge between the camera and the center, ends with the camera where the drag left it', async () => {
const ridgeWestOfTheCenter: TerrainHeight = (lng) => Math.abs(lng * metersPerDegree + 36) < 8 ? 30 : 0;
const {map, frame} = await createMapOverShapedTerrain({zoom: 17.5, pitch: 75, maxPitch: 85, maxZoom: 18}, ridgeWestOfTheCenter, 0, 30);
simulate.dragInMoves(map.getCanvas(), [100, 100], [212, 100], movesPerDrag, frame, 2);
renderFrames(frame, 10);
const cameraAtRelease = cameraPosition(map);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2, clientX: 212, clientY: 100});
renderFrames(frame, 30);
const cameraMoveAfterRelease = cameraMove(cameraAtRelease, cameraPosition(map));
expect(cameraMoveAfterRelease).toBeLessThan(0.01);
});
test('a drag at maxZoom toward a slope, starting before the terrain is drawn, ends with the camera where the drag left it', async () => {
const slopeRisingNorth: TerrainHeight = (_lng, lat) => Math.max(0, 0.5 * (lat * metersPerDegree - 20));
const {map, frame} = await createMapOverShapedTerrain({zoom: 18, pitch: 70, maxPitch: 85, maxZoom: 18}, slopeRisingNorth, 0, 1000);
const drawn = map.terrain.getCoverageIndex();
const coverageIndex = vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(null);
simulate.dragInMoves(map.getCanvas(), [100, 50], [100, 55], 1, frame);
coverageIndex.mockReturnValue(drawn);
simulate.dragInMoves(map.getCanvas(), [100, 55], [100, 150], movesPerDrag, frame);
renderFrames(frame, 10);
const cameraAtRelease = cameraPosition(map);
simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, clientX: 100, clientY: 150});
renderFrames(frame, 30);
const cameraMoveAfterRelease = cameraMove(cameraAtRelease, cameraPosition(map));
expect(cameraMoveAfterRelease).toBeLessThan(0.01);
});
test('a wheel zoom over a rise in the terrain under the center that is not drawn yet leaves the held center elevation alone', async () => {
const slopeRisingNorth = (rise: number): TerrainHeight => (_lng, lat) => 1000 + rise + 50000 * lat;
const {map, frame} = await createMapOverShapedTerrain({zoom: 14, pitch: 60, maxZoom: 18}, slopeRisingNorth(0), 0, 2000);
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(null);
simulate.wheel(map.getCanvas(), {deltaY: -wheelNotch, clientX: 100, clientY: 100});
frame();
setTerrainHeight(map, slopeRisingNorth(500), 0, 2000);
vi.spyOn(map.terrain, 'getCoverageIndex').mockReturnValue(null);
renderFrames(frame, 8);
expect(map.getCameraTargetElevation()).toBe(1000);
});
test('a wheel zoom with the center not clamped to the ground leaves the center elevation where it was', async () => {
const {map, frame} = await createMapOverFlatTerrain({zoom: 12, pitch: 70, maxZoom: 18, centerClampedToGround: false}, 1000);
wheelNotches(map, frame, -4 * wheelNotch, [100, 100], 4);
expect(map.getCameraTargetElevation()).toBe(0);
});
});
describe('Keep camera outside terrain', () => {
test('Try to move camera into terrain', () => {
let terrainElevation = 10;
const terrainStub = {} as Terrain;
terrainStub.getElevationForLngLat = vi.fn(() => terrainElevation);
terrainStub.getElevationAndDEMStateForLngLat = () => ({elevation: terrainElevation, demState: 'loaded'});
terrainStub.getCoverageIndex = () => null;
map.terrain = terrainStub;
map._camera.terrain = terrainStub;
// Terrain elevation is 10 everywhere, we are above it at zoom level 15
// with pitch 45 deg.
map.jumpTo({center: [0.0, 0.0], bearing: 0, pitch: 45, zoom: 15});
const initialLngLat = map._camera.transform.screenPointToLocation(map._camera.transform.getCameraPoint());
const initialAltitude = map._camera.transform.getCameraAltitude();
expect(initialAltitude).toBeCloseTo(516, 0);
// Now we set the elevation to 5000 everywhere and try to jump to the
// same position. This would lead to a jump into the terrain, which
// must not be possible.
// Camera should be above the terrain, but at the same location as
// before and with decreased pitch.
terrainElevation = 5000;
map.jumpTo({center: [0.0, 0.0], pitch: 45, zoom: 15});
const lngLat = map._camera.transform.screenPointToLocation(map._camera.transform.getCameraPoint());
expect(lngLat.lng).toBeCloseTo(initialLngLat.lng);
expect(lngLat.lat).toBeCloseTo(initialLngLat.lat);
expect(map._camera.transform.getCameraAltitude()).toBeGreaterThan(initialAltitude);
expect(map._camera.transform.getCameraAltitude()).toBeGreaterThan(terrainElevation);
});
});
describe('queryTerrainElevation', () => {
test('should return null if terrain is not set', () => {
map.terrain = null;
const result = map.queryTerrainElevation([0, 0]);
expect(result).toBeNull();
});
test('calls getElevationForLngLat with the location and the transform', () => {
const getElevationForLngLat = vi.fn();
map.terrain = {getElevationForLngLat} as any as Terrain;
map._camera.transform = createMercatorTransform({minZoom: 0, maxZoom: 22, minPitch: 0, maxPitch: 60, renderWorldCopies: true});
map.queryTerrainElevation([1, 2]);
expect(map.terrain.getElevationForLngLat).toHaveBeenCalledWith(
expect.objectContaining({lng: 1, lat: 2,}),
map._camera.transform
);
});
});