UNPKG

maplibre-gl

Version:

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

554 lines (499 loc) • 19.8 kB
import {vi, expect, onTestFinished, type Mock} from 'vitest'; import {Map, type MapOptions} from '../../ui/map.ts'; import {NullWebGL2RenderingContext, createNullGL} from './null_gl.ts'; import {extend} from '../../util/util.ts'; import {MessageType, type ActorMessage, type RequestResponseMessageMap} from '../actor_messages.ts'; import {Evented} from '../evented.ts'; import {createMercatorTransform, type MercatorTransform} from '../../geo/projection/mercator_transform.ts'; import {CRSWorldCoordinateHelper, identityCrs, type CRS} from '../../geo/projection/crs.ts'; import {RequestManager} from '../request_manager.ts'; import {Terrain} from '../../render/terrain.ts'; import {Painter} from '../../render/painter.ts'; import {TileManager} from '../../tile/tile_manager.ts'; import {MercatorCoordinate} from '../../geo/mercator_coordinate.ts'; import {EXTENT} from '../../data/extent.ts'; import {Frustum} from '../primitives/frustum.ts'; import {mat4} from 'gl-matrix'; import {DEMData} from '../../data/dem_data.ts'; import {RGBAImage} from '../image.ts'; import {shaders} from '../../shaders/shaders.ts'; import {MercatorShaderDefine, MercatorShaderVariantKey} from '../../geo/projection/mercator_projection.ts'; import type {OverscaledTileID} from '../../tile/tile_id.ts'; import type {Style} from '../../style/style.ts'; import type {IReadonlyTransform, ITransform} from '../../geo/transform_interface.ts'; import type {SourceSpecification, StyleSpecification, TerrainSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {SourceEventType} from '../../ui/events.ts'; import type {IActor} from '../actor.ts'; import type {FrameRenderData} from '../../render/frame_render_context.ts'; import type {Dispatcher} from '../../util/dispatcher.ts'; import type {Framebuffer} from '../../webgl/framebuffer.ts'; import type {Tile} from '../../tile/tile.ts'; import type {TerrainCoverageIndex, TerrainElevationSampler} from '../../render/terrain_coverage.ts'; export class StubMap extends Evented { style: Style; transform: IReadonlyTransform; private _requestManager: RequestManager; _terrain: TerrainSpecification; constructor() { super(); this.transform = createMercatorTransform(); this._requestManager = new RequestManager(); } _getMapId(): number { return 1; } getPixelRatio(): number { return 1; } setTerrain(terrain: TerrainSpecification): void { this._terrain = terrain; } getTerrain(): TerrainSpecification { return this._terrain; } migrateProjection(newTransform: ITransform): void { newTransform.apply(this.transform, true); this.transform = newTransform; } } export function createMap(options?: Partial<MapOptions> & {deleteStyle?: boolean}): Map { const container = window.document.createElement('div'); const defaultOptions = { container, interactive: false, attributionControl: false, maplibreLogo: false, trackResize: true, style: { 'version': 8, 'sources': {}, 'layers': [] } }; Object.defineProperty(container, 'clientWidth', {value: 200, configurable: true}); Object.defineProperty(container, 'clientHeight', {value: 200, configurable: true}); if (options?.deleteStyle) delete defaultOptions.style; return new Map(extend(defaultOptions, options)); } export function equalWithPrecision( test: {equal: (a: number, b: number, message: string, extra?: unknown) => unknown}, expected: number, actual: number, multiplier: number, message?: string, extra?: unknown ): unknown { message ||= `should be equal to within ${multiplier}`; const expectedRounded = Math.round(expected / multiplier) * multiplier; const actualRounded = Math.round(actual / multiplier) * multiplier; return test.equal(expectedRounded, actualRounded, message, extra); } export function setPerformance(): void { window.performance.mark = vi.fn(); window.performance.clearMeasures = vi.fn(); window.performance.clearMarks = vi.fn(); } export function setMatchMedia(): void { // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(query => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } function setResizeObserver() { global.ResizeObserver = vi.fn(class { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); }); } let _originalGetContext: typeof HTMLCanvasElement.prototype.getContext | undefined; function setNullGLGetContext() { _originalGetContext ??= HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (type: string, attributes?: any): any { if (type === 'webgl2') return new NullWebGL2RenderingContext(this, attributes); return _originalGetContext.call(this, type, attributes); } as any; } export function beforeMapTest(): void { setNullGLGetContext(); setPerformance(); setMatchMedia(); setResizeObserver(); onTestFinished(() => { HTMLCanvasElement.prototype.getContext = _originalGetContext; }); } export function getWrapDispatcher(): (actor: IActor) => Dispatcher { return (actor: IActor) => { return { actorsPromise: Promise.resolve([actor]), waitForInitComplete() { return Promise.resolve(); }, getActor() { return Promise.resolve(actor); }, getReadyActor() { return actor; } } as any as Dispatcher; }; } export function getMockDispatcher(): Dispatcher { const wrapDispatcher = getWrapDispatcher(); return wrapDispatcher({ sendAsync() { return Promise.resolve({}); }, }); } export function stubAjaxGetImage(createImageBitmap: typeof global.createImageBitmap): void { global.createImageBitmap = createImageBitmap; global.URL.revokeObjectURL = () => {}; global.URL.createObjectURL = (_) => { return null; }; Object.defineProperty(global.Image.prototype, 'src', { set(url: string) { if (url === 'error') { this.onerror(); } else if (this.onload) { this.onload(); } } }); } /** * This should be used in test that use nise since the internal buffer returned from a file is not an instance of ArrayBuffer for some reason. * @param data - the data read from a file, for example by `fs.readFileSync(...)` * @returns a copy of the data in the file in `ArrayBuffer` format */ export function bufferToArrayBuffer(data: Buffer): ArrayBuffer { const newBuffer = new ArrayBuffer(data.buffer.byteLength); const view = new Uint8Array(newBuffer); data.copy(view); return view.buffer; } /** * This allows test to wait for a certain amount of time before continuing. * @param milliseconds - the amount of time to wait in milliseconds * @returns - a promise that resolves after the specified amount of time */ export const sleep: (milliseconds?: number) => Promise<void> = (milliseconds: number = 0) => { return new Promise<void>(resolve => setTimeout(resolve, milliseconds)); }; export function waitForMetadataEvent(source: Evented<SourceEventType>): Promise<void> { return new Promise((resolve) => { source.on('data', (e) => { if (e.sourceDataType === 'metadata') { resolve(); } }); }); } export function createStyleSource(): SourceSpecification { return { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }; } export function createStyle(): StyleSpecification { return { version: 8, center: [-73.9749, 40.7736], zoom: 12.5, bearing: 29, pitch: 50, sources: {}, layers: [] }; } export function expectToBeCloseToArray(actual: number[], expected: number[], precision?: number): void { expect(actual).toHaveLength(expected.length); for (let i = 0; i < expected.length; i++) { expect(actual[i]).toBeCloseTo(expected[i], precision); } } export function createTerrain(): Terrain { return { getCoverageIndex: () => null, getElevationForLngLatZoom: () => 1000, getElevationForLngLat: () => 1000, getElevationAndDEMStateForLngLat: () => ({elevation: 1000, demState: 'loaded'}), getMinTileElevationForLngLatZoom: () => 0, resetElevationCache: () => {}, getFramebuffer: () => ({}), tileManager: { update: () => {}, getRenderableTiles: () => [], anyTilesAfterTime: () => false } } as any as Terrain; } export function createFramebuffer(): Framebuffer { return { colorAttachment: { get: () => null, set: () => {} }, depthAttachment: { get: () => null, set: () => {} }, destroy: () => {} } as unknown as Framebuffer; } export function waitForEvent(evented: Evented, eventName: string, predicate: (e: any) => boolean): Promise<any> { return new Promise((resolve) => { const listener = (e: Event) => { if (predicate(e)) { resolve(e); } }; evented.on(eventName, listener); }); } export function createTestCameraFrustum(fovy: number, aspectRatio: number, zNear: number, zFar: number, elevation: number, rotation: number): Frustum { const proj = new Float64Array(16) as any as mat4; const invProj = new Float64Array(16) as any as mat4; // Note that left handed coordinate space is used where z goes towards the sky. // Y has to be flipped as well because it's part of the projection/camera matrix used in transform.js mat4.perspective(proj, fovy, aspectRatio, zNear, zFar); mat4.scale(proj, proj, [1, -1, 1]); mat4.translate(proj, proj, [0, 0, elevation]); mat4.rotateZ(proj, proj, rotation); mat4.invert(invProj, proj); return Frustum.fromInvProjectionMatrix(invProj, 1.0, 0.0); } export function createDEM(heightFn: (x: number, y: number) => number, dim: number = 8): DEMData { const stride = dim + 4; const pixels = new Uint8Array(stride * stride * 4); for (let y = 0; y < dim; y++) { for (let x = 0; x < dim; x++) { const value = heightFn(x, y) + 32768; const index = ((y + 2) * stride + x + 2) * 4; pixels[index] = Math.floor(value / 256); pixels[index + 1] = Math.floor(value) % 256; pixels[index + 2] = Math.round((value - Math.floor(value)) * 256); pixels[index + 3] = 255; } } return new DEMData('dem', new RGBAImage({width: stride, height: stride}, pixels), 'terrarium'); } /** A painter over a null GL context. */ export function createPainter(): Painter { return new Painter(createNullGL()); } /** A tile manager over a raster-dem source that never loads, for terrain built in tests. */ export function createRasterDEMTileManager(): TileManager { return new TileManager('dem', {type: 'raster-dem', tiles: ['/dem/{z}/{x}/{y}.png'], tileSize: 512}, getMockDispatcher()); } /** The margin a terrain's coverage index keeps between its elevation bracket and its tiles' lowest and highest elevation. */ const COVERAGE_BRACKET_PADDING_M = 10; /** * The coverage index of a terrain drawn as one world tile whose surface is `height` meters at a location, between * `minElevation` and `maxElevation` meters. */ export function createCoverageIndex(height: (lng: number, lat: number) => number, minElevation: number, maxElevation: number): TerrainCoverageIndex { const sampler: TerrainElevationSampler = (x, y) => { const {lng, lat} = new MercatorCoordinate(x / EXTENT, y / EXTENT).toLngLat(); return height(lng, lat); }; return { zooms: [0], samplerPerTile: new globalThis.Map([['0/0/0/0', sampler]]), minElevation: minElevation - COVERAGE_BRACKET_PADDING_M, maxElevation: maxElevation + COVERAGE_BRACKET_PADDING_M }; } export function createDEMTerrain(tileIDs: OverscaledTileID[], dem: DEMData | null, exaggeration: number = 1): Terrain { const terrain = new Terrain(createPainter(), createRasterDEMTileManager(), {source: 'dem', exaggeration}); terrain.tileManager.getRenderableTiles = () => tileIDs.map(tileID => ({tileID}) as Tile); terrain.tileManager.getSourceTile = (tileID) => (dem ? {tileID, dem} as Tile : undefined); return terrain; } const fakeImages = { hello: {data: {width: 1, height: 1, data: new Uint8Array([0])}, pixelRatio: 1, sdf: false, version: 0} }; /** * The glyph a {@link createFakeActor} answers a `getGlyphs` request with, keyed by the grapheme * cluster layout asks for it by. */ const fakeGlyphs = { 'StandardFont-Bold': { default: { e: {id: 101, bitmap: {width: 1, height: 1, data: new Uint8Array([0])}, metrics: {width: 1, height: 1, left: 0, top: 0, advance: 1}} } } }; /** * An actor that answers a worker source's requests for images and glyphs with fixtures, after a * delay long enough that a test can abort the request part-way. * * @param shouldAbort - consulted on every request. Where it is given, the actor also rejects a * request that is aborted while in flight; where it is not, an aborted request is simply never * answered, as it is for a source that has moved on. * @param onAbort - called whenever a request in flight is aborted */ export function createFakeActor(shouldAbort?: () => boolean, onAbort?: () => void): IActor & {sendAsync: Mock} { return { sendAsync: vi.fn(<T extends MessageType>(message: ActorMessage<T>, abortController?: AbortController): Promise<RequestResponseMessageMap[T][1]> => { if (shouldAbort?.()) return Promise.reject('aborted by test'); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { const response = message.type === MessageType.getImages ? fakeImages : fakeGlyphs; resolve(response as RequestResponseMessageMap[T][1]); }, 100); abortController?.signal.addEventListener('abort', () => { clearTimeout(timeout); onAbort?.(); if (shouldAbort) reject('aborted by abortController'); }); }); }) }; } /** * A transform over the built-in identity CRS (the identity over lng/lat, with tile 0/0/0 spanning -90..90 on both * axes), sized to the given viewport. */ export function createIdentityCrsTransform(width: number, height: number): MercatorTransform { const transform = createMercatorTransform({ minZoom: -5, maxZoom: 22, minPitch: 0, maxPitch: 85, renderWorldCopies: true, }); transform.setWorldCoordinateHelper(new CRSWorldCoordinateHelper(identityCrs)); transform.resize(width, height); return transform; } /** * A synthetic CRS whose axes both depend on lng and lat: lng/lat rotated by 30 degrees, * laid out in degrees, with tile 0/0/0 spanning -150..150 on each rotated axis. */ export function createRotatedCrs(): CRS { const cos = Math.cos(Math.PI / 6); const sin = Math.sin(Math.PI / 6); return { name: 'test:rotated', projection: { forward([lng, lat]) { return [lng * cos - lat * sin, lng * sin + lat * cos]; }, inverse([x, y]) { return [x * cos + y * sin, -x * sin + y * cos]; }, }, tileMatrixSet: {origin: [-150, 150], extentAtZoom0: 300}, }; } /** * The identity CRS with a converter that answers only inside its tile matrix set, -90..90 on both axes, * and gives `answerOutside` everywhere else. */ export function createIdentityCrsAnsweringInside(answerOutside: number[] | null): CRS { return { name: 'test:identity-answering-inside', projection: { forward([lng, lat]) { return [lng, lat]; }, inverse([x, y]) { return Math.abs(x) <= 90 && Math.abs(y) <= 90 ? [x, y] : answerOutside; }, }, tileMatrixSet: {origin: [-90, 90], extentAtZoom0: 180}, }; } /** * Plate carrée on a tile 0/0/0 twice as wide as the world, with an inverse that wraps a position past ±180 around * to the other side of the world, as proj4 does past the edge of a world projection. */ export function createWrappingWorldCrs(): CRS { return { name: 'test:wrapping-world', projection: { forward([lng, lat]) { return [lng, lat]; }, inverse([x, y]) { return [(x + 540) % 360 - 180, y]; }, }, tileMatrixSet: {origin: [-360, 360], extentAtZoom0: 720}, }; } /** * Plate carrée on a tile 0/0/0 twice as wide as the world, with an inverse that answers everywhere, so a position * past ±180 has a longitude of its own. */ export function createWidePlateCarreeCrs(): CRS { return { name: 'test:wide-plate-carree', projection: { forward([lng, lat]) { return [lng, lat]; }, inverse([x, y]) { return [x, y]; }, }, tileMatrixSet: {origin: [-360, 360], extentAtZoom0: 720}, }; } /** * A north polar stereographic CRS on a sphere, oriented like EPSG:3413 (lng -45 points down) and true to scale * at 70N, with tile 0/0/0 spanning the EPSG:3413 grid NASA GIBS serves. Parallels are circles around the pole. */ export function createPolarStereographicCrs(): CRS { const degrees = 180 / Math.PI; const scale = 6378137 * (1 + Math.sin(70 / degrees)); const centralLng = -45; return { name: 'test:polar', projection: { forward([lng, lat]) { const rho = scale * Math.tan(Math.PI / 4 - lat / degrees / 2); const angle = (lng - centralLng) / degrees; return [rho * Math.sin(angle), -rho * Math.cos(angle)]; }, inverse([x, y]) { return [centralLng + Math.atan2(x, -y) * degrees, 90 - 2 * Math.atan(Math.hypot(x, y) / scale) * degrees]; }, }, tileMatrixSet: {origin: [-4194304, 4194304], extentAtZoom0: 8388608}, }; } /** * Returns frame data for a still mercator map with every debug option off. */ export function createFrameRenderData(): FrameRenderData { return { showOverdrawInspector: false, showTileBoundaries: false, showPadding: false, rotating: false, zooming: false, moving: false, fadeDuration: 0, symbolFadeChange: 1, variableOffsets: {}, anisotropicFilterPitch: 20, projectionTransition: 0, isRenderingGlobe: false, projectionShaderVariant: {name: MercatorShaderVariantKey, define: MercatorShaderDefine, prelude: shaders.projectionMercator}, useSubdivision: false, pixelRatio: 1, light: undefined, sky: undefined }; }