UNPKG

maplibre-gl

Version:

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

1,388 lines (1,173 loc) 129 kB
import {describe, beforeEach, afterEach, test, expect, vi, type MockInstance} from 'vitest'; import {Style} from './style'; import {TileManager} from '../tile/tile_manager'; import {StyleLayer} from './style_layer'; import {extend} from '../util/util'; import {Event} from '../util/evented'; import {RGBAImage} from '../util/image'; import {rtlMainThreadPluginFactory} from '../source/rtl_text_plugin_main_thread'; import {browser} from '../util/browser'; import {OverscaledTileID} from '../tile/tile_id'; import {fakeServer, type FakeServer} from 'nise'; import {type EvaluationParameters} from './evaluation_parameters'; import {Color, type Feature, type LayerSpecification, type GeoJSONSourceSpecification, type FilterSpecification, type SourceSpecification, type StyleSpecification, type SymbolLayerSpecification, type SkySpecification} from '@maplibre/maplibre-gl-style-spec'; import {type GeoJSONSource} from '../source/geojson_source'; import {StubMap, sleep, waitForEvent} from '../util/test/util'; import {RTLPluginLoadedEventName} from '../source/rtl_text_plugin_status'; import {MessageType} from '../util/actor_messages'; import {MercatorTransform} from '../geo/projection/mercator_transform'; import {type Tile} from '../tile/tile'; import type Point from '@mapbox/point-geometry'; import {type PossiblyEvaluated} from './properties'; import {type SymbolLayoutProps, type SymbolLayoutPropsPossiblyEvaluated} from './style_layer/symbol_style_layer_properties.g'; import {type CirclePaintProps, type CirclePaintPropsPossiblyEvaluated} from './style_layer/circle_style_layer_properties.g'; function createStyleJSON(properties?): StyleSpecification { return extend({ 'version': 8, 'sources': {}, 'layers': [] }, properties); } function createSource() { return { type: 'vector', minzoom: 1, maxzoom: 10, attribution: 'MapLibre', tiles: ['http://example.com/{z}/{x}/{y}.png'] } as any as SourceSpecification; } function createGeoJSONSource(): GeoJSONSourceSpecification { return { 'type': 'geojson', 'data': { 'type': 'FeatureCollection', 'features': [] } }; } const getStubMap = () => new StubMap() as any; function createStyle(map = getStubMap()) { const style = new Style(map); map.style = style; return style; } let server: FakeServer; let mockConsoleError: MockInstance; beforeEach(() => { global.fetch = null; server = fakeServer.create(); mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => { }); }); afterEach(() => { server.restore(); mockConsoleError.mockRestore(); }); describe('Style', () => { test('RTL plugin load reloads vector source but not raster source', async() => { const map = getStubMap(); const style = new Style(map); map.style = style; style.loadJSON({ 'version': 8, 'sources': { 'raster': { type: 'raster', tiles: ['http://tiles.server'] }, 'vector': { type: 'vector', tiles: ['http://tiles.server'] } }, 'layers': [{ 'id': 'raster', 'type': 'raster', 'source': 'raster' }] }); await style.once('style.load'); vi.spyOn(style.tileManagers['raster'], 'reload'); vi.spyOn(style.tileManagers['vector'], 'reload'); rtlMainThreadPluginFactory().fire(new Event(RTLPluginLoadedEventName)); expect(style.tileManagers['raster'].reload).not.toHaveBeenCalled(); expect(style.tileManagers['vector'].reload).toHaveBeenCalled(); }); }); describe('Style.loadURL', () => { test('fires "dataloading"', () => { const style = new Style(getStubMap()); const spy = vi.fn(); style.on('dataloading', spy); style.loadURL('style.json'); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0].target).toBe(style); expect(spy.mock.calls[0][0].dataType).toBe('style'); }); test('transforms style URL before request', () => { const map = getStubMap(); const spy = vi.spyOn(map._requestManager, 'transformRequest'); const style = new Style(map); style.loadURL('style.json'); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0]).toBe('style.json'); expect(spy.mock.calls[0][1]).toBe('Style'); }); test('can asynchronously transform style request', async () => { server.respondWith('style.json', JSON.stringify(createStyleJSON())); const map = getStubMap(); map._requestManager.transformRequest = async (url, type) => ({ url, type, headers: {Authorization: 'Bearer token'} }); const style = new Style(map); style.loadURL('style.json'); await sleep(0); server.respond(); await waitForEvent(style, 'data', (event) => event.dataType === 'style'); expect(server.requests[0].url).toBe('style.json'); expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); }); test('validates the style', async () => { const style = new Style(getStubMap()); const errorPromise = style.once('error'); style.loadURL('style.json'); server.respondWith(JSON.stringify(createStyleJSON({version: 'invalid'}))); await sleep(0); server.respond(); const {error} = await errorPromise; expect(error).toBeTruthy(); expect(error.message).toMatch(/version/); }); test('cancels pending requests if removed', async () => { const style = new Style(getStubMap()); style.loadURL('style.json'); await sleep(0); style._remove(); expect((server.lastRequest as any).aborted).toBe(true); }); test('does not fire an error if removed', async () => { const style = new Style(getStubMap()); const spy = vi.fn(); style.on('error', spy); style.loadURL('style.json'); style._remove(); await sleep(0); expect(spy).not.toHaveBeenCalled(); }); test('fires an error if the request fails', async () => { const style = new Style(getStubMap()); const errorStatus = 400; const promise = style.once('error'); style.loadURL('style.json'); server.respondWith(request => request.respond(errorStatus)); await sleep(0); server.respond(); const {error} = await promise; expect(error).toBeTruthy(); expect(error.status).toBe(errorStatus); }); test('does not throw if request is pending when removed', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); const errorHandler = vi.fn(); style.on('error', errorHandler); style.loadURL('style.json'); style._remove(); expect(errorHandler).not.toHaveBeenCalled(); }); }); describe('Style.loadJSON', () => { test('serialize() returns undefined until style is loaded', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); expect(style.serialize()).toBeUndefined(); await style.once('style.load'); expect(style.serialize()).toEqual(createStyleJSON()); }); test('fires "dataloading" (synchronously)', () => { const style = new Style(getStubMap()); const spy = vi.fn(); style.on('dataloading', spy); style.loadJSON(createStyleJSON()); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0].target).toBe(style); expect(spy.mock.calls[0][0].dataType).toBe('style'); }); test('fires "data" (asynchronously)', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); const e = await style.once('data'); expect(e.target).toBe(style); expect(e.dataType).toBe('style'); }); test('fires "data" when the sprite finishes loading', async () => { // Stubbing to bypass Web APIs that supported by jsdom: // * `URL.createObjectURL` in ajax.getImage (https://github.com/tmpvar/jsdom/issues/1721) // * `canvas.getContext('2d')` in browser.getImageData vi.spyOn(browser, 'getImageData'); // stub Image so we can invoke 'onload' // https://github.com/jsdom/jsdom/commit/58a7028d0d5b6aacc5b435daee9fd8f9eacbb14c server.respondWith('GET', 'http://example.com/sprite.png', new ArrayBuffer(8)); server.respondWith('GET', 'http://example.com/sprite.json', '{}'); const style = new Style(getStubMap()); style.loadJSON({ 'version': 8, 'sources': {}, 'layers': [], 'sprite': 'http://example.com/sprite' }); style.once('error', (e) => expect(e).toBeFalsy()); const e = await style.once('data'); expect(e.target).toBe(style); expect(e.dataType).toBe('style'); const promise = style.once('data'); await sleep(0); server.respond(); await promise; expect(e.target).toBe(style); expect(e.dataType).toBe('style'); }); test('Validate sprite image extraction', async () => { // Stubbing to bypass Web APIs that supported by jsdom: // * `URL.createObjectURL` in ajax.getImage (https://github.com/tmpvar/jsdom/issues/1721) // * `canvas.getContext('2d')` in browser.getImageData vi.spyOn(browser, 'getImageData'); // stub Image so we can invoke 'onload' // https://github.com/jsdom/jsdom/commit/58a7028d0d5b6aacc5b435daee9fd8f9eacbb14c server.respondWith('GET', 'http://example.com/sprite.png', new ArrayBuffer(8)); server.respondWith('GET', 'http://example.com/sprite.json', '{"image1": {"width": 1, "height": 1, "x": 0, "y": 0, "pixelRatio": 1.0}}'); const style = new Style(getStubMap()); style.loadJSON({ 'version': 8, 'sources': {}, 'layers': [], 'sprite': 'http://example.com/sprite' }); const firstDataEvent = await style.once('data'); expect(firstDataEvent.target).toBe(style); expect(firstDataEvent.dataType).toBe('style'); const secondDataPromise = style.once('data'); await sleep(0); server.respond(); const secondDateEvent = await secondDataPromise; expect(secondDateEvent.target).toBe(style); expect(secondDateEvent.dataType).toBe('style'); const response = await style.imageManager.getImages(['image1']); const image = response['image1']; expect(image.data).toBeInstanceOf(RGBAImage); expect(image.data.width).toBe(1); expect(image.data.height).toBe(1); expect(image.pixelRatio).toBe(1); }); test('validates the style', async () => { const style = new Style(getStubMap()); const promise = style.once('error'); style.loadJSON(createStyleJSON({version: 'invalid'})); const {error} = await promise; expect(error).toBeTruthy(); expect(error.message).toMatch(/version/); }); test('creates sources', async () => { const style = createStyle(); style.loadJSON(extend(createStyleJSON(), { 'sources': { 'mapLibre': { 'type': 'vector', 'tiles': [] } } })); await style.once('style.load'); expect(style.tileManagers['mapLibre'] instanceof TileManager).toBeTruthy(); }); test('creates layers', async () => { const style = createStyle(); style.loadJSON({ 'version': 8, 'sources': { 'foo': { 'type': 'vector' } }, 'layers': [{ 'id': 'fill', 'source': 'foo', 'source-layer': 'source-layer', 'type': 'fill' }] }); await style.once('style.load'); expect(style.getLayer('fill') instanceof StyleLayer).toBeTruthy(); }); test('transforms sprite json and image URLs before request', async () => { const map = getStubMap(); const transformSpy = vi.spyOn(map._requestManager, 'transformRequest'); const style = createStyle(map); style.loadJSON(extend(createStyleJSON(), { 'sprite': 'http://example.com/sprites/bright-v8' })); await style.once('style.load'); expect(transformSpy).toHaveBeenCalledTimes(2); expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/sprites/bright-v8.json'); expect(transformSpy.mock.calls[0][1]).toBe('SpriteJSON'); expect(transformSpy.mock.calls[1][0]).toBe('http://example.com/sprites/bright-v8.png'); expect(transformSpy.mock.calls[1][1]).toBe('SpriteImage'); }); test('emits an error on non-existant vector source layer', async () => { const style = createStyle(); style.loadJSON(createStyleJSON({ sources: { '-source-id-': {type: 'vector', tiles: []} }, layers: [] })); await style.once('style.load'); style.removeSource('-source-id-'); const source = createSource(); source['vector_layers'] = [{id: 'green'}]; style.addSource('-source-id-', source); style.addLayer({ 'id': '-layer-id-', 'type': 'circle', 'source': '-source-id-', 'source-layer': '-source-layer-' }); style.update({} as EvaluationParameters); const event = await style.once('error'); const err = event.error; expect(err).toBeTruthy(); expect(err.toString().indexOf('-source-layer-') !== -1).toBeTruthy(); expect(err.toString().indexOf('-source-id-') !== -1).toBeTruthy(); expect(err.toString().indexOf('-layer-id-') !== -1).toBeTruthy(); }); test('sets up layer event forwarding', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON({ layers: [{ id: 'background', type: 'background' }] })); const errorPromise = style.once('error'); await style.once('style.load'); style._layers.background.fire(new Event('error', {mapLibre: true})); const e = await errorPromise; expect(e.layer).toEqual({id: 'background'}); expect(e.mapLibre).toBeTruthy(); }); test('sets terrain if defined', async () => { const map = getStubMap(); const style = new Style(map); map.setTerrain = vi.fn(); style.loadJSON(createStyleJSON({ sources: {'source-id': createGeoJSONSource()}, terrain: {source: 'source-id', exaggeration: 0.33} })); await style.once('style.load'); expect(style.map.setTerrain).toHaveBeenCalled(); }); test('sets state if defined', async () => { const map = getStubMap(); const style = new Style(map); style.loadJSON(createStyleJSON({ state: { foo: { default: 'bar' } } })); await style.once('style.load'); expect(style.getGlobalState()).toEqual({foo: 'bar'}); }); test('applies transformStyle function', async () => { const previousStyle = createStyleJSON({ sources: { base: { type: 'geojson', data: {type: 'FeatureCollection', features: []} } }, layers: [{ id: 'layerId0', type: 'circle', source: 'base' }, { id: 'layerId1', type: 'circle', source: 'base' }] }); const style = new Style(getStubMap()); style.loadJSON(createStyleJSON(), { transformStyle: (prevStyle, nextStyle) => ({ ...nextStyle, sources: { ...nextStyle.sources, base: prevStyle.sources.base }, layers: [ ...nextStyle.layers, prevStyle.layers[0] ] }) }, previousStyle); await style.once('style.load'); expect('base' in style.stylesheet.sources).toBeTruthy(); expect(style.stylesheet.layers[0].id).toBe(previousStyle.layers[0].id); expect(style.stylesheet.layers).toHaveLength(1); }); test('propagates global state object to layers', async () => { const style = new Style(getStubMap()); style.loadJSON( createStyleJSON({ sources: { 'source-id': createGeoJSONSource() }, layers: [ { id: 'layer-id', type: 'symbol', source: 'source-id', layout: { 'text-size': ['global-state', 'size'] } } ] }) ); await style.once('style.load'); // tests that reference to globalState is propagated to layers // by setting globalState property and checking if the new value // was used when evaluating the layer const globalState = {size: {default: 12}}; style.setGlobalState(globalState); const layer = style.getLayer('layer-id'); layer.recalculate({} as EvaluationParameters, []); const layout = layer.layout as PossiblyEvaluated<SymbolLayoutProps, SymbolLayoutPropsPossiblyEvaluated>; expect(layout.get('text-size').evaluate({} as Feature, {})).toBe(12); }); test('propagates global state object to layers added after loading style', async () => { const style = new Style(getStubMap()); style.loadJSON( createStyleJSON({ sources: { 'source-id': createGeoJSONSource() }, layers: [] }) ); await style.once('style.load'); style.addLayer({ id: 'layer-id', type: 'circle', source: 'source-id', paint: { 'circle-color': ['global-state', 'color'], 'circle-radius': ['global-state', 'radius'] } }); // tests that reference to globalState is propagated to layers // by setting globalState property and checking if the new value // was used when evaluating the layer const globalState = {color: {default: 'red'}, radius: {default: 12}}; style.setGlobalState(globalState); const layer = style.getLayer('layer-id'); layer.recalculate({} as EvaluationParameters, []); const paint = layer.paint as PossiblyEvaluated<CirclePaintProps, CirclePaintPropsPossiblyEvaluated>; expect(paint.get('circle-color').evaluate({} as Feature, {})).toEqual(new Color(1, 0, 0, 1)); expect(paint.get('circle-radius').evaluate({} as Feature, {})).toEqual(12); }); test('does not throw if request is pending when removed', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); const errorHandler = vi.fn(); style.on('error', errorHandler); style.loadJSON( createStyleJSON({ sources: { 'source-id': createGeoJSONSource() }, }) ); style._remove(); expect(errorHandler).not.toHaveBeenCalled(); }); }); describe('Style._load', () => { test('initiates sprite loading when it\'s present', () => { const style = new Style(getStubMap()); const prevStyleSpec = createStyleJSON({ sprite: 'https://example.com/test1' }); const nextStyleSpec = createStyleJSON({ sprite: 'https://example.com/test2' }); const _loadSpriteSpyOn = vi.spyOn(style, '_loadSprite'); style._load(nextStyleSpec, {}, prevStyleSpec); expect(_loadSpriteSpyOn).toHaveBeenCalledTimes(1); }); test('does not initiate sprite loading when it\'s absent (undefined)', () => { const style = new Style(getStubMap()); const prevStyleSpec = createStyleJSON({ sprite: 'https://example.com/test1' }); const nextStyleSpec = createStyleJSON({sprite: undefined}); const _loadSpriteSpyOn = vi.spyOn(style, '_loadSprite'); style._load(nextStyleSpec, {}, prevStyleSpec); expect(_loadSpriteSpyOn).not.toHaveBeenCalled(); }); test('layers are broadcasted to worker', () => { const style = new Style(getStubMap()); let dispatchType: MessageType; let dispatchData; const styleSpec = createStyleJSON({ layers: [{ id: 'background', type: 'background' }] }); const _broadcastSpyOn = vi.spyOn(style.dispatcher, 'broadcast') .mockImplementation((type, data) => { dispatchType = type; dispatchData = data; return Promise.resolve({} as any); }); style._load(styleSpec, {}); expect(_broadcastSpyOn).toHaveBeenCalled(); expect(dispatchType).toBe(MessageType.setLayers); expect(dispatchData).toHaveLength(1); expect(dispatchData[0].id).toBe('background'); // cleanup _broadcastSpyOn.mockRestore(); }); test('validate style when validate option is true', () => { const style = new Style(getStubMap()); const styleSpec = createStyleJSON({ layers: [{ id: 'background', type: 'background' }, { id: 'custom', type: 'custom' }] }); const stub = vi.spyOn(console, 'error'); style._load(styleSpec, {validate: true}); // 1. layers[1]: missing required property "source" // 2. layers[1].type: expected one of [fill, line, symbol, circle, heatmap, fill-extrusion, raster, hillshade, background], "custom" found expect(stub).toHaveBeenCalledTimes(2); // cleanup stub.mockReset(); }); test('layers are NOT serialized immediately after creation', () => { const style = new Style(getStubMap()); const styleSpec = createStyleJSON({ layers: [{ id: 'background', type: 'background' }, { id: 'custom', type: 'custom' }] }); style._load(styleSpec, {validate: false}); expect(style._serializedLayers).toBeNull(); }); test('projection is mercator if not specified', () => { const style = new Style(getStubMap()); const styleSpec = createStyleJSON({ layers: [{ id: 'background', type: 'background' }] }); style._load(styleSpec, {validate: false}); expect(style.projection.name).toBe('mercator'); expect(style.serialize().projection).toBeUndefined(); }); }); describe('Style._remove', () => { test('removes cache sources and clears their tiles', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON({ sources: {'source-id': createGeoJSONSource()} })); await style.once('style.load'); const tileManager = style.tileManagers['source-id']; vi.spyOn(tileManager, 'setEventedParent'); vi.spyOn(tileManager, 'onRemove'); vi.spyOn(tileManager, 'clearTiles'); style._remove(); expect(tileManager.setEventedParent).toHaveBeenCalledWith(null); expect(tileManager.onRemove).toHaveBeenCalledWith(style.map); expect(tileManager.clearTiles).toHaveBeenCalled(); }); test('deregisters plugin listener', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); vi.spyOn(rtlMainThreadPluginFactory(), 'off'); await style.once('style.load'); style._remove(); expect(rtlMainThreadPluginFactory().off).toHaveBeenCalled(); }); }); describe('Style.update', () => { test('on error', async () => { const style = createStyle(); style.loadJSON({ 'version': 8, 'sources': { 'source': { 'type': 'vector' } }, 'layers': [{ 'id': 'second', 'source': 'source', 'source-layer': 'source-layer', 'type': 'fill' }] }); style.on('error', (error) => { expect(error).toBeFalsy(); }); await style.once('style.load'); style.addLayer({id: 'first', source: 'source', type: 'fill', 'source-layer': 'source-layer'}, 'second'); style.addLayer({id: 'third', source: 'source', type: 'fill', 'source-layer': 'source-layer'}); style.removeLayer('second'); const spy = vi.fn().mockResolvedValue(Promise.resolve({} as any)); style.dispatcher.broadcast = spy; style.update({} as EvaluationParameters); expect(spy).toHaveBeenCalled(); expect(spy.mock.calls[0][0]).toBe(MessageType.updateLayers); expect(spy.mock.calls[0][1]['layers'].map((layer) => { return layer.id; })).toEqual(['first', 'third']); expect(spy.mock.calls[0][1]['removedIds']).toEqual(['second']); }); }); describe('Style.setState', () => { test('throw before loaded', () => { const style = new Style(getStubMap()); expect(() => style.setState(createStyleJSON())).toThrow(/load/i); }); test('do nothing if there are no changes', async () => { const style = createStyle(); style.loadJSON(createStyleJSON()); await style.once('style.load'); const spys = []; spys.push(vi.spyOn(style, 'addLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setPaintProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayoutProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setFilter').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'addSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGeoJSONSourceData').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayerZoomRange').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLight').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGlobalState').mockImplementation((() => {}) as any)); const didChange = style.setState(createStyleJSON()); expect(didChange).toBeFalsy(); for (const spy of spys) { expect(spy).not.toHaveBeenCalled(); } }); test('do operations if there are changes', async () => { const style = createStyle(); const styleJson = createStyleJSON({ state: { accentColor: { default: 'blue' } }, layers: [{ id: 'layerId0', type: 'symbol', source: 'sourceId0', 'source-layer': '123' }, { id: 'layerId1', type: 'circle', source: 'sourceId1', 'source-layer': '' }], sources: { sourceId0: createGeoJSONSource(), sourceId1: createGeoJSONSource(), }, light: { anchor: 'viewport' }, sky: { 'atmosphere-blend': 0 } }); style.loadJSON(styleJson); await style.once('style.load'); const spys = []; spys.push(vi.spyOn(style, 'addLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setPaintProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayoutProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setFilter').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'addSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayerZoomRange').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLight').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGeoJSONSourceData').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGlyphs').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setSprite').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setProjection').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style.map, 'setTerrain').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGlobalState').mockImplementation((() => {}) as any)); const newStyle = JSON.parse(JSON.stringify(styleJson)) as StyleSpecification; newStyle.state.accentColor.default = 'red'; newStyle.layers[0].paint = {'text-color': '#7F7F7F',}; newStyle.layers[0].layout = {'text-size': 16,}; newStyle.layers[0].minzoom = 2; (newStyle.layers[0] as SymbolLayerSpecification).filter = ['==', 'id', 1]; newStyle.layers.splice(1, 1); newStyle.sources['foo'] = createSource(); delete newStyle.sources['sourceId1']; newStyle.light = { anchor: 'map' }; newStyle.layers.push({ id: 'layerId2', type: 'circle', source: 'sourceId0' }); ((newStyle.sources.sourceId0 as GeoJSONSourceSpecification).data as GeoJSON.FeatureCollection).features.push({} as any); newStyle.glyphs = 'https://example.com/{fontstack}/{range}.pbf'; newStyle.sprite = 'https://example.com'; newStyle.terrain = { source: 'foo', exaggeration: 0.5 }; newStyle.zoom = 2; newStyle.projection = {type: 'globe'}; newStyle.sky = { 'fog-color': '#000001', 'sky-color': '#000002', 'horizon-fog-blend': 0.5, 'atmosphere-blend': 1 }; const didChange = style.setState(newStyle); expect(didChange).toBeTruthy(); for (const spy of spys) { expect(spy).toHaveBeenCalled(); } }); test('fire style.load event when JSON style is diffed', async () => { const style = createStyle(); const styleJson = createStyleJSON(); style.loadJSON(styleJson); await style.once('style.load'); const newStyleJSON: StyleSpecification = { ...styleJson, layers: [ { id: 'layerId2', type: 'background', }, ...styleJson.layers, ] }; const spy = vi.fn(); await style.once('style.load', spy); style.setState(newStyleJSON); expect(spy).toHaveBeenCalledWith(expect.objectContaining({style: style, type: 'style.load'})); }); test('change transition doesn\'t change the style, but is considered a change', async () => { const style = createStyle(); const styleJson = createStyleJSON(); style.loadJSON(styleJson); await style.once('style.load'); const spys = []; spys.push(vi.spyOn(style, 'addLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeLayer').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setPaintProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayoutProperty').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setFilter').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'addSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'removeSource').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLayerZoomRange').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setLight').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGeoJSONSourceData').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setGlyphs').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setSprite').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style.map, 'setTerrain').mockImplementation((() => {}) as any)); spys.push(vi.spyOn(style, 'setSky').mockImplementation((() => {}) as any)); const newStyleJson = createStyleJSON(); newStyleJson.transition = {duration: 5}; const didChange = style.setState(newStyleJson); expect(didChange).toBeTruthy(); for (const spy of spys) { expect(spy).not.toHaveBeenCalled(); } }); test('Issue #3893: compare new source options against originally provided options rather than normalized properties', async () => { server.respondWith('/tilejson.json', JSON.stringify({ tiles: ['http://tiles.server'] })); const initial = createStyleJSON(); initial.sources.mySource = { type: 'raster', url: '/tilejson.json' }; const style = new Style(getStubMap()); style.loadJSON(initial); const promise = style.once('style.load'); server.respond(); await promise; const spyRemove = vi.spyOn(style, 'removeSource').mockImplementation((() => {}) as any); const spyAdd = vi.spyOn(style, 'addSource').mockImplementation((() => {}) as any); style.setState(initial); expect(spyRemove).not.toHaveBeenCalled(); expect(spyAdd).not.toHaveBeenCalled(); }); test('return true if there is a change', async () => { const initialState = createStyleJSON(); const nextState = createStyleJSON({ sources: { foo: { type: 'geojson', data: {type: 'FeatureCollection', features: []} } } }); const style = new Style(getStubMap()); style.loadJSON(initialState); await style.once('style.load'); const didChange = style.setState(nextState); expect(didChange).toBeTruthy(); expect(style.stylesheet).toEqual(nextState); }); test('sets GeoJSON source data if different', async () => { const initialState = createStyleJSON({ 'sources': {'source-id': createGeoJSONSource()} }); const geoJSONSourceData = { 'type': 'FeatureCollection', 'features': [ { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [125.6, 10.1] } } ] }; const nextState = createStyleJSON({ 'sources': { 'source-id': { 'type': 'geojson', 'data': geoJSONSourceData } } }); const style = new Style(getStubMap()); style.loadJSON(initialState); await style.once('style.load'); const geoJSONSource = style.tileManagers['source-id'].getSource() as GeoJSONSource; const mockStyleSetGeoJSONSourceDate = vi.spyOn(style, 'setGeoJSONSourceData'); const mockGeoJSONSourceSetData = vi.spyOn(geoJSONSource, 'setData'); const didChange = style.setState(nextState); expect(mockStyleSetGeoJSONSourceDate).toHaveBeenCalledWith('source-id', geoJSONSourceData); expect(mockGeoJSONSourceSetData).toHaveBeenCalledWith(geoJSONSourceData); expect(didChange).toBeTruthy(); expect(style.stylesheet).toEqual(nextState); }); test('updates stylesheet according to applied transformStyle function', async () => { const initialState = createStyleJSON({ sources: { base: { type: 'geojson', data: {type: 'FeatureCollection', features: []} } }, layers: [{ id: 'layerId0', type: 'circle', source: 'base' }, { id: 'layerId1', type: 'circle', source: 'base' }] }); const nextState = createStyleJSON(); const style = new Style(getStubMap()); style.loadJSON(initialState); await style.once('style.load'); const didChange = style.setState(nextState, { transformStyle: (prevStyle, nextStyle) => ({ ...nextStyle, sources: { ...nextStyle.sources, base: prevStyle.sources.base }, layers: [ ...nextStyle.layers, prevStyle.layers[0] ] }) }); expect(didChange).toBeTruthy(); expect('base' in style.stylesheet.sources).toBeTruthy(); expect(style.stylesheet.layers[0].id).toBe(initialState.layers[0].id); expect(style.stylesheet.layers).toHaveLength(1); }); test('Style.setState skips validateStyle when validate false', async () => { const style = new Style(getStubMap()); const styleSpec = createStyleJSON(); style.loadJSON(styleSpec); await style.once('style.load'); style.addSource('abc', createSource()); const nextState = {...styleSpec}; nextState.sources['def'] = {type: 'geojson'} as GeoJSONSourceSpecification; const didChange = style.setState(nextState, {validate: false}); expect(didChange).toBeTruthy(); }); }); describe('Style.addSource', () => { test('throw before loaded', () => { const style = new Style(getStubMap()); expect(() => style.addSource('source-id', createSource())).toThrow(/load/i); }); test('throw if missing source type', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); const source = createSource(); delete source.type; await style.once('style.load'); expect(() => style.addSource('source-id', source)).toThrow(/type/i); }); test('fires "data" event', async () => { const style = createStyle(); style.loadJSON(createStyleJSON()); const source = createSource(); const dataPromise = style.once('data'); style.on('style.load', () => { style.addSource('source-id', source); style.update({} as EvaluationParameters); }); await dataPromise; }); test('throws on duplicates', async () => { const style = createStyle(); style.loadJSON(createStyleJSON()); const source = createSource(); await style.once('style.load'); style.addSource('source-id', source); expect(() => { style.addSource('source-id', source); }).toThrow(/Source "source-id" already exists./); }); test('sets up source event forwarding', async () => { const promisesResolve = {} as any; const promises = [ new Promise((resolve) => { promisesResolve.error = resolve; }), new Promise((resolve) => { promisesResolve.metadata = resolve; }), new Promise((resolve) => { promisesResolve.content = resolve; }), new Promise((resolve) => { promisesResolve.other = resolve; }), ]; const style = createStyle(); style.loadJSON(createStyleJSON({ layers: [{ id: 'background', type: 'background' }] })); const source = createSource(); await style.once('style.load'); style.on('error', () => { promisesResolve.error(); }); style.on('data', (e) => { if (e.sourceDataType === 'metadata' && e.dataType === 'source') { promisesResolve.metadata(); } else if (e.sourceDataType === 'content' && e.dataType === 'source') { promisesResolve.content(); } else { promisesResolve.other(); } }); style.addSource('source-id', source); // fires data twice style.tileManagers['source-id'].fire(new Event('error')); style.tileManagers['source-id'].fire(new Event('data')); await expect(Promise.all(promises)).resolves.toBeDefined(); }); }); describe('Style.removeSource', () => { test('throw before loaded', () => { const style = new Style(getStubMap()); expect(() => style.removeSource('source-id')).toThrow(/load/i); }); test('fires "data" event', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); const source = createSource(); const dataPromise = style.once('data'); style.on('style.load', () => { style.addSource('source-id', source); style.removeSource('source-id'); style.update({} as EvaluationParameters); }); await dataPromise; }); test('clears tiles', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON({ sources: {'source-id': createGeoJSONSource()} })); await style.once('style.load'); const tileManager = style.tileManagers['source-id']; vi.spyOn(tileManager, 'clearTiles'); style.removeSource('source-id'); expect(tileManager.clearTiles).toHaveBeenCalledTimes(1); }); test('throws on non-existence', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); expect(() => { style.removeSource('source-id'); }).toThrow(/There is no source with this ID/); }); async function createStyleAndLoad(): Promise<Style> { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON({ 'sources': { 'mapLibre-source': createGeoJSONSource() }, 'layers': [{ 'id': 'mapLibre-layer', 'type': 'circle', 'source': 'mapLibre-source', 'source-layer': 'whatever' }] })); await style.once('style.load'); style.update({zoom: 1} as EvaluationParameters); return style; } test('throws if source is in use', async () => { const style = await createStyleAndLoad(); const promise = style.once('error'); style.removeSource('mapLibre-source'); const event = await promise; expect(event.error.message.includes('"mapLibre-source"')).toBeTruthy(); expect(event.error.message.includes('"mapLibre-layer"')).toBeTruthy(); }); test('does not throw if source is not in use', async () => { const style = await createStyleAndLoad(); const promise = style.once('error'); style.removeLayer('mapLibre-layer'); style.removeSource('mapLibre-source'); await expect(Promise.any([promise, sleep(100)])).resolves.toBeUndefined(); }); test('tears down source event forwarding', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); const source = createSource(); await style.once('style.load'); style.addSource('source-id', source); const tileManager = style.tileManagers['source-id']; style.removeSource('source-id'); // Suppress error reporting tileManager.on('error', () => {}); style.on('data', () => { expect(false).toBeTruthy(); }); style.on('error', () => { expect(false).toBeTruthy(); }); tileManager.fire(new Event('data')); tileManager.fire(new Event('error')); }); }); describe('Style.setProjection', () => { test('does not mutate original input style JSON', async () => { const style = new Style(getStubMap()); const inputJson = createStyleJSON({projection: {type: 'mercator'}}); const inputJsonString = JSON.stringify(inputJson); const inputProjection = inputJson.projection; style.loadJSON(inputJson); await style.once('style.load'); style.setProjection({type: 'globe'}); expect(inputJson.projection).toBe(inputProjection); expect(inputJsonString).toEqual(JSON.stringify(inputJson)); }); }); describe('Style.setSky', () => { test('does not mutate original input style JSON', async () => { const style = new Style(getStubMap()); const inputJson = createStyleJSON({sky: {'sky-color': 'fuchsia'}}); const inputJsonString = JSON.stringify(inputJson); const inputSky = inputJson.sky; style.loadJSON(inputJson); await style.once('style.load'); style.setSky({'sky-color': 'magenta'}); expect(inputJson.sky).toBe(inputSky); expect(inputJsonString).toEqual(JSON.stringify(inputJson)); }); }); describe('Style.setGlyphs', () => { test('does not mutate original input style JSON', async () => { const style = new Style(getStubMap()); const inputJson = createStyleJSON({glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'}); const inputJsonString = JSON.stringify(inputJson); const inputGlyphs = inputJson.glyphs; style.loadJSON(inputJson); await style.once('style.load'); style.setGlyphs('https://foo.maplibre.org/font/{fontstack}/{range}.pbf'); expect(inputJson.glyphs).toBe(inputGlyphs); expect(inputJsonString).toEqual(JSON.stringify(inputJson)); }); test('allows glyphs to be unset via null and undefined', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); style.update({zoom: 1} as EvaluationParameters); const glyphsUrl = 'https://foo.maplibre.org/font/{fontstack}/{range}.pbf'; // Set glyphs style.setGlyphs(glyphsUrl); expect(style.getGlyphsUrl()).toBe(glyphsUrl); // Unset via null style.setGlyphs(null); expect(style.getGlyphsUrl()).toBeNull(); // Set again style.setGlyphs(glyphsUrl); expect(style.getGlyphsUrl()).toBe(glyphsUrl); // Unset via undefined style.setGlyphs(undefined); expect(style.getGlyphsUrl()).toBeNull(); }); }); describe('Style.addSprite', () => { test('throw before loaded', () => { const style = new Style(getStubMap()); expect(() => style.addSprite('test', 'https://example.com/sprite')).toThrow(/load/i); }); test('validates input and fires an error if there\'s already an existing sprite with the same id', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); const promise = style.once('error'); style.addSprite('test', 'https://example.com/sprite'); style.addSprite('test', 'https://example.com/sprite2'); const error = await promise; expect(error.error.message).toMatch(/sprite: all the sprites' ids must be unique, but test is duplicated/); }); test('adds a new sprite to the stylesheet when there\'s no sprite at all', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); await style.once('style.load'); style.addSprite('test', 'https://example.com/sprite'); expect(style.stylesheet.sprite).toStrictEqual([{id: 'test', url: 'https://example.com/sprite'}]); }); test('adds a new sprite to the stylesheet when there\'s a stringy sprite existing', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON({sprite: 'https://example.com/default'})); await style.once('style.load');