mapbox-gl
Version:
A WebGL interactive maps library
421 lines (374 loc) • 14.7 kB
JavaScript
// @flow
import {CanonicalTileID} from './tile_id.js';
import {Event, ErrorEvent, Evented} from '../util/evented.js';
import {getImage, ResourceType} from '../util/ajax.js';
import EXTENT from '../data/extent.js';
import {RasterBoundsArray} from '../data/array_types.js';
import boundsAttributes from '../data/bounds_attributes.js';
import SegmentVector from '../data/segment.js';
import Texture from '../render/texture.js';
import MercatorCoordinate from '../geo/mercator_coordinate.js';
import browser from '../util/browser.js';
import tileTransform, {getTilePoint} from '../geo/projection/tile_transform.js';
import {mat3, vec3} from 'gl-matrix';
import window from '../util/window.js';
import type {Source} from './source.js';
import type {CanvasSourceSpecification} from './canvas_source.js';
import type Map from '../ui/map.js';
import type Dispatcher from '../util/dispatcher.js';
import type Tile from './tile.js';
import type {Callback} from '../types/callback.js';
import type {Cancelable} from '../types/cancelable.js';
import type VertexBuffer from '../gl/vertex_buffer.js';
import type {
ImageSourceSpecification,
VideoSourceSpecification
} from '../style-spec/types.js';
import type Context from '../gl/context.js';
type Coordinates = [[number, number], [number, number], [number, number], [number, number]];
// perspective correction for texture mapping, see https://github.com/mapbox/mapbox-gl-js/issues/9158
// adapted from https://math.stackexchange.com/a/339033/48653
function basisToPoints(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number) {
const m = [x1, x2, x3, y1, y2, y3, 1, 1, 1];
const s = [x4, y4, 1];
const ma = mat3.adjoint([], m);
const [sx, sy, sz] = vec3.transformMat3(s, s, mat3.transpose(ma, ma));
return mat3.multiply(m, [sx, 0, 0, 0, sy, 0, 0, 0, sz], m);
}
function getPerspectiveTransform(w: number, h: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number) {
const s = basisToPoints(0, 0, w, 0, 0, h, w, h);
const m = basisToPoints(x1, y1, x2, y2, x3, y3, x4, y4);
mat3.multiply(m, mat3.adjoint(s, s), m);
return [
m[6] / m[8] * w / EXTENT,
m[7] / m[8] * h / EXTENT
];
}
/**
* A data source containing an image.
* See the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/#sources-image) for detailed documentation of options.
*
* @example
* // add to map
* map.addSource('some id', {
* type: 'image',
* url: 'https://www.mapbox.com/images/foo.png',
* coordinates: [
* [-76.54, 39.18],
* [-76.52, 39.18],
* [-76.52, 39.17],
* [-76.54, 39.17]
* ]
* });
*
* // update coordinates
* const 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.mapbox.com/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
* @see [Example: Add an image](https://www.mapbox.com/mapbox-gl-js/example/image-on-a-map/)
* @see [Example: Animate a series of images](https://www.mapbox.com/mapbox-gl-js/example/animate-images/)
*/
class ImageSource extends Evented implements Source {
type: string;
id: string;
minzoom: number;
maxzoom: number;
tileSize: number;
url: string;
width: number;
height: number;
coordinates: Coordinates;
tiles: {[_: string]: Tile};
options: any;
dispatcher: Dispatcher;
map: Map;
texture: Texture | null;
image: HTMLImageElement | ImageBitmap | ImageData;
// $FlowFixMe
tileID: CanonicalTileID;
_boundsArray: ?RasterBoundsArray;
boundsBuffer: ?VertexBuffer;
boundsSegments: ?SegmentVector;
_loaded: boolean;
_dirty: boolean;
_imageRequest: ?Cancelable;
perspectiveTransform: [number, number];
/**
* @private
*/
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;
this._dirty = false;
}
load(newCoordinates?: Coordinates, loaded?: boolean) {
this._loaded = loaded || false;
this.fire(new Event('dataloading', {dataType: 'source'}));
this.url = this.options.url;
this._imageRequest = getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), (err, image) => {
this._imageRequest = null;
this._loaded = true;
if (err) {
this.fire(new ErrorEvent(err));
} else if (image) {
const {HTMLImageElement} = window;
if (image instanceof HTMLImageElement) {
this.image = browser.getImageData(image);
} else {
this.image = image;
}
this._dirty = true;
this.width = this.image.width;
this.height = this.image.height;
if (newCoordinates) {
this.coordinates = newCoordinates;
}
this._finishLoading();
}
});
}
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 {Object} options Options object.
* @param {string} [options.url] Required image URL.
* @param {Array<Array<number>>} [options.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.
* @returns {ImageSource} Returns itself to allow for method chaining.
* @example
* // Add to an image source to the map with some initial URL and coordinates
* map.addSource('image_source_id', {
* type: 'image',
* url: 'https://www.mapbox.com/images/foo.png',
* coordinates: [
* [-76.54, 39.18],
* [-76.52, 39.18],
* [-76.52, 39.17],
* [-76.54, 39.17]
* ]
* });
* // Then update the image URL and coordinates
* imageSource.updateImage({
* url: 'https://www.mapbox.com/images/bar.png',
* coordinates: [
* [-76.5433, 39.1857],
* [-76.5280, 39.1838],
* [-76.5295, 39.1768],
* [-76.5452, 39.1787]
* ]
* });
*/
updateImage(options: {url: string, coordinates?: Coordinates}): this {
if (!this.image || !options.url) {
return this;
}
if (this._imageRequest && options.url !== this.options.url) {
this._imageRequest.cancel();
this._imageRequest = null;
}
this.options.url = options.url;
this.load(options.coordinates, this._loaded);
return this;
}
_finishLoading() {
if (this.map) {
this.setCoordinates(this.coordinates);
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
}
}
// $FlowFixMe[method-unbinding]
onAdd(map: Map) {
this.map = map;
this.load();
}
// $FlowFixMe[method-unbinding]
onRemove() {
if (this._imageRequest) {
this._imageRequest.cancel();
this._imageRequest = null;
}
if (this.texture) this.texture.destroy();
}
/**
* Sets the image's coordinates and re-renders the map.
*
* @param {Array<Array<number>>} 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.
* @returns {ImageSource} Returns itself to allow for method chaining.
* @example
* // Add an image source to the map with some initial coordinates
* map.addSource('image_source_id', {
* type: 'image',
* url: 'https://www.mapbox.com/images/foo.png',
* coordinates: [
* [-76.54, 39.18],
* [-76.52, 39.18],
* [-76.52, 39.17],
* [-76.54, 39.17]
* ]
* });
* // Then update the image coordinates
* imageSource.setCoordinates([
* [-76.5433, 39.1857],
* [-76.5280, 39.1838],
* [-76.5295, 39.1768],
* [-76.5452, 39.1787]
* ]);
*/
setCoordinates(coordinates: Coordinates): this {
this.coordinates = coordinates;
this._boundsArray = undefined;
// 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
// $FlowFixMe[method-unbinding]
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;
this.fire(new Event('data', {dataType:'source', sourceDataType: 'content'}));
return this;
}
// $FlowFixMe[method-unbinding]
_clear() {
this._boundsArray = undefined;
}
_prepareData(context: Context) {
for (const w in this.tiles) {
const tile = this.tiles[w];
if (tile.state !== 'loaded') {
tile.state = 'loaded';
tile.texture = this.texture;
}
}
if (this._boundsArray) return;
const tileTr = tileTransform(this.tileID, this.map.transform.projection);
// Transform the corner coordinates into the coordinate space of our tile.
const [tl, tr, br, bl] = this.coordinates.map((coord) => {
const projectedCoord = tileTr.projection.project(coord[0], coord[1]);
return getTilePoint(tileTr, projectedCoord)._round();
});
this.perspectiveTransform = getPerspectiveTransform(
this.width, this.height, tl.x, tl.y, tr.x, tr.y, bl.x, bl.y, br.x, br.y);
const boundsArray = this._boundsArray = new RasterBoundsArray();
boundsArray.emplaceBack(tl.x, tl.y, 0, 0);
boundsArray.emplaceBack(tr.x, tr.y, EXTENT, 0);
boundsArray.emplaceBack(bl.x, bl.y, 0, EXTENT);
boundsArray.emplaceBack(br.x, br.y, EXTENT, EXTENT);
if (this.boundsBuffer) {
this.boundsBuffer.destroy();
}
this.boundsBuffer = context.createVertexBuffer(boundsArray, boundsAttributes.members);
this.boundsSegments = SegmentVector.simpleSegment(0, 0, 4, 2);
}
// $FlowFixMe[method-unbinding]
prepare() {
if (Object.keys(this.tiles).length === 0 || !this.image) return;
const context = this.map.painter.context;
const gl = context.gl;
if (this._dirty) {
if (!this.texture) {
this.texture = new Texture(context, this.image, gl.RGBA);
this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
} else {
this.texture.update(this.image);
}
this._dirty = false;
}
this._prepareData(context);
}
loadTile(tile: Tile, callback: Callback<void>) {
// We have a single tile -- whoose 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 = {};
callback(null);
} else {
tile.state = 'errored';
callback(null);
}
}
serialize(): Object {
return {
type: 'image',
url: this.options.url,
coordinates: this.coordinates
};
}
hasTransition(): boolean {
return false;
}
}
/**
* Given a list of coordinates, get their center as a coordinate.
*
* @returns centerpoint
* @private
*/
export function getCoordinatesCenterTileID(coords: Array<MercatorCoordinate>): CanonicalTileID {
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));
}
export default ImageSource;