UNPKG

maplibre-gl

Version:

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

630 lines (561 loc) 24 kB
import {CanonicalTileID} from '../tile/tile_id.ts'; import {ErrorEvent, Evented} from '../util/evented.ts'; import {MapSourceDataEvent, type SourceEventType} from '../ui/events.ts'; import {ImageRequest} from '../util/image_request.ts'; import {ResourceType} from '../util/request_manager.ts'; import {Texture} from '../webgl/texture.ts'; import {MercatorCoordinate} from '../geo/mercator_coordinate.ts'; import type {Source} from './source.ts'; import type {CanvasSourceSpecification} from './canvas_source.ts'; import type {Map} from '../ui/map.ts'; import type {Dispatcher} from '../util/dispatcher.ts'; import type {Tile} from '../tile/tile.ts'; import type { ImageSourceSpecification, VideoSourceSpecification } from '@maplibre/maplibre-gl-style-spec'; import type Point from '@mapbox/point-geometry'; import {ensureError, MAX_TILE_ZOOM} from '../util/util.ts'; import {Bounds} from '../geo/bounds.ts'; import {isAbortError} from '../util/abort_error.ts'; import { bilinearImageWarp, type RasterImageWarp } from '../webgl/program/raster_program.ts'; import {mat2} from 'gl-matrix'; import {createTileMeshWithBuffers} from '../util/create_tile_mesh.ts'; import type {Context} from '../webgl/context.ts'; import type {Mesh} from '../render/mesh.ts'; /** * How many grid cells per axis the mesh of a subdivided quad has. Within a cell the bilinear warp * is still approximated by one mapping per triangle, so the error falls off with the cell size. */ const SUBDIVIDED_QUAD_GRANULARITY = 16; /** * How much a warp may foreshorten the image, as the ratio between the largest and the smallest * homogeneous denominator over the quad, while staying purely projective. Ordinary oblique imagery * stays well below it. */ const PROJECTIVE_FORESHORTENING = 4; /** * The foreshortening at which a warp is taken to be purely bilinear. * * The denominator vanishes on the warp's vanishing line, and that line touches the quad exactly when * the quad degenerates into a triangle, so foreshortening grows without bound as a corner approaches * the diagonal between its neighbours. Left alone, such a quad keeps a valid but wildly lopsided * warp, squeezing nearly the whole image into a sliver along one edge and magnifying a handful of * texels over the rest of the quad. */ const BILINEAR_FORESHORTENING = 512; /** * Four geographical coordinates, * represented as arrays of longitude and latitude numbers, which define the corners of the image. * The coordinates start at the top left corner of the image and proceed in clockwise order. * They do not have to represent a rectangle. */ export type Coordinates = [[number, number], [number, number], [number, number], [number, number]]; /** * @experimental * How an {@link ImageSource} warps its image onto its four coordinates, for the cases where the * coordinates do not form a rectangle. * * - `perspective` maps the image as the perspective view of a plane, which is what georeferenced * photography and any other image of a flat scene wants: straight lines in the image stay * straight, and the image foreshortens towards its more distant edge. * - `flat` interpolates the image between the four coordinates bilinearly, pinning it like a rubber * sheet, which is what an image being reshaped by hand wants: every corner moves the image only * near itself, and the result is stable no matter how far a corner is dragged. Straight lines in * the image only stay straight while they run parallel to its edges, and there is no * foreshortening, in the same sense as the CSS `transform-style: flat`. * - `auto`, the default, is `perspective` while the coordinates plausibly describe a perspective * view, and blends continuously towards `flat` as they stop doing so, which they do as a corner * approaches the diagonal between its two neighbours. * * Coordinates with no perspective view at all - a concave, self-crossing or collinear quad - are * always warped flat, whichever of these is set. */ export type ImageSourceWarp = 'auto' | 'perspective' | 'flat'; /** * An already-decoded image that can be handed to an {@link ImageSource} directly, * without a network request. */ export type ImageSourceImage = HTMLImageElement | HTMLCanvasElement | ImageBitmap | ImageData; /** * The options object for the {@link ImageSource.updateImage} method. * * Provide exactly one of `url` (to load an image over the network) or `image` * (an already-decoded image to display directly, without a network request). */ export type UpdateImageOptions = { /** * The image coordinates */ coordinates?: Coordinates; } & ({ /** * The image URL to load. */ url: string; } | { /** * An already-decoded image (`HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap` or `ImageData`) * to display directly, without a network request. */ image: ImageSourceImage; }); export type CanonicalTileRange = { minTileY: number; maxTileY: number; /** * Image can exceed the boundary of a single "world" (tile 0/0/0), * so we need to know the tile range for wrapping. */ minTileXWrapped: number; maxTileXWrapped: number; minWrap: number; maxWrap: number; }; /** * A data source containing an image. * (See the [Style Specification](https://maplibre.org/maplibre-style-spec/#sources-image) for detailed documentation of options.) * * @group Sources * * @example * ```ts * // add to map * map.addSource('some id', { * type: 'image', * url: 'https://www.maplibre.org/images/foo.png', * coordinates: [ * [-76.54, 39.18], * [-76.52, 39.18], * [-76.52, 39.17], * [-76.54, 39.17] * ] * }); * * // update coordinates * let mySource = map.getSource('some id'); * mySource.setCoordinates([ * [-76.54335737228394, 39.18579907229748], * [-76.52803659439087, 39.1838364847587], * [-76.5295386314392, 39.17683392507606], * [-76.54520273208618, 39.17876344106642] * ]); * * // update url and coordinates simultaneously * mySource.updateImage({ * url: 'https://www.maplibre.org/images/bar.png', * coordinates: [ * [-76.54335737228394, 39.18579907229748], * [-76.52803659439087, 39.1838364847587], * [-76.5295386314392, 39.17683392507606], * [-76.54520273208618, 39.17876344106642] * ] * }) * * // update with an already-decoded image (no network request) * const bitmap = await createImageBitmap(myCanvas); * mySource.updateImage({image: bitmap}); * * map.removeSource('some id'); // remove * ``` */ export class ImageSource extends Evented<SourceEventType> implements Source { type: string; id: string; minzoom: number; maxzoom: number; tileSize: number; url: string; /** * This object is used to store the range of terrain tiles that overlap with this tile. * It is relevant for image tiles, as the image exceeds single tile boundaries. */ terrainTileRanges: {[zoom: string]: CanonicalTileRange}; coordinates: Coordinates; tiles: {[_: string]: Tile}; options: any; dispatcher: Dispatcher; map: Map; texture: Texture | null; image: ImageSourceImage; tileID: CanonicalTileID; tileCoords: Point[]; imageWarp: RasterImageWarp = bilinearImageWarp; flippedWindingOrder: boolean = false; _loaded: boolean; _abortController: AbortController; private _warp: ImageSourceWarp = 'auto'; private _imageDirty: boolean = false; /** * Whether the image has to be warped over a subdivided mesh instead of a pair of triangles, * because {@link imageWarp} is not the affine mapping that a parallelogram gets. */ private _subdividedQuad: boolean = false; private _subdividedMesh: Mesh | null = null; /** @internal */ constructor(id: string, options: ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) { super(); this.id = id; this.dispatcher = dispatcher; this.coordinates = options.coordinates; this.type = 'image'; this.minzoom = 0; this.maxzoom = 22; this.tileSize = 512; this.tiles = {}; this._loaded = false; this.setEventedParent(eventedParent); this.options = options; } async load(newCoordinates?: Coordinates): Promise<void> { this._loaded = false; this.fire(new MapSourceDataEvent('dataloading')); this.url = this.options.url; this._abortController = new AbortController(); try { const image = await ImageRequest.transformAndGetImage(this.map._requestManager, this.url, ResourceType.Image, this._abortController); this._abortController = null; this._loaded = true; if (image?.data) { this._setImage(image.data); if (newCoordinates) { this.coordinates = newCoordinates; } this._finishLoading(); } } catch (err) { // In case of abort error, the aborter may have started a new request so we don't want to clear its abort controller. if (isAbortError(err)) return; this._abortController = null; this._loaded = true; this.fire(new ErrorEvent(ensureError(err))); } } loaded(): boolean { return this._loaded; } /** * Updates the image and, optionally, the coordinates. To avoid having the image flash after changing, * set the `raster-fade-duration` paint property on the raster layer to 0. * * Provide exactly one of `url` (to fetch a new image over the network) or `image` (an * already-decoded `HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap` or `ImageData` to * display directly, without a network request). * * @param options - The options object. */ updateImage(options: UpdateImageOptions): this { if (this._abortController) { this._abortController.abort(); this._abortController = null; } if ('image' in options) { // Use the already-decoded image directly, skipping the network request. this._loaded = true; this._setImage(options.image); if (options.coordinates) { this.coordinates = options.coordinates; } this._finishLoading(); return this; } if (!options.url) { return this; } this.options.url = options.url; this.load(options.coordinates); return this; } /** Loaded tiles hold `this.texture`, so the wrapper has to outlive the images in it. */ private _setImage(image: ImageSourceImage): void { this.image = image; this._imageDirty = true; } /** * @internal * The mesh the image is drawn with, or null to use the tile mesh of the current projection. * * The raster vertex shader evaluates {@link imageWarp} per vertex, so a pair of triangles only * samples it at the four corners. That is enough for the affine mapping of a parallelogram, and * for a purely projective mapping, whose straight lines survive linear interpolation across the * diagonal. Anything in between is neither, and would be textured as two independently warped * halves with a visible seam along that diagonal, so it needs the warp evaluated per grid cell. * * A projection that subdivides its own tile meshes already does this, so it keeps them. */ getMesh(context: Context, projectionSubdividesTiles: boolean): Mesh | null { if (!this._subdividedQuad || projectionSubdividesTiles) { return null; } this._subdividedMesh ??= createTileMeshWithBuffers(context, {granularity: SUBDIVIDED_QUAD_GRANULARITY}); return this._subdividedMesh; } /** Teardown only: dropping the reference alone leaves the allocation to the GC. */ private _disposeTexture(): void { this.texture?.destroy(); this.texture = null; } _finishLoading(): void { if (this.map) { this.setCoordinates(this.coordinates); this.fire(new MapSourceDataEvent('data', {sourceDataType: 'metadata'})); } } onAdd(map: Map): void { this.map = map; this.load(); } onRemove(): void { if (this._abortController) { this._abortController.abort(); this._abortController = null; } this._disposeTexture(); this._subdividedMesh?.destroy(); this._subdividedMesh = null; this.image = null; this.tiles = {}; } /** * @experimental * Sets how the image is warped onto its coordinates and re-renders the map. * * This only has an effect while the coordinates do not form a rectangle, and it is not part of * the style specification, so it does not survive `map.setStyle`. * * @param warp - The warp to use, see {@link ImageSourceWarp}. * * @example * ```ts * // Keep the image pinned to its corners while the user drags them around. * map.getSource('some id').setWarp('flat'); * ``` */ setWarp(warp: ImageSourceWarp): this { if (this._warp === warp) { return this; } this._warp = warp; if (this.tileCoords) { this.setCoordinates(this.coordinates); } return this; } /** * @experimental * Returns how the image is warped onto its coordinates. * * @returns The warp in use, see {@link ImageSourceWarp}. */ getWarp(): ImageSourceWarp { return this._warp; } /** * Sets the image's coordinates and re-renders the map. * * @param coordinates - Four geographical coordinates, * represented as arrays of longitude and latitude numbers, which define the corners of the image. * The coordinates start at the top left corner of the image and proceed in clockwise order. * They do not have to represent a rectangle. */ setCoordinates(coordinates: Coordinates): this { this.coordinates = coordinates; // Calculate which mercator tile is suitable for rendering the video in // and create a buffer with the corner coordinates. These coordinates // may be outside the tile, because raster tiles aren't clipped when rendering. // transform the geo coordinates into (zoom 0) tile space coordinates const cornerCoords = coordinates.map(MercatorCoordinate.fromLngLat); // Compute the coordinates of the tile we'll use to hold this image's // render data this.tileID = getCoordinatesCenterTileID(cornerCoords); // Compute tiles overlapping with the image. We need to know for which // terrain tiles we have to render the image. this.terrainTileRanges = this._getOverlappingTileRanges(cornerCoords); // Constrain min/max zoom to our tile's zoom level in order to force // TileManager to request this tile (no matter what the map's zoom // level) this.minzoom = this.maxzoom = this.tileID.z; // Transform the corner coordinates into the coordinate space of our // tile. this.tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round()); this.imageWarp = calculateImageWarp(this.tileCoords, this._warp); // A purely projective warp survives a pair of triangles, because its straight lines are // straight in both, and so does any warp of a parallelogram, which is affine either way. // Anything else is textured as two independently warped halves without a subdivided mesh. this._subdividedQuad = this.imageWarp[2] > 0 && !isParallelogram(this.tileCoords); this.flippedWindingOrder = hasWrongWindingOrder(this.tileCoords); this.fire(new MapSourceDataEvent('data', {sourceDataType: 'content'})); return this; } prepare(): void { if (Object.keys(this.tiles).length === 0 || !this.image) { return; } const context = this.map.painter.context; const gl = context.gl; if (!this.texture) { this.texture = new Texture(context, this.image, gl.RGBA); this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); } else if (this._imageDirty) { this.texture.update(this.image); this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); } this._imageDirty = false; let newTilesLoaded = false; for (const w in this.tiles) { const tile = this.tiles[w]; if (tile.state !== 'loaded') { tile.state = 'loaded'; tile.texture = this.texture; newTilesLoaded = true; } } if (newTilesLoaded) { this.fire(new MapSourceDataEvent('data', {sourceDataType: 'idle', sourceId: this.id})); } } async loadTile(tile: Tile): Promise<void> { // We have a single tile -- whose coordinates are this.tileID -- that // covers the image we want to render. If that's the one being // requested, set it up with the image; otherwise, mark the tile as // `errored` to indicate that we have no data for it. // If the world wraps, we may have multiple "wrapped" copies of the // single tile. if (this.tileID?.equals(tile.tileID.canonical)) { this.tiles[String(tile.tileID.wrap)] = tile; tile.buckets = {}; } else { tile.state = 'errored'; } } serialize(): ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification { return { type: 'image', url: this.options.url, coordinates: this.coordinates }; } hasTransition() { return false; } /** * Given a list of coordinates, determine overlapping tile ranges for all zoom levels. * * @returns Overlapping tile ranges for all zoom levels. * @internal */ private _getOverlappingTileRanges( coords: MercatorCoordinate[] ): {[zoom: string]: CanonicalTileRange} { const {minX, minY, maxX, maxY} = Bounds.fromPoints(coords); const ranges: {[zoom: string]: CanonicalTileRange} = {}; for (let z = 0; z <= MAX_TILE_ZOOM; z++) { const tilesAtZoom = Math.pow(2, z); const minTileX = Math.floor(minX * tilesAtZoom); const minTileY = Math.floor(minY * tilesAtZoom); const maxTileX = Math.floor(maxX * tilesAtZoom); const maxTileY = Math.floor(maxY * tilesAtZoom); const minTileXWrapped = ((minTileX % tilesAtZoom) + tilesAtZoom) % tilesAtZoom; const maxTileXWrapped = maxTileX % tilesAtZoom; const minWrap = Math.floor(minTileX / tilesAtZoom); const maxWrap = Math.floor(maxTileX / tilesAtZoom); ranges[z] = { minWrap, maxWrap, minTileXWrapped, maxTileXWrapped, minTileY, maxTileY }; } return ranges; } } /** * Given a list of coordinates, get their center as a coordinate. * * @returns centerpoint * @internal */ export function getCoordinatesCenterTileID(coords: MercatorCoordinate[]): CanonicalTileID { const bounds = Bounds.fromPoints(coords); const dx = bounds.width(); const dy = bounds.height(); const dMax = Math.max(dx, dy); const zoom = Math.max(0, Math.floor(-Math.log(dMax) / Math.LN2)); const tilesAtZoom = Math.pow(2, zoom); return new CanonicalTileID( zoom, Math.floor((bounds.minX + bounds.maxX) / 2 * tilesAtZoom), Math.floor((bounds.minY + bounds.maxY) / 2 * tilesAtZoom)); } function hasWrongWindingOrder(coords: Point[]) { const e0x = coords[1].x - coords[0].x; const e0y = coords[1].y - coords[0].y; const e1x = coords[2].x - coords[0].x; const e1y = coords[2].y - coords[0].y; const crossProduct = e0x * e1y - e0y * e1x; return crossProduct < 0; } /** * How the image is warped onto its coordinates, given the corners in tile space and what the user * asked for. The projective warp is the perspective view of a plane, so it is the one a photograph * wants; the bilinear warp is a rubber sheet pinned at the corners, and is the only one left once * the corners stop describing a perspective view at all. * * Based on Paul S. Heckbert, "Fundamentals of Texture Mapping and Image * Warping", UCB/CSD-89-516, 1989, section 2.2.3 and appendix A.2. * * @see https://www2.eecs.berkeley.edu/Pubs/TechRpts/1989/5504.html * @see https://www.cs.cmu.edu/~ph/texfund/texfund.pdf */ function calculateImageWarp(cornerCoords: Point[], warp: ImageSourceWarp): RasterImageWarp { // A parallelogram is warped affinely either way, and skipping the math keeps the common case of // a rectangle free of the signed zeroes that dividing an exactly zero numerator produces. if (warp === 'flat' || isParallelogram(cornerCoords)) { return bilinearImageWarp; } const [topLeft, topRight, bottomRight, bottomLeft] = cornerCoords; const sumX = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x; const sumY = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y; const basis: mat2 = [ topRight.x - bottomRight.x, topRight.y - bottomRight.y, bottomLeft.x - bottomRight.x, bottomLeft.y - bottomRight.y ]; const [rightX, rightY, downX, downY] = basis; const determinant = mat2.determinant(basis); const perspectiveX = (sumX * downY - downX * sumY) / determinant; const perspectiveY = (rightX * sumY - sumX * rightY) / determinant; // The homogeneous denominator at the four corners of the unit square, normalized to one at the // top left. Its spread is how much the warp foreshortens the image, and it stays finite and // positive over the whole quad exactly while the quad is a perspective view of the image, so // this one comparison also rejects a collinear, concave or self-crossing quad. A degenerate // determinant reaches it as an infinite or not-a-number spread. const denominators = [1, 1 + perspectiveX, 1 + perspectiveX + perspectiveY, 1 + perspectiveY]; const foreshortening = Math.max(...denominators) / Math.min(...denominators); const blend = warp === 'perspective' ? 0 : bilinearBlend(foreshortening); if (!(foreshortening >= 1 && foreshortening <= BILINEAR_FORESHORTENING) || blend >= 1) { return bilinearImageWarp; } return [perspectiveX, perspectiveY, blend]; } /** * How far a warp of the given foreshortening is blended towards the bilinear one, ramping from * purely projective at {@link PROJECTIVE_FORESHORTENING} to purely bilinear at * {@link BILINEAR_FORESHORTENING}. Blending rather than switching keeps the image continuous as the * quad is reshaped: the two warps only agree for a parallelogram, and diverge by roughly a tenth of * the quad per unit of foreshortening, so a switch would visibly displace the image. */ function bilinearBlend(foreshortening: number): number { const ramp = (1 - PROJECTIVE_FORESHORTENING / foreshortening) / (1 - PROJECTIVE_FORESHORTENING / BILINEAR_FORESHORTENING); return Math.max(0, ramp); } function isParallelogram(cornerCoords: Point[]): boolean { const [topLeft, topRight, bottomRight, bottomLeft] = cornerCoords; return topLeft.x + bottomRight.x === topRight.x + bottomLeft.x && topLeft.y + bottomRight.y === topRight.y + bottomLeft.y; }