UNPKG

maplibre-gl

Version:

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

575 lines (486 loc) 21.9 kB
import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest'; import {GEOJSON_TILE_LAYER_NAME} from '@maplibre/vt-pbf'; import {GeoJSONWorkerSource, type LoadGeoJSONParameters} from './geojson_worker_source'; import {StyleLayerIndex} from '../style/style_layer_index'; import {OverscaledTileID} from '../tile/tile_id'; import {setPerformance, sleep} from '../util/test/util'; import {type FakeServer, fakeServer} from 'nise'; import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings'; import type {GeoJSONVT} from '@maplibre/geojson-vt'; import type {Actor, IActor} from '../util/actor'; import type {TileParameters, WorkerTileParameters, WorkerTileResult, WorkerTileWithData} from './worker_source'; import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {WorkerTile} from './worker_tile'; const actor = {send: () => {}} as any as Actor; describe('geojson tile worker source', () => { const actor: IActor = {sendAsync: () => Promise.resolve({})} as any as IActor; test('GeoJSONWorkerSource.removeTile removes loaded tile', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); source.tileState.loaded = { '0': {} as WorkerTile }; const res = await source.removeTile({ source: 'source', uid: 0 } as any as TileParameters); expect(res).toBeUndefined(); expect(source.tileState.loaded).toEqual({}); }); test('GeoJSONWorkerSource.reloadTile reloads a previously-loaded tile', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); const parse = vi.fn().mockResolvedValue({} as WorkerTileResult); source.tileState.loaded = { '0': { status: 'done', vectorTile: {}, parse } as any as WorkerTile }; const reloadPromise = source.reloadTile({uid: 0} as any as WorkerTileParameters); expect(parse).toHaveBeenCalledTimes(1); await expect(reloadPromise).resolves.toBeTruthy(); }); test('GeoJSONWorkerSource.reloadTile returns parse result without rawTileData when parsing state was already consumed', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); const parseResult = {buckets: []} as any as WorkerTileResult; const parse = vi.fn().mockResolvedValue(parseResult); source.tileState.loaded = { '0': { status: 'parsing', vectorTile: {}, parse } as any as WorkerTile }; const result = await source.reloadTile({uid: 0} as any as WorkerTileParameters) as WorkerTileWithData; expect(parse).toHaveBeenCalledTimes(1); expect(result).toBe(parseResult); expect(result.rawTileData).toBeUndefined(); }); test('GeoJSONWorkerSource.loadTile reparses tile if reloadTile has been called during parsing', async () => { const layerIndex = new StyleLayerIndex([{ id: 'test', source: 'source', 'source-layer': '_geojsonTileLayer', type: 'symbol', layout: { 'icon-image': 'hello', 'text-font': ['StandardFont-Bold'], 'text-field': '{name}' } }]); const actor = { sendAsync: (message: {type: string; data: unknown}, abortController: AbortController) => { return new Promise((resolve, _reject) => { const res = setTimeout(() => { const response = message.type === 'getImages' ? {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}; resolve(response); }, 100); abortController.signal.addEventListener('abort', () => { clearTimeout(res); }); }); } }; const source = new GeoJSONWorkerSource(actor as any, layerIndex, ['hello']); const geoJson = { type: 'FeatureCollection', features: [{ type: 'Feature', id: 1, geometry: { type: 'Point', coordinates: [0, 0] }, properties: { name: 'test' } }] } as GeoJSON.GeoJSON; await source.loadData({source: 'source', data: geoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision, } as any as WorkerTileParameters).then(() => expect(false).toBeTruthy()); // allow promise to run await sleep(0); const res = await source.reloadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision, } as any as WorkerTileParameters) as WorkerTileWithData; expect(res).toBeDefined(); expect(res.rawTileData).toBeDefined(); }); test('GeoJSONWorkerSource.loadTile returns null for an empty tile', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); await source.loadData({source: 'source', data: {type: 'FeatureCollection', features: []}, geojsonVtOptions: {}} as LoadGeoJSONParameters); const result = await source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, } as any as WorkerTileParameters); expect(result).toBeNull(); }); test('GeoJSONWorkerSource.loadTile throws error when data has not been loaded', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); await expect(source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, } as any as WorkerTileParameters)).rejects.toThrowError(/Unable to parse the data into a cluster or geojson/); }); test('GeoJSONWorkerSource.abortTile aborts tile state', async () => { const source = new GeoJSONWorkerSource(actor, new StyleLayerIndex(), []); const abortSpy = vi.spyOn(source.tileState, 'abort'); await source.abortTile({ source: 'source', uid: 0 } as any as TileParameters); expect(abortSpy).toHaveBeenCalledWith(0); }); }); describe('reloadTile', () => { test('does not rebuild vector data unless data has changed', async () => { const layers = [ { id: 'mylayer', source: 'sourceId', type: 'symbol', } ] as LayerSpecification[]; const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); const spy = vi.spyOn(source, 'loadVectorTile'); const geoJson = { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [0, 0] } }; const tileParams = { source: 'sourceId', uid: 0, tileID: new OverscaledTileID(0, 0, 0, 0, 0), maxZoom: 10 }; await source.loadData({source: 'sourceId', data: geoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); // first call should load vector data from geojson const firstData = await source.reloadTile(tileParams as any as WorkerTileParameters) as WorkerTileWithData; expect(spy).toHaveBeenCalledTimes(1); // second call won't give us new rawTileData let data = await source.reloadTile(tileParams as any as WorkerTileParameters) as WorkerTileWithData; expect('rawTileData' in data).toBeFalsy(); data.rawTileData = firstData.rawTileData; data.encoding = 'mvt'; expect(data).toEqual(firstData); // also shouldn't call loadVectorData again expect(spy).toHaveBeenCalledTimes(1); // replace geojson data await source.loadData({source: 'sourceId', data: geoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); // should call loadVectorData again after changing geojson data data = await source.reloadTile(tileParams as any as WorkerTileParameters) as WorkerTileWithData; expect('rawTileData' in data).toBeTruthy(); expect(data).toEqual(firstData); expect(spy).toHaveBeenCalledTimes(2); }); test('handles null and undefined properties during tile serialization', async () => { const layers = [ { id: 'mylayer', source: 'sourceId', type: 'symbol', } ] as LayerSpecification[]; const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); const geoJson = { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [0, 0] }, 'properties': { 'nullProperty': null, 'undefinedProperty': undefined, 'stringProperty': 'string' } }; const tileParams = { source: 'sourceId', uid: 0, tileID: new OverscaledTileID(0, 0, 0, 0, 0), maxZoom: 10 }; await source.loadData({type: 'geojson', source: 'sourceId', data: geoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); // load vector data from geojson, passing through the tile serialization step const data = await source.reloadTile(tileParams as any as WorkerTileParameters) as WorkerTileWithData; expect(data.featureIndex).toBeDefined(); // deserialize tile layers in the feature index data.featureIndex.rawTileData = data.rawTileData; const featureLayers = data.featureIndex.loadVTLayers(); expect(Object.keys(featureLayers)).toHaveLength(1); // validate supported features are present in the index expect(featureLayers[GEOJSON_TILE_LAYER_NAME].feature(0).properties['stringProperty']).toBeDefined(); }); }); describe('resourceTiming', () => { let server: FakeServer; beforeEach(() => { setPerformance(); global.fetch = null; server = fakeServer.create(); }); afterEach(() => { server.restore(); vi.clearAllMocks(); }); const layers = [ { id: 'mylayer', source: 'sourceId', type: 'symbol', } ] as LayerSpecification[]; const geoJson = { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [0, 0] } } as GeoJSON.GeoJSON; test('loadData - url', async () => { server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); const exampleResourceTiming = { connectEnd: 473, connectStart: 473, decodedBodySize: 86494, domainLookupEnd: 473, domainLookupStart: 473, duration: 341, encodedBodySize: 52528, entryType: 'resource', fetchStart: 473.5, initiatorType: 'xmlhttprequest', name: 'http://localhost:2900/fake.geojson', nextHopProtocol: 'http/1.1', redirectEnd: 0, redirectStart: 0, requestStart: 477, responseEnd: 815, responseStart: 672, secureConnectionStart: 0 } as any as PerformanceEntry; window.performance.getEntriesByName = vi.fn().mockReturnValue([exampleResourceTiming]); const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); const promise = source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}, geojsonVtOptions: {}} as LoadGeoJSONParameters); server.respond(); const result = await promise; expect(result.resourceTiming.testSource).toEqual([exampleResourceTiming]); }); test('loadData - url (resourceTiming fallback method)', async () => { server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); const sampleMarks = [100, 350]; const marks = {}; const measures = {}; window.performance.getEntriesByName = vi.fn().mockImplementation((name) => { return measures[name] || []; }); vi.spyOn(performance, 'mark').mockImplementation((name) => { marks[name] = sampleMarks.shift(); return null; }); window.performance.measure = vi.fn().mockImplementation((name, start, end) => { measures[name] = measures[name] || []; measures[name].push({ duration: marks[end] - marks[start], entryType: 'measure', name, startTime: marks[start] }); return null; }); vi.spyOn(performance, 'clearMarks').mockImplementation(() => { return null; }); vi.spyOn(performance, 'clearMeasures').mockImplementation(() => { return null; }); const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); const promise = source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}, geojsonVtOptions: {}} as LoadGeoJSONParameters); server.respond(); const result = await promise; expect(result.resourceTiming.testSource).toEqual( [{'duration': 250, 'entryType': 'measure', 'name': 'http://localhost/nonexistent', 'startTime': 100}] ); }); test('loadData - data', async () => { const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); const result = await source.loadData({source: 'testSource', data: geoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); expect(result.resourceTiming).toBeUndefined(); expect(result.data).toBeUndefined(); }); }); describe('loadData', () => { let server: FakeServer; beforeEach(() => { global.fetch = null; server = fakeServer.create(); }); afterEach(() => { server.restore(); }); const layers = [ { id: 'layer1', source: 'source1', type: 'symbol', }, { id: 'layer2', source: 'source2', type: 'symbol', } ] as LayerSpecification[]; const geoJson = { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [0, 0] } } as GeoJSON.GeoJSON; const updateableGeoJson = { type: 'Feature', id: 'point', geometry: { type: 'Point', coordinates: [0, 0], }, properties: {}, } as GeoJSON.GeoJSON; const updateableFeatureCollection = { type: 'FeatureCollection', features: [ { type: 'Feature', id: 'point1', geometry: { type: 'Point', coordinates: [0, 0], }, properties: {}, }, { type: 'Feature', id: 'point2', geometry: { type: 'Point', coordinates: [1, 1], }, properties: {}, } ] } as GeoJSON.GeoJSON; const layerIndex = new StyleLayerIndex(layers); function createWorker() { return new GeoJSONWorkerSource(actor, layerIndex, []); } test('abandons previous requests', async () => { const worker = createWorker(); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); const p1 = worker.loadData({source: 'source1', request: {url: ''}, geojsonVtOptions: {}} as LoadGeoJSONParameters); await sleep(0); const p2 = worker.loadData({source: 'source1', request: {url: ''}, geojsonVtOptions: {}} as LoadGeoJSONParameters); await sleep(0); server.respond(); const firstCallResult = await p1; expect(firstCallResult && firstCallResult.abandoned).toBeTruthy(); const result = await p2; expect(result && result.abandoned).toBeFalsy(); }); test('removeSource aborts requests', async () => { const worker = createWorker(); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); const loadPromise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); await sleep(0); const removePromise = worker.removeSource({source: 'source1', type: 'type'}); await sleep(0); server.respond(); const result = await loadPromise; expect(result && result.abandoned).toBeTruthy(); await removePromise; }); test('loadData with geojson creates an updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); await worker.loadData({source: 'source1', data: updateableGeoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}, geojsonVtOptions: {}} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); test('loadData with geojson network call creates an updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(updateableGeoJson)); }); const load1Promise = worker.loadData({source: 'source1', request: {url: ''}, geojsonVtOptions: {}} as LoadGeoJSONParameters); server.respond(); const result = await load1Promise; expect(result.data).toStrictEqual(updateableGeoJson); await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); test('loadData with diff updates', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); await worker.loadData({source: 'source1', data: updateableGeoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); const result = await worker.loadData({source: 'source1', dataDiff: { add: [{ type: 'Feature', id: 'update_point', geometry: {type: 'Point', coordinates: [0, 0]}, properties: {} }] }} as LoadGeoJSONParameters); expect(result).toBeDefined(); expect(result.data).toBeUndefined(); }); test('loadData should reject as first call with no data', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); await expect(worker.loadData({} as LoadGeoJSONParameters)).rejects.toBeDefined(); }); test('loadData should resolve as subsequent call with no data', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); await worker.loadData({source: 'source1', data: updateableGeoJson, geojsonVtOptions: {}} as LoadGeoJSONParameters); await expect(worker.loadData({} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); test('loadData should process cluster change with no data and build relevant map and reduce methods', async () => { const updateSpy = vi.fn(); const mockGeoJSONIndex = { updateClusterOptions: updateSpy } as any as GeoJSONVT; const worker = new GeoJSONWorkerSource(actor, layerIndex, [], () => mockGeoJSONIndex); await worker.loadData({source: 'source1', data: updateableFeatureCollection, geojsonVtOptions: {cluster: false}} as LoadGeoJSONParameters); expect(mockGeoJSONIndex.updateClusterOptions).not.toHaveBeenCalled(); await expect(worker.loadData({ type: 'geojson', updateCluster: true, geojsonVtOptions: { cluster: true, clusterOptions: {}, }, clusterProperties: { 'max': ['max', ['get', 'scalerank']], 'sum': ['+', ['get', 'scalerank']], } } as LoadGeoJSONParameters)).resolves.toBeDefined(); expect(updateSpy).toHaveBeenCalled(); expect(updateSpy.mock.calls[0][1].map).toBeInstanceOf(Function); expect(updateSpy.mock.calls[0][1].reduce).toBeInstanceOf(Function); }); });