UNPKG

maplibre-gl

Version:

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

1,393 lines (1,186 loc) 106 kB
import {Map, MapOptions} from './map'; import {createMap, setErrorWebGlContext, beforeMapTest} from '../util/test/util'; import {LngLat} from '../geo/lng_lat'; import {Tile} from '../source/tile'; import {OverscaledTileID} from '../source/tile_id'; import {Event, ErrorEvent} from '../util/evented'; import simulate from '../../test/unit/lib/simulate_interaction'; import {fixedLngLat, fixedNum} from '../../test/unit/lib/fixed'; import {GeoJSONSourceSpecification, LayerSpecification, SourceSpecification, StyleSpecification} from '@maplibre/maplibre-gl-style-spec'; import {RequestTransformFunction} from '../util/request_manager'; import {extend} from '../util/util'; import {LngLatBoundsLike} from '../geo/lng_lat_bounds'; import {IControl} from './control/control'; import {EvaluationParameters} from '../style/evaluation_parameters'; import {fakeServer, FakeServer} from 'nise'; import {CameraOptions} from './camera'; import {Terrain} from '../render/terrain'; import {mercatorZfromAltitude} from '../geo/mercator_coordinate'; import {Transform} from '../geo/transform'; import {StyleImageInterface} from '../style/style_image'; import {Style} from '../style/style'; import {MapSourceDataEvent} from './events'; import {config} from '../util/config'; function createStyleSource() { return { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } as SourceSpecification; } let server: FakeServer; beforeEach(() => { beforeMapTest(); global.fetch = null; server = fakeServer.create(); }); afterEach(() => { server.restore(); }); describe('Map', () => { test('version', () => { const map = createMap({interactive: true, style: null}); expect(typeof map.version === 'string').toBeTruthy(); // Semver regex: https://gist.github.com/jhorsman/62eeea161a13b80e39f5249281e17c39 // Backslashes are doubled to escape them const regexp = new RegExp('^([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+[0-9A-Za-z-]+)?$'); expect(regexp.test(map.version)).toBeTruthy(); }); test('constructor', () => { const map = createMap({interactive: true, style: null}); expect(map.getContainer()).toBeTruthy(); expect(map.getStyle()).toBeUndefined(); expect(map.boxZoom.isEnabled()).toBeTruthy(); expect(map.doubleClickZoom.isEnabled()).toBeTruthy(); expect(map.dragPan.isEnabled()).toBeTruthy(); expect(map.dragRotate.isEnabled()).toBeTruthy(); expect(map.keyboard.isEnabled()).toBeTruthy(); expect(map.scrollZoom.isEnabled()).toBeTruthy(); expect(map.touchZoomRotate.isEnabled()).toBeTruthy(); expect(() => { new Map({ container: 'anElementIdWhichDoesNotExistInTheDocument' } as any as MapOptions); }).toThrow( new Error('Container \'anElementIdWhichDoesNotExistInTheDocument\' not found.') ); }); test('bad map-specific token breaks map', () => { const container = window.document.createElement('div'); Object.defineProperty(container, 'offsetWidth', {value: 512}); Object.defineProperty(container, 'offsetHeight', {value: 512}); createMap(); //t.error(); }); test('initial bounds in constructor options', () => { const container = window.document.createElement('div'); Object.defineProperty(container, 'offsetWidth', {value: 512}); Object.defineProperty(container, 'offsetHeight', {value: 512}); const bounds = [[-133, 16], [-68, 50]]; const map = createMap({container, bounds}); expect(fixedLngLat(map.getCenter(), 4)).toEqual({lng: -100.5, lat: 34.7171}); expect(fixedNum(map.getZoom(), 3)).toBe(2.113); }); test('initial bounds options in constructor options', () => { const bounds = [[-133, 16], [-68, 50]]; const map = (fitBoundsOptions) => { const container = window.document.createElement('div'); Object.defineProperty(container, 'offsetWidth', {value: 512}); Object.defineProperty(container, 'offsetHeight', {value: 512}); return createMap({container, bounds, fitBoundsOptions}); }; const unpadded = map(undefined); const padded = map({padding: 100}); expect(unpadded.getZoom() > padded.getZoom()).toBeTruthy(); }); describe('disables handlers', () => { test('disables all handlers', () => { const map = createMap({interactive: false}); expect(map.boxZoom.isEnabled()).toBeFalsy(); expect(map.doubleClickZoom.isEnabled()).toBeFalsy(); expect(map.dragPan.isEnabled()).toBeFalsy(); expect(map.dragRotate.isEnabled()).toBeFalsy(); expect(map.keyboard.isEnabled()).toBeFalsy(); expect(map.scrollZoom.isEnabled()).toBeFalsy(); expect(map.touchZoomRotate.isEnabled()).toBeFalsy(); }); const handlerNames = [ 'scrollZoom', 'boxZoom', 'dragRotate', 'dragPan', 'keyboard', 'doubleClickZoom', 'touchZoomRotate' ]; handlerNames.forEach((handlerName) => { test(`disables "${handlerName}" handler`, () => { const options = {}; options[handlerName] = false; const map = createMap(options); expect(map[handlerName].isEnabled()).toBeFalsy(); }); }); }); test('emits load event after a style is set', done => { const map = new Map({container: window.document.createElement('div')} as any as MapOptions); const fail = () => done('test failed'); const pass = () => done(); map.on('load', fail); setTimeout(() => { map.off('load', fail); map.on('load', pass); map.setStyle(createStyle()); }, 1); }); describe('#mapOptions', () => { test('maxTileCacheZoomLevels: Default value is set', () => { const map = createMap(); expect(map._maxTileCacheZoomLevels).toBe(config.MAX_TILE_CACHE_ZOOM_LEVELS); }); test('maxTileCacheZoomLevels: Value can be set via map options', () => { const map = createMap({maxTileCacheZoomLevels: 1}); expect(map._maxTileCacheZoomLevels).toBe(1); }); test('Style validation is enabled by default', () => { let validationOption = false; jest.spyOn(Style.prototype, 'loadJSON').mockImplementationOnce((styleJson, options) => { validationOption = options.validate; }); createMap(); expect(validationOption).toBeTruthy(); }); test('Style validation disabled using mapOptions', () => { let validationOption = true; jest.spyOn(Style.prototype, 'loadJSON').mockImplementationOnce((styleJson, options) => { validationOption = options.validate; }); createMap({validateStyle: false}); expect(validationOption).toBeFalsy(); }); test('fadeDuration is set after first idle event', async () => { let idleTriggered = false; const fadeDuration = 100; const spy = jest.spyOn(Style.prototype, 'update').mockImplementation((parameters: EvaluationParameters) => { if (!idleTriggered) { expect(parameters.fadeDuration).toBe(0); } else { expect(parameters.fadeDuration).toBe(fadeDuration); } }); const style = createStyle(); const map = createMap({style, fadeDuration}); await map.once('idle'); idleTriggered = true; map.zoomTo(0.5, {duration: 100}); spy.mockRestore(); }); }); describe('#setStyle', () => { test('returns self', () => { const map = new Map({container: window.document.createElement('div')} as any as MapOptions); expect(map.setStyle({ version: 8, sources: {}, layers: [] })).toBe(map); }); test('sets up event forwarding', () => { createMap({}, (error, map) => { expect(error).toBeFalsy(); const events = []; function recordEvent(event) { events.push(event.type); } map.on('error', recordEvent); map.on('data', recordEvent); map.on('dataloading', recordEvent); map.style.fire(new Event('error')); map.style.fire(new Event('data')); map.style.fire(new Event('dataloading')); expect(events).toEqual([ 'error', 'data', 'dataloading', ]); }); }); test('fires *data and *dataloading events', () => { createMap({}, (error, map) => { expect(error).toBeFalsy(); const events = []; function recordEvent(event) { events.push(event.type); } map.on('styledata', recordEvent); map.on('styledataloading', recordEvent); map.on('sourcedata', recordEvent); map.on('sourcedataloading', recordEvent); map.on('tiledata', recordEvent); map.on('tiledataloading', recordEvent); map.style.fire(new Event('data', {dataType: 'style'})); map.style.fire(new Event('dataloading', {dataType: 'style'})); map.style.fire(new Event('data', {dataType: 'source'})); map.style.fire(new Event('dataloading', {dataType: 'source'})); map.style.fire(new Event('data', {dataType: 'tile'})); map.style.fire(new Event('dataloading', {dataType: 'tile'})); expect(events).toEqual([ 'styledata', 'styledataloading', 'sourcedata', 'sourcedataloading', 'tiledata', 'tiledataloading' ]); }); }); test('can be called more than once', () => { const map = createMap(); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); }); test('setStyle back to the first style should work', done => { const redStyle = {version: 8 as const, sources: {}, layers: [ {id: 'background', type: 'background' as const, paint: {'background-color': 'red'}}, ]}; const blueStyle = {version: 8 as const, sources: {}, layers: [ {id: 'background', type: 'background' as const, paint: {'background-color': 'blue'}}, ]}; const map = createMap({style: redStyle}); map.setStyle(blueStyle); map.once('style.load', () => { map.setStyle(redStyle); const serializedStyle = map.style.serialize(); expect(serializedStyle.layers[0].paint['background-color']).toBe('red'); done(); }); }); test('style transform overrides unmodified map transform', done => { const map = new Map({container: window.document.createElement('div')} as any as MapOptions); map.transform.lngRange = [-120, 140]; map.transform.latRange = [-60, 80]; map.transform.resize(600, 400); expect(map.transform.zoom).toBe(0.6983039737971014); expect(map.transform.unmodified).toBeTruthy(); map.setStyle(createStyle()); map.on('style.load', () => { expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -73.9749, lat: 40.7736})); expect(fixedNum(map.transform.zoom)).toBe(12.5); expect(fixedNum(map.transform.bearing)).toBe(29); expect(fixedNum(map.transform.pitch)).toBe(50); done(); }); }); test('style transform does not override map transform modified via options', done => { const map = new Map({container: window.document.createElement('div'), zoom: 10, center: [-77.0186, 38.8888]} as any as MapOptions); expect(map.transform.unmodified).toBeFalsy(); map.setStyle(createStyle()); map.on('style.load', () => { expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888})); expect(fixedNum(map.transform.zoom)).toBe(10); expect(fixedNum(map.transform.bearing)).toBe(0); expect(fixedNum(map.transform.pitch)).toBe(0); done(); }); }); test('style transform does not override map transform modified via setters', done => { const map = new Map({container: window.document.createElement('div')} as any as MapOptions); expect(map.transform.unmodified).toBeTruthy(); map.setZoom(10); map.setCenter([-77.0186, 38.8888]); expect(map.transform.unmodified).toBeFalsy(); map.setStyle(createStyle()); map.on('style.load', () => { expect(fixedLngLat(map.transform.center)).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888})); expect(fixedNum(map.transform.zoom)).toBe(10); expect(fixedNum(map.transform.bearing)).toBe(0); expect(fixedNum(map.transform.pitch)).toBe(0); done(); }); }); test('passing null removes style', () => { const map = createMap(); const style = map.style; expect(style).toBeTruthy(); jest.spyOn(style, '_remove'); map.setStyle(null); expect(style._remove).toHaveBeenCalledTimes(1); }); test('passing null releases the worker', () => { const map = createMap(); const spyWorkerPoolAcquire = jest.spyOn(map.style.dispatcher.workerPool, 'acquire'); const spyWorkerPoolRelease = jest.spyOn(map.style.dispatcher.workerPool, 'release'); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); expect(spyWorkerPoolAcquire).toHaveBeenCalledTimes(1); expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(0); spyWorkerPoolAcquire.mockClear(); map.setStyle(null); expect(spyWorkerPoolAcquire).toHaveBeenCalledTimes(0); expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(1); // Cleanup spyWorkerPoolAcquire.mockClear(); spyWorkerPoolRelease.mockClear(); }); test('transformStyle should copy the source and the layer into next style', done => { const style = extend(createStyle(), { sources: { maplibre: { type: 'vector', minzoom: 1, maxzoom: 10, tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, layers: [{ id: 'layerId0', type: 'circle', source: 'maplibre', 'source-layer': 'sourceLayer' }, { id: 'layerId1', type: 'circle', source: 'maplibre', 'source-layer': 'sourceLayer' }] }); const map = createMap({style}); map.setStyle(createStyle(), { diff: false, transformStyle: (prevStyle, nextStyle) => ({ ...nextStyle, sources: { ...nextStyle.sources, maplibre: prevStyle.sources.maplibre }, layers: [ ...nextStyle.layers, prevStyle.layers[0] ] }) }); map.on('style.load', () => { const loadedStyle = map.style.serialize(); expect('maplibre' in loadedStyle.sources).toBeTruthy(); expect(loadedStyle.layers[0].id).toBe(style.layers[0].id); expect(loadedStyle.layers).toHaveLength(1); done(); }); }); test('delayed setStyle with transformStyle should copy the source and the layer into next style with diffing', done => { const style = extend(createStyle(), { sources: { maplibre: { type: 'vector', minzoom: 1, maxzoom: 10, tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, layers: [{ id: 'layerId0', type: 'circle', source: 'maplibre', 'source-layer': 'sourceLayer' }, { id: 'layerId1', type: 'circle', source: 'maplibre', 'source-layer': 'sourceLayer' }] }); const map = createMap({style}); window.setTimeout(() => { map.setStyle(createStyle(), { diff: true, transformStyle: (prevStyle, nextStyle) => ({ ...nextStyle, sources: { ...nextStyle.sources, maplibre: prevStyle.sources.maplibre }, layers: [ ...nextStyle.layers, prevStyle.layers[0] ] }) }); const loadedStyle = map.style.serialize(); expect('maplibre' in loadedStyle.sources).toBeTruthy(); expect(loadedStyle.layers[0].id).toBe(style.layers[0].id); expect(loadedStyle.layers).toHaveLength(1); done(); }, 100); }); test('transformStyle should get called when passed to setStyle after the map is initialised without a style', done => { const map = createMap({deleteStyle: true}); map.setStyle(createStyle(), { diff: true, transformStyle: (prevStyle, nextStyle) => { expect(prevStyle).toBeUndefined(); return { ...nextStyle, sources: { maplibre: { type: 'vector', minzoom: 1, maxzoom: 10, tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, layers: [{ id: 'layerId0', type: 'circle', source: 'maplibre', 'source-layer': 'sourceLayer' }] }; } }); map.on('style.load', () => { const loadedStyle = map.style.serialize(); expect('maplibre' in loadedStyle.sources).toBeTruthy(); expect(loadedStyle.layers[0].id).toBe('layerId0'); done(); }); }); test('map load should be fired when transformStyle is used on setStyle after the map is initialised without a style', done => { const map = createMap({deleteStyle: true}); map.setStyle({version: 8, sources: {}, layers: []}, { diff: true, transformStyle: (prevStyle, nextStyle) => { expect(prevStyle).toBeUndefined(); expect(nextStyle).toBeDefined(); return createStyle(); } }); map.on('load', () => done()); }); test('Override default style validation', () => { let validationOption = true; jest.spyOn(Style.prototype, 'loadJSON').mockImplementationOnce((styleJson, options) => { validationOption = options.validate; }); const map = createMap({style: null}); map.setStyle({version: 8, sources: {}, layers: []}, {validate: false}); expect(validationOption).toBeFalsy(); }); }); describe('#setTransformRequest', () => { test('returns self', () => { const transformRequest = (() => {}) as any as RequestTransformFunction; const map = new Map({container: window.document.createElement('div')} as any as MapOptions); expect(map.setTransformRequest(transformRequest)).toBe(map); expect(map._requestManager._transformRequestFn).toBe(transformRequest); }); test('can be called more than once', () => { const map = createMap(); const transformRequest = (() => {}) as any as RequestTransformFunction; map.setTransformRequest(transformRequest); map.setTransformRequest(transformRequest); }); }); describe('#is_Loaded', () => { test('Map#isSourceLoaded', done => { const style = createStyle(); const map = createMap({style}); map.on('load', () => { map.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'idle') { expect(map.isSourceLoaded('geojson')).toBe(true); done(); } }); map.addSource('geojson', createStyleSource()); expect(map.isSourceLoaded('geojson')).toBe(false); }); }); test('Map#isSourceLoaded (equivalent to event.isSourceLoaded)', done => { const style = createStyle(); const map = createMap({style}); map.on('load', () => { map.on('data', (e) => { if (e.dataType === 'source' && 'source' in e) { const sourceDataEvent = e as MapSourceDataEvent; expect(map.isSourceLoaded('geojson')).toBe(sourceDataEvent.isSourceLoaded); if (sourceDataEvent.sourceDataType === 'idle') { done(); } } }); map.addSource('geojson', createStyleSource()); expect(map.isSourceLoaded('geojson')).toBe(false); }); }); test('Map#isStyleLoaded', done => { const style = createStyle(); const map = createMap({style}); expect(map.isStyleLoaded()).toBe(false); map.on('load', () => { expect(map.isStyleLoaded()).toBe(true); done(); }); }); test('Map#areTilesLoaded', done => { const style = createStyle(); const map = createMap({style}); expect(map.areTilesLoaded()).toBe(true); map.on('load', () => { const fakeTileId = new OverscaledTileID(0, 0, 0, 0, 0); map.addSource('geojson', createStyleSource()); map.style.sourceCaches.geojson._tiles[fakeTileId.key] = new Tile(fakeTileId, undefined); expect(map.areTilesLoaded()).toBe(false); map.style.sourceCaches.geojson._tiles[fakeTileId.key].state = 'loaded'; expect(map.areTilesLoaded()).toBe(true); done(); }); }); }); describe('#getStyle', () => { test('returns undefined if the style has not loaded yet', done => { const style = createStyle(); const map = createMap({style}); expect(map.getStyle()).toBeUndefined(); done(); }); test('returns the style', done => { const style = createStyle(); const map = createMap({style}); map.on('load', () => { expect(map.getStyle()).toEqual(style); done(); }); }); test('returns the style with added sources', done => { const style = createStyle(); const map = createMap({style}); map.on('load', () => { map.addSource('geojson', createStyleSource()); expect(map.getStyle()).toEqual(extend(createStyle(), { sources: {geojson: createStyleSource()} })); done(); }); }); test('fires an error on checking if non-existant source is loaded', done => { const style = createStyle(); const map = createMap({style}); map.on('load', () => { map.on('error', ({error}) => { expect(error.message).toMatch(/There is no source with ID/); done(); }); map.isSourceLoaded('geojson'); }); }); test('returns the style with added layers', done => { const style = createStyle(); const map = createMap({style}); const layer = { id: 'background', type: 'background' } as LayerSpecification; map.on('load', () => { map.addLayer(layer); expect(map.getStyle()).toEqual(extend(createStyle(), { layers: [layer] })); done(); }); }); test('a layer can be added even if a map is created without a style', () => { const map = createMap({deleteStyle: true}); const layer = { id: 'background', type: 'background' } as LayerSpecification; map.addLayer(layer); }); test('a source can be added even if a map is created without a style', () => { const map = createMap({deleteStyle: true}); const source = createStyleSource(); map.addSource('fill', source); }); test('a layer can be added with an embedded source specification', () => { const map = createMap({deleteStyle: true}); const source: GeoJSONSourceSpecification = { type: 'geojson', data: {type: 'Point', coordinates: [0, 0]} }; map.addLayer({ id: 'foo', type: 'symbol', source }); }); test('returns the style with added source and layer', done => { const style = createStyle(); const map = createMap({style}); const source = createStyleSource(); const layer = { id: 'fill', type: 'fill', source: 'fill' } as LayerSpecification; map.on('load', () => { map.addSource('fill', source); map.addLayer(layer); expect(map.getStyle()).toEqual(extend(createStyle(), { sources: {fill: source}, layers: [layer] })); done(); }); }); test('creates a new Style if diff fails', () => { const style = createStyle(); const map = createMap({style}); jest.spyOn(map.style, 'setState').mockImplementation(() => { throw new Error('Dummy error'); }); jest.spyOn(console, 'warn').mockImplementation(() => {}); const previousStyle = map.style; map.setStyle(style); expect(map.style && map.style !== previousStyle).toBeTruthy(); }); test('creates a new Style if diff option is false', () => { const style = createStyle(); const map = createMap({style}); const spy = jest.spyOn(map.style, 'setState'); const previousStyle = map.style; map.setStyle(style, {diff: false}); expect(map.style && map.style !== previousStyle).toBeTruthy(); expect(spy).not.toHaveBeenCalled(); }); }); test('#moveLayer', async () => { const map = createMap({ style: extend(createStyle(), { sources: { mapbox: { type: 'vector', minzoom: 1, maxzoom: 10, tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, layers: [{ id: 'layerId1', type: 'circle', source: 'mapbox', 'source-layer': 'sourceLayer' }, { id: 'layerId2', type: 'circle', source: 'mapbox', 'source-layer': 'sourceLayer' }] }) }); await map.once('render'); map.moveLayer('layerId1', 'layerId2'); expect(map.getLayer('layerId1').id).toBe('layerId1'); expect(map.getLayer('layerId2').id).toBe('layerId2'); }); test('#getLayer', async () => { const layer = { id: 'layerId', type: 'circle', source: 'mapbox', 'source-layer': 'sourceLayer' }; const map = createMap({ style: extend(createStyle(), { sources: { mapbox: { type: 'vector', minzoom: 1, maxzoom: 10, tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, layers: [layer] }) }); await map.once('render'); const mapLayer = map.getLayer('layerId'); expect(mapLayer.id).toBe(layer.id); expect(mapLayer.type).toBe(layer.type); expect(mapLayer.source).toBe(layer.source); }); describe('#getLayersOrder', () => { test('returns ids of layers in the correct order', done => { const map = createMap({ style: extend(createStyle(), { 'sources': { 'raster': { type: 'raster', tiles: ['http://tiles.server'] } }, 'layers': [{ 'id': 'raster', 'type': 'raster', 'source': 'raster' }] }) }); map.on('style.load', () => { map.addLayer({ id: 'custom', type: 'custom', render() {} }, 'raster'); expect(map.getLayersOrder()).toEqual(['custom', 'raster']); done(); }); }); }); describe('#resize', () => { test('sets width and height from container clients', () => { const map = createMap(), container = map.getContainer(); Object.defineProperty(container, 'clientWidth', {value: 250}); Object.defineProperty(container, 'clientHeight', {value: 250}); map.resize(); expect(map.transform.width).toBe(250); expect(map.transform.height).toBe(250); }); test('fires movestart, move, resize, and moveend events', () => { const map = createMap(), events = []; (['movestart', 'move', 'resize', 'moveend'] as any).forEach((event) => { map.on(event, (e) => { events.push(e.type); }); }); map.resize(); expect(events).toEqual(['movestart', 'move', 'resize', 'moveend']); }); test('listen to window resize event', () => { const spy = jest.fn(); global.ResizeObserver = jest.fn().mockImplementation(() => ({ observe: spy })); createMap(); expect(spy).toHaveBeenCalled(); }); test('do not resize if trackResize is false', () => { let observerCallback: Function = null; global.ResizeObserver = jest.fn().mockImplementation((c) => ({ observe: () => { observerCallback = c; } })); const map = createMap({trackResize: false}); const spyA = jest.spyOn(map, 'stop'); const spyB = jest.spyOn(map, '_update'); const spyC = jest.spyOn(map, 'resize'); observerCallback(); expect(spyA).not.toHaveBeenCalled(); expect(spyB).not.toHaveBeenCalled(); expect(spyC).not.toHaveBeenCalled(); }); test('do resize if trackResize is true (default)', async () => { let observerCallback: Function = null; global.ResizeObserver = jest.fn().mockImplementation((c) => ({ observe: () => { observerCallback = c; } })); const map = createMap(); const updateSpy = jest.spyOn(map, '_update'); const resizeSpy = jest.spyOn(map, 'resize'); // The initial "observe" event fired by ResizeObserver should be captured/muted // in the map constructor observerCallback(); expect(updateSpy).not.toHaveBeenCalled(); expect(resizeSpy).not.toHaveBeenCalled(); // The next "observe" event should fire a resize / _update observerCallback(); expect(updateSpy).toHaveBeenCalled(); expect(resizeSpy).toHaveBeenCalledTimes(1); // Additional "observe" events should be throttled observerCallback(); observerCallback(); observerCallback(); observerCallback(); expect(resizeSpy).toHaveBeenCalledTimes(1); await new Promise((resolve) => { setTimeout(resolve, 100); }); expect(resizeSpy).toHaveBeenCalledTimes(2); }); test('width and height correctly rounded', () => { const map = createMap(); const container = map.getContainer(); Object.defineProperty(container, 'clientWidth', {value: 250.6}); Object.defineProperty(container, 'clientHeight', {value: 250.6}); map.resize(); expect(map.getCanvas().width).toBe(250); expect(map.getCanvas().height).toBe(250); expect(map.painter.width).toBe(250); expect(map.painter.height).toBe(250); }); }); describe('#getBounds', () => { test('getBounds', () => { const map = createMap({zoom: 0}); expect(parseFloat(map.getBounds().getCenter().lng.toFixed(10))).toBe(-0); expect(parseFloat(map.getBounds().getCenter().lat.toFixed(10))).toBe(0); expect(toFixed(map.getBounds().toArray())).toEqual(toFixed([ [-70.31249999999976, -57.326521225216965], [70.31249999999977, 57.32652122521695]])); }); test('rotated bounds', () => { const map = createMap({zoom: 1, bearing: 45}); expect( toFixed([[-49.718445552178764, -44.44541580601936], [49.7184455522, 44.445415806019355]]) ).toEqual(toFixed(map.getBounds().toArray())); map.setBearing(135); expect( toFixed([[-49.718445552178764, -44.44541580601936], [49.7184455522, 44.445415806019355]]) ).toEqual(toFixed(map.getBounds().toArray())); }); function toFixed(bounds) { const n = 10; return [ [normalizeFixed(bounds[0][0], n), normalizeFixed(bounds[0][1], n)], [normalizeFixed(bounds[1][0], n), normalizeFixed(bounds[1][1], n)] ]; } function normalizeFixed(num, n) { // workaround for "-0.0000000000" ≠ "0.0000000000" return parseFloat(num.toFixed(n)).toFixed(n); } }); describe('#setMaxBounds', () => { test('constrains map bounds', () => { const map = createMap({zoom: 0}); map.setMaxBounds([[-130.4297, 50.0642], [-61.52344, 24.20688]]); expect( toFixed([[-130.4297000000, 7.0136641176], [-61.5234400000, 60.2398142283]]) ).toEqual(toFixed(map.getBounds().toArray())); }); test('when no argument is passed, map bounds constraints are removed', () => { const map = createMap({zoom: 0}); map.setMaxBounds([[-130.4297, 50.0642], [-61.52344, 24.20688]]); expect( toFixed([[-166.28906999999964, -27.6835270554], [-25.664070000000066, 73.8248206697]]) ).toEqual(toFixed(map.setMaxBounds(null).setZoom(0).getBounds().toArray())); }); test('should not zoom out farther than bounds', () => { const map = createMap(); map.setMaxBounds([[-130.4297, 50.0642], [-61.52344, 24.20688]]); expect(map.setZoom(0).getZoom()).not.toBe(0); }); function toFixed(bounds) { const n = 9; return [ [bounds[0][0].toFixed(n), bounds[0][1].toFixed(n)], [bounds[1][0].toFixed(n), bounds[1][1].toFixed(n)] ]; } }); describe('#getMaxBounds', () => { test('returns null when no bounds set', () => { const map = createMap({zoom: 0}); expect(map.getMaxBounds()).toBeNull(); }); test('returns bounds', () => { const map = createMap({zoom: 0}); const bounds = [[-130.4297, 50.0642], [-61.52344, 24.20688]] as LngLatBoundsLike; map.setMaxBounds(bounds); expect(map.getMaxBounds().toArray()).toEqual(bounds); }); }); describe('#getRenderWorldCopies', () => { test('initially false', () => { const map = createMap({renderWorldCopies: false}); expect(map.getRenderWorldCopies()).toBe(false); }); test('initially true', () => { const map = createMap({renderWorldCopies: true}); expect(map.getRenderWorldCopies()).toBe(true); }); }); describe('#setRenderWorldCopies', () => { test('initially false', () => { const map = createMap({renderWorldCopies: false}); map.setRenderWorldCopies(true); expect(map.getRenderWorldCopies()).toBe(true); }); test('initially true', () => { const map = createMap({renderWorldCopies: true}); map.setRenderWorldCopies(false); expect(map.getRenderWorldCopies()).toBe(false); }); test('undefined', () => { const map = createMap({renderWorldCopies: false}); map.setRenderWorldCopies(undefined); expect(map.getRenderWorldCopies()).toBe(true); }); test('null', () => { const map = createMap({renderWorldCopies: true}); map.setRenderWorldCopies(null); expect(map.getRenderWorldCopies()).toBe(false); }); }); test('#setMinZoom', () => { const map = createMap({zoom: 5}); map.setMinZoom(3.5); map.setZoom(1); expect(map.getZoom()).toBe(3.5); }); test('unset minZoom', () => { const map = createMap({minZoom: 5}); map.setMinZoom(null); map.setZoom(1); expect(map.getZoom()).toBe(1); }); test('#getMinZoom', () => { const map = createMap({zoom: 0}); expect(map.getMinZoom()).toBe(-2); map.setMinZoom(10); expect(map.getMinZoom()).toBe(10); }); test('ignore minZooms over maxZoom', () => { const map = createMap({zoom: 2, maxZoom: 5}); expect(() => { map.setMinZoom(6); }).toThrow(); map.setZoom(0); expect(map.getZoom()).toBe(0); }); test('#setMaxZoom', () => { const map = createMap({zoom: 0}); map.setMaxZoom(3.5); map.setZoom(4); expect(map.getZoom()).toBe(3.5); }); test('unset maxZoom', () => { const map = createMap({maxZoom: 5}); map.setMaxZoom(null); map.setZoom(6); expect(map.getZoom()).toBe(6); }); test('#getMaxZoom', () => { const map = createMap({zoom: 0}); expect(map.getMaxZoom()).toBe(22); map.setMaxZoom(10); expect(map.getMaxZoom()).toBe(10); }); test('ignore maxZooms over minZoom', () => { const map = createMap({minZoom: 5}); expect(() => { map.setMaxZoom(4); }).toThrow(); map.setZoom(5); expect(map.getZoom()).toBe(5); }); test('throw on maxZoom smaller than minZoom at init', () => { expect(() => { createMap({minZoom: 10, maxZoom: 5}); }).toThrow(new Error('maxZoom must be greater than or equal to minZoom')); }); test('throw on maxZoom smaller than minZoom at init with falsey maxZoom', () => { expect(() => { createMap({minZoom: 1, maxZoom: 0}); }).toThrow(new Error('maxZoom must be greater than or equal to minZoom')); }); test('#setMinPitch', () => { const map = createMap({pitch: 20}); map.setMinPitch(10); map.setPitch(0); expect(map.getPitch()).toBe(10); }); test('unset minPitch', () => { const map = createMap({minPitch: 20}); map.setMinPitch(null); map.setPitch(0); expect(map.getPitch()).toBe(0); }); test('#getMinPitch', () => { const map = createMap({pitch: 0}); expect(map.getMinPitch()).toBe(0); map.setMinPitch(10); expect(map.getMinPitch()).toBe(10); }); test('ignore minPitchs over maxPitch', () => { const map = createMap({pitch: 0, maxPitch: 10}); expect(() => { map.setMinPitch(20); }).toThrow(); map.setPitch(0); expect(map.getPitch()).toBe(0); }); test('#setMaxPitch', () => { const map = createMap({pitch: 0}); map.setMaxPitch(10); map.setPitch(20); expect(map.getPitch()).toBe(10); }); test('unset maxPitch', () => { const map = createMap({maxPitch: 10}); map.setMaxPitch(null); map.setPitch(20); expect(map.getPitch()).toBe(20); }); test('#getMaxPitch', () => { const map = createMap({pitch: 0}); expect(map.getMaxPitch()).toBe(60); map.setMaxPitch(10); expect(map.getMaxPitch()).toBe(10); }); test('ignore maxPitchs over minPitch', () => { const map = createMap({minPitch: 10}); expect(() => { map.setMaxPitch(0); }).toThrow(); map.setPitch(10); expect(map.getPitch()).toBe(10); }); test('throw on maxPitch smaller than minPitch at init', () => { expect(() => { createMap({minPitch: 10, maxPitch: 5}); }).toThrow(new Error('maxPitch must be greater than or equal to minPitch')); }); test('throw on maxPitch smaller than minPitch at init with falsey maxPitch', () => { expect(() => { createMap({minPitch: 1, maxPitch: 0}); }).toThrow(new Error('maxPitch must be greater than or equal to minPitch')); }); test('throw on maxPitch greater than valid maxPitch at init', () => { expect(() => { createMap({maxPitch: 90}); }).toThrow(new Error('maxPitch must be less than or equal to 85')); }); test('throw on minPitch less than valid minPitch at init', () => { expect(() => { createMap({minPitch: -10}); }).toThrow(new Error('minPitch must be greater than or equal to 0')); }); test('#remove', () => { const map = createMap(); const spyWorkerPoolRelease = jest.spyOn(map.style.dispatcher.workerPool, 'release'); expect(map.getContainer().childNodes).toHaveLength(2); map.remove(); expect(spyWorkerPoolRelease).toHaveBeenCalledTimes(1); expect(map.getContainer().childNodes).toHaveLength(0); // Cleanup spyWorkerPoolRelease.mockClear(); }); test('#remove calls onRemove on added controls', () => { const map = createMap(); const control = { onRemove: jest.fn(), onAdd(_) { return window.document.createElement('div'); } }; map.addControl(control); map.remove(); expect(control.onRemove).toHaveBeenCalledTimes(1); }); test('#remove calls onRemove on added controls before style is destroyed', done => { const map = createMap(); let onRemoveCalled = 0; let style; const control = { onRemove(map) { onRemoveCalled++; expect(map.getStyle()).toEqual(style); }, onAdd(_) { return window.document.createElement('div'); } }; map.addControl(control); map.on('style.load', () => { style = map.getStyle(); map.remove(); expect(onRemoveCalled).toBe(1); done(); }); }); test('does not fire "webglcontextlost" after #remove has been called', done => { const map = createMap(); const canvas = map.getCanvas(); map.once('webglcontextlost', () => done('"webglcontextlost" fired after #remove has been called')); map.remove(); // Dispatch the event manually because at the time of this writing, gl does not support // the WEBGL_lose_context extension. canvas.dispatchEvent(new window.Event('webglcontextlost')); done(); }); test('does not fire "webglcontextrestored" after #remove has been called', done => { const map = createMap(); const canvas = map.getCanvas(); map.once('webglcontextlost', () => { map.once('webglcontextrestored', () => done('"webglcontextrestored" fired after #remove has been called')); map.remove(); canvas.dispatchEvent(new window.Event('webglcontextrestored')); done(); }); // Dispatch the event manually because at the time of this writing, gl does not support // the WEBGL_lose_context extension. canvas.dispatchEvent(new window.Event('webglcontextlost')); }); test('#redraw', async () => { const map = createMap(); await map.once('idle'); const renderPromise = map.once('render'); map.redraw(); await renderPromise; }); test('#addControl', () => { const map = createMap(); const control = { onAdd(_) { expect(map).toBe(_); return window.document.createElement('div'); } } as any as IControl; map.addControl(control); expect(map._controls[0]).toBe(control); }); test('#removeControl errors on invalid arguments', () => { const map = createMap(); const control = {} as any as IControl; const stub = jest.spyOn(console, 'error').mockImplementation(() => {}); map.addControl(control); map.removeControl(control); expect(stub).toHaveBeenCalledTimes(2); }); test('#removeControl', () => { const map = createMap(); const control = { onAdd() { return window.document.createElement('div'); }, onRemove(_) { expect(map).toBe(_); } }; map.addControl(control); map.removeControl(control); expect(map._controls).toHaveLength(0); }); test('#hasControl', () => { const map = createMap(); function Ctrl() {} Ctrl.prototype = { onAdd(_) { return window.document.createElement('div'); } }; const control = new Ctrl(); expect(map.hasControl(control)).toBe(false); map.addControl(control); expect(map.hasControl(control)).toBe(true); }); test('#project', () => { const map = createMap(); expect(map.project([0, 0])).toEqual({x: 100, y: 100}); }); test('#unproject', () => { const map = createMap(); expect(fixedLngLat(map.unproject([100, 100]))).toEqual({lng: 0, lat: 0}); }); test('#listImages', done => { const map = createMap(); map.on('load', () => { expect(map.listImages()).toHaveLength(0); map.addImage('img', {width: 1, height: 1, data: new Uint8Array(4)}); const images = map.lis