import {CanonicalTileID} from './tile_id'; import {Event, ErrorEvent, Evented} from '../util/evented'; import {ImageRequest} from '../util/image_request'; import {ResourceType} from '../util/request_manager'; import {EXTENT} from '../data/extent'; import {RasterBoundsArray} from '../data/array_types.g'; import rasterBoundsAttributes from '../data/raster_bounds_attributes'; import {SegmentVector} from '../data/segment'; import {Texture} from '../render/texture'; import {MercatorCoordinate} from '../geo/mercator_coordinate'; import type {Source} from './source'; import type {CanvasSourceSpecification} from './canvas_source'; import type {Map} from '../ui/map'; import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; import type {VertexBuffer} from '../gl/vertex_buffer'; import type { ImageSourceSpecification, VideoSourceSpecification } from '@maplibre/maplibre-gl-style-spec'; /** * 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]]; /** * The options object for the {@link ImageSource#updateImage} method */ export type UpdateImageOptions = { /** * Required image URL. */ url: string; /** * The image coordinates */ coordinates?: Coordinates; } /** * 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] * ] * }) * * map.removeSource('some id'); // remove * ``` */ export class ImageSource extends Evented implements Source { type: string; id: string; minzoom: number; maxzoom: number; tileSize: number; url: string; coordinates: Coordinates; tiles: {[_: string]: Tile}; options: any; dispatcher: Dispatcher; map: Map; texture: Texture | null; image: HTMLImageElement | ImageBitmap; tileID: CanonicalTileID; _boundsArray: RasterBoundsArray; boundsBuffer: VertexBuffer; boundsSegments: SegmentVector; _loaded: boolean; _request: AbortController; /** @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 { this._loaded = false; this.fire(new Event('dataloading', {dataType: 'source'})); this.url = this.options.url; this._request = new AbortController(); try { const image = await ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request); this._request = null; this._loaded = true; if (image && image.data) { this.image = image.data; if (newCoordinates) { this.coordinates = newCoordinates; } this._finishLoading(); } } catch (err) { this._request = null; this._loaded = true; this.fire(new ErrorEvent(err)); } } loaded(): boolean { return this._loaded; } /** * Updates the image URL 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. * * @param options - The options object. */ updateImage(options: UpdateImageOptions): this { if (!options.url) { return this; } if (this._request) { this._request.abort(); this._request = null; } this.options.url = options.url; this.load(options.coordinates).finally(() => { this.texture = null; }); return this; } _finishLoading() { if (this.map) { this.setCoordinates(this.coordinates); this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); } } onAdd(map: Map) { this.map = map; this.load(); } onRemove() { if (this._request) { this._request.abort(); this._request = null; } } /** * 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); // Constrain min/max zoom to our tile's zoom level in order to force // SourceCache 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. const tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round()); this._boundsArray = new RasterBoundsArray(); this._boundsArray.emplaceBack(tileCoords[0].x, tileCoords[0].y, 0, 0); this._boundsArray.emplaceBack(tileCoords[1].x, tileCoords[1].y, EXTENT, 0); this._boundsArray.emplaceBack(tileCoords[3].x, tileCoords[3].y, 0, EXTENT); this._boundsArray.emplaceBack(tileCoords[2].x, tileCoords[2].y, EXTENT, EXTENT); if (this.boundsBuffer) { this.boundsBuffer.destroy(); delete this.boundsBuffer; } this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); return this; } prepare() { if (Object.keys(this.tiles).length === 0 || !this.image) { return; } const context = this.map.painter.context; const gl = context.gl; if (!this.boundsBuffer) { this.boundsBuffer = context.createVertexBuffer(this._boundsArray, rasterBoundsAttributes.members); } if (!this.boundsSegments) { this.boundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2); } if (!this.texture) { this.texture = new Texture(context, this.image, gl.RGBA); this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE); } 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 Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id})); } } async loadTile(tile: Tile): Promise { // 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 && 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, get their center as a coordinate. * * @returns centerpoint * @internal */ export function getCoordinatesCenterTileID(coords: Array) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const coord of coords) { minX = Math.min(minX, coord.x); minY = Math.min(minY, coord.y); maxX = Math.max(maxX, coord.x); maxY = Math.max(maxY, coord.y); } const dx = maxX - minX; const dy = maxY - minY; 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((minX + maxX) / 2 * tilesAtZoom), Math.floor((minY + maxY) / 2 * tilesAtZoom)); }