UNPKG

maplibre-gl

Version:

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

714 lines (611 loc) 25.3 kB
import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest'; import {Map} from '../map.ts'; import {createMap, beforeMapTest, createStyle, createStyleSource, sleep} from '../../util/test/util.ts'; import {ErrorEvent} from '../../util/evented.ts'; import {MapSourceDataEvent, MapStyleDataEvent} from '../events.ts'; import {fixedLngLat, fixedNum} from '../../../test/unit/lib/fixed.ts'; import {extend} from '../../util/util.ts'; import {fakeServer, type FakeServer} from 'nise'; import {Style} from '../../style/style.ts'; import {LngLatBounds} from '../../geo/lng_lat_bounds.ts'; import type {GeoJSONSourceSpecification, LayerSpecification, StyleSpecification} from '@maplibre/maplibre-gl-style-spec'; let server: FakeServer; beforeEach(() => { beforeMapTest(); global.fetch = null; server = fakeServer.create(); }); afterEach(() => { server.restore(); }); describe('setStyle', () => { test('replaces a terrain style while terrain is loading', async () => { const terrainStyle: StyleSpecification = { version: 8, sources: { dem: { type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, terrain: {source: 'dem'}, layers: [] }; const nextTerrainStyle: StyleSpecification = { ...terrainStyle, sources: {nextDem: terrainStyle.sources.dem}, terrain: {source: 'nextDem'} }; const map = createMap({style: terrainStyle}); await map.once('style.load'); const styleLoad = map.once('style.load'); map.setStyle(nextTerrainStyle, {diff: false}); map.redraw(); await styleLoad; expect(map.getTerrain()).toEqual({source: 'nextDem'}); }); test('replaces a loading terrain style with a style without terrain', async () => { const map = createMap({ style: { version: 8, sources: { dem: { type: 'raster-dem', tiles: ['http://example.com/{z}/{x}/{y}.png'] } }, terrain: {source: 'dem'}, layers: [] } }); await map.once('style.load'); const styleLoad = map.once('style.load'); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); map.redraw(); await styleLoad; expect(map.getTerrain()).toBeNull(); }); test('returns self', () => { const map = new Map({container: window.document.createElement('div')}); expect(map.setStyle({ version: 8, sources: {}, layers: [] })).toBe(map); }); test('sets up event forwarding', async () => { const map = createMap(); await map.once('load'); 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 ErrorEvent(new Error('test'))); map.style.fire(new MapSourceDataEvent('data')); map.style.fire(new MapSourceDataEvent('dataloading')); expect(events).toEqual([ 'error', 'data', 'dataloading', ]); }); test('fires *data and *dataloading events', async () => { const map = createMap(); await map.once('load'); 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.style.fire(new MapStyleDataEvent('data')); map.style.fire(new MapStyleDataEvent('dataloading')); map.style.fire(new MapSourceDataEvent('data')); map.style.fire(new MapSourceDataEvent('dataloading')); expect(events).toEqual([ 'styledata', 'styledataloading', 'sourcedata', 'sourcedataloading' ]); }); test('can be called more than once', async () => { const map = createMap(); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); map.setStyle({version: 8, sources: {}, layers: []}, {diff: false}); await map.once('style.load'); expect(map.getStyle()).toEqual({version: 8, sources: {}, layers: []}); }); test('setStyle back to the first style should work', async () => { 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}); const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); map.setStyle(blueStyle); await map.once('style.load'); map.setStyle(redStyle); const serializedStyle = map.style.serialize(); expect(serializedStyle.layers[0].paint['background-color']).toBe('red'); spy.mockRestore(); }); test('style transform overrides unmodified map transform when max bounds are set for a large area', async () => { const container = window.document.createElement('div'); Object.defineProperty(container, 'clientWidth', {value: 600}); Object.defineProperty(container, 'clientHeight', {value: 400}); const map = createMap({container, deleteStyle: true}); map.setMaxBounds(new LngLatBounds([-120, -60], [140, 80])); expect(map.getZoom()).toBeCloseTo(0.6983039737971013, 10); const style = createStyle(); map.setStyle(style); await map.once('style.load'); expect(fixedLngLat(map.getCenter())).toEqual({lng: style.center[0], lat: style.center[1]}); expect(fixedNum(map.getZoom())).toBe(style.zoom); expect(fixedNum(map.getBearing())).toBe(style.bearing); expect(fixedNum(map.getPitch())).toBe(style.pitch); }); test('style transform overrides map transform when zoom and pitch limits are set in options', async () => { const map = createMap({deleteStyle: true, minZoom: 1, minPitch: 10}); const style = createStyle(); map.setStyle(style); await map.once('style.load'); expect(fixedLngLat(map.getCenter())).toEqual({lng: style.center[0], lat: style.center[1]}); expect(fixedNum(map.getZoom())).toBe(style.zoom); expect(fixedNum(map.getBearing())).toBe(style.bearing); expect(fixedNum(map.getPitch())).toBe(style.pitch); }); test('style transform overrides map transform when a max zoom limit is set in options', async () => { const map = createMap({deleteStyle: true, maxZoom: -1}); const style = createStyle(); map.setStyle(style); await map.once('style.load'); expect(fixedNum(map.getCenter().lng)).toBe(style.center[0]); expect(fixedNum(map.getBearing())).toBe(style.bearing); expect(fixedNum(map.getPitch())).toBe(style.pitch); expect(fixedNum(map.getZoom())).toBe(-1); expect(fixedNum(map.getCenter().lat)).toBe(36.5978911823); }); test('style transform does not override map transform modified via options', async () => { const map = createMap({deleteStyle: true, zoom: 10, center: [-77.0186, 38.8888]}); map.setStyle(createStyle()); await map.once('style.load'); expect(fixedLngLat(map.getCenter())).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888})); expect(fixedNum(map.getZoom())).toBe(10); expect(fixedNum(map.getBearing())).toBe(0); expect(fixedNum(map.getPitch())).toBe(0); }); test('style transform does not override map transform modified via setters', async () => { const map = createMap({deleteStyle: true}); map.setZoom(10); map.setCenter([-77.0186, 38.8888]); map.setStyle(createStyle()); await map.once('style.load'); expect(fixedLngLat(map.getCenter())).toEqual(fixedLngLat({lng: -77.0186, lat: 38.8888})); expect(fixedNum(map.getZoom())).toBe(10); expect(fixedNum(map.getBearing())).toBe(0); expect(fixedNum(map.getPitch())).toBe(0); }); test('passing null removes style', () => { const map = createMap(); const style = map.style; expect(style).toBeTruthy(); vi.spyOn(style, '_remove'); map.setStyle(null); expect(style._remove).toHaveBeenCalledTimes(1); }); test('passing null releases the worker', () => { const map = createMap(); const spyWorkerPoolAcquire = vi.spyOn(map.style.dispatcher.workerPool, 'acquire'); const spyWorkerPoolRelease = vi.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', async () => { 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] ] }) }); await map.once('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); }); test('delayed setStyle with transformStyle should copy the source and the layer into next style with diffing', async () => { 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}); await sleep(100); 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); }); // in this special case, retrieval of the style JSON is not done by style but by map test('can asynchronously transform style JSON request specified to setStyle with diffing', async () => { server.respondWith('style.json', JSON.stringify(createStyle())); 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}); const transformRequestSpy = vi.fn(async (url) => ({ url, headers: {Authorization: 'Bearer token'} })); map.setTransformRequest(transformRequestSpy); await map.once('style.load'); map.setStyle('style.json', {diff: true}); await sleep(0); server.respond(); await map.once('style.load'); expect(transformRequestSpy).toHaveBeenCalledWith('style.json', 'Style'); expect(server.requests[0].url).toBe('style.json'); expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); }); test('transformStyle should get called when passed to setStyle after the map is initialised without a style', async () => { 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' }] }; } }); await map.once('style.load'); const loadedStyle = map.style.serialize(); expect('maplibre' in loadedStyle.sources).toBeTruthy(); expect(loadedStyle.layers[0].id).toBe('layerId0'); }); test('stale style URL load does not complete after style is cleared during async transformRequest', async () => { server.respondWith('style.json', JSON.stringify(createStyle())); let resolveTransformRequest: (value: {url: string}) => void; const transformRequest = new Promise<{url: string}>((resolve) => { resolveTransformRequest = resolve; }); const map = createMap({deleteStyle: true}); const initialTransform = map._camera.transform; const initialPainterTransform = map.painter.transform; const projectionTransitionSpy = vi.fn(); map.on('projectiontransition', projectionTransitionSpy); map.setTransformRequest(() => transformRequest); map.setStyle('style.json', {diff: false}); map.setStyle(null, {diff: false}); resolveTransformRequest({url: 'style.json'}); await sleep(0); server.respond(); await sleep(0); expect(map.style).toBeUndefined(); expect(projectionTransitionSpy).not.toHaveBeenCalled(); expect(map._camera.transform).toBe(initialTransform); expect(map.painter.transform).toBe(initialPainterTransform); }); test('map load should be fired when transformStyle is used on setStyle after the map is initialised without a style', async () => { const map = createMap({deleteStyle: true}); map.setStyle({version: 8, sources: {}, layers: []}, { diff: true, transformStyle: (prevStyle, nextStyle) => { expect(prevStyle).toBeUndefined(); expect(nextStyle).toBeDefined(); return createStyle(); } }); await map.once('load'); }); test('Override default style validation', () => { let validationOption = true; vi.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('getStyleUrl', () => { test('returns the URL given to setStyle', () => { const map = createMap(); map.setStyle('/styles/streets.json'); expect(map.getStyleUrl()).toBe('/styles/streets.json'); }); test('returns the URL given in the map options', () => { const map = createMap({style: '/styles/streets.json'}); expect(map.getStyleUrl()).toBe('/styles/streets.json'); }); test('returns null when the style is given as an object', () => { const map = createMap({style: '/styles/streets.json'}); map.setStyle(createStyle()); expect(map.getStyleUrl()).toBeNull(); }); test('returns null after the style is removed', () => { const map = createMap({style: '/styles/streets.json'}); map.setStyle(null); expect(map.getStyleUrl()).toBeNull(); }); }); describe('getStyle', () => { test('returns undefined if the style has not loaded yet', () => { const style = createStyle(); const map = createMap({style}); expect(map.getStyle()).toBeUndefined(); }); test('returns the style', async () => { const style = createStyle(); const map = createMap({style}); await map.once('load'); expect(map.getStyle()).toEqual(style); }); test('returns the previous style even if modified', async () => { const style = { version: 8 as const, sources: {}, layers: [ { id: 'background', type: 'background' as const, paint: {'background-color': 'blue'} }, ] }; const map = createMap({style}); await map.once('load'); const newStyle = map.getStyle(); newStyle.layers[0].paint = {'background-color': 'red'}; // map.getStyle() should still equal the original style since // we have not yet called map.setStyle(...). expect(map.getStyle()).toEqual(style); }); test('returns the style with added sources', async () => { const style = createStyle(); const map = createMap({style}); await map.once('load'); map.addSource('geojson', createStyleSource()); expect(map.getStyle()).toEqual(extend(createStyle(), { sources: {geojson: createStyleSource()} })); }); test('fires an error on checking if non-existent source is loaded', async () => { const style = createStyle(); const map = createMap({style}); await map.once('load'); const errorPromise = map.once('error'); map.isSourceLoaded('geojson'); const error = await errorPromise; expect(error.error.message).toMatch(/There is no tile manager with ID/); }); test('returns the style with added layers', async () => { const style = createStyle(); const map = createMap({style}); const layer = { id: 'background', type: 'background' } as LayerSpecification; await map.once('load'); map.addLayer(layer); expect(map.getStyle()).toEqual(extend(createStyle(), { layers: [layer] })); }); 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); expect(map.getLayer('background')).toBeDefined(); }); 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); expect(map.getSource('fill')).toBeDefined(); }); 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 }); expect(map.getLayer('foo')).toBeDefined(); }); test('returns the style with added source and layer', async () => { const style = createStyle(); const map = createMap({style}); const source = createStyleSource(); const layer = { id: 'fill', type: 'fill', source: 'fill' } as LayerSpecification; await map.once('load'); map.addSource('fill', source); map.addLayer(layer); expect(map.getStyle()).toEqual(extend(createStyle(), { sources: {fill: source}, layers: [layer] })); }); test('creates a new Style if diff fails', () => { const style = createStyle(); const map = createMap({style}); vi.spyOn(map.style, 'setState').mockImplementation(() => { throw new Error('Dummy error'); }); vi.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 = vi.spyOn(map.style, 'setState'); const previousStyle = map.style; map.setStyle(style, {diff: false}); expect(map.style && map.style !== previousStyle).toBeTruthy(); expect(spy).not.toHaveBeenCalled(); }); describe('setSky', () => { test('calls style setSky when set', () => { const map = createMap(); const spy = vi.fn(); map.style.setSky = spy; map.setSky({'horizon-fog-blend': 0.5}); expect(spy).toHaveBeenCalled(); }); }); describe('getSky', () => { test('returns undefined when not set', () => { const map = createMap(); expect(map.getSky()).toBeUndefined(); }); }); describe('setLight', () => { test('calls style setLight when set', () => { const map = createMap(); const spy = vi.fn(); map.style.setLight = spy; map.setLight({anchor: 'viewport'}); expect(spy).toHaveBeenCalled(); }); }); describe('getLight', () => { test('calls style getLight when invoked', () => { const map = createMap(); const spy = vi.fn(); map.style.getLight = spy; map.getLight(); expect(spy).toHaveBeenCalled(); }); }); describe('setFontFaces', () => { test('round-trips the font files the style draws text with', async () => { const map = createMap(); await map.once('style.load'); expect(map.getFontFaces()).toBeNull(); const fontFaces = { 'Noto Sans Regular': [{url: 'https://example.com/khmer.ttf', 'unicode-range': ['U+1780-17FF']}] }; map.setFontFaces(fontFaces); expect(map.getFontFaces()).toEqual(fontFaces); map.setFontFaces(null); expect(map.getFontFaces()).toBeNull(); }); }); });