mapbox-gl
Version:
A WebGL interactive maps library
368 lines (315 loc) • 12.4 kB
JavaScript
// @flow
import Tile from './tile.js';
import window from '../util/window.js';
import TileBounds from './tile_bounds.js';
import RasterTileSource from './raster_tile_source.js';
import {extend, pick} from '../util/util.js';
import {Event, ErrorEvent, Evented} from '../util/evented.js';
import type Map from '../ui/map.js';
import type Dispatcher from '../util/dispatcher.js';
import type {Source} from './source.js';
import type {Callback} from '../types/callback.js';
import type {OverscaledTileID} from './tile_id.js';
type DataType = 'raster';
function isRaster(data: any): boolean {
return data instanceof window.ImageData ||
data instanceof window.HTMLCanvasElement ||
data instanceof window.ImageBitmap ||
data instanceof window.HTMLImageElement;
}
/**
* Interface for custom sources. This is a specification for
* implementers to model: it is not an exported method or class.
*
* Custom sources allow a user to load and modify their own tiles.
* These sources can be added between any regular sources using {@link Map#addSource}.
*
* Custom sources must have a unique `id` and must have the `type` of `"custom"`.
* They must implement `loadTile` and may implement `unloadTile`, `onAdd` and `onRemove`.
* They can trigger rendering using {@link Map#triggerRepaint}.
*
* @interface CustomSourceInterface
* @property {string} id A unique source id.
* @property {string} type The source's type. Must be `"custom"`.
* @example
* // Custom source implemented as ES6 class
* class CustomSource {
* constructor() {
* this.id = 'custom-source';
* this.type = 'custom';
* this.tileSize = 256;
* this.tilesUrl = 'https://stamen-tiles.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg';
* this.attribution = 'Map tiles by Stamen Design, under CC BY 3.0';
* }
*
* async loadTile(tile, {signal}) {
* const url = this.tilesUrl
* .replace('{z}', String(tile.z))
* .replace('{x}', String(tile.x))
* .replace('{y}', String(tile.y));
*
* const response = await fetch(url, {signal});
* const data = await response.arrayBuffer();
*
* const blob = new window.Blob([new Uint8Array(data)], {type: 'image/png'});
* const imageBitmap = await window.createImageBitmap(blob);
*
* return imageBitmap;
* }
* }
*
* map.on('load', () => {
* map.addSource('custom-source', new CustomSource());
* map.addLayer({
* id: 'layer',
* type: 'raster',
* source: 'custom-source'
* });
* });
*/
/**
* Optional method called when the source has been added to the Map with {@link Map#addSource}.
* This gives the source a chance to initialize resources and register event listeners.
*
* @function
* @memberof CustomSourceInterface
* @instance
* @name onAdd
* @param {Map} map The Map this custom source was just added to.
*/
/**
* Optional method called when the source has been removed from the Map with {@link Map#removeSource}.
* This gives the source a chance to clean up resources and event listeners.
*
* @function
* @memberof CustomSourceInterface
* @instance
* @name onRemove
* @param {Map} map The Map this custom source was added to.
*/
/**
* Optional method called after the tile is unloaded from the map viewport. This
* gives the source a chance to clean up resources and event listeners.
*
* @function
* @memberof CustomSourceInterface
* @instance
* @name unloadTile
* @param {{ z: number, x: number, y: number }} tile Tile name to unload in the XYZ scheme format.
*/
/**
* Optional method called during a render frame to check if there is a tile to render.
*
* @function
* @memberof CustomSourceInterface
* @instance
* @name hasTile
* @param {{ z: number, x: number, y: number }} tile Tile name to prepare in the XYZ scheme format.
* @returns {boolean} True if tile exists, otherwise false.
*/
/**
* Called when the map starts loading tile for the current animation frame.
*
* @function
* @memberof CustomSourceInterface
* @instance
* @name loadTile
* @param {{ z: number, x: number, y: number }} tile Tile name to load in the XYZ scheme format.
* @param {Object} options Options.
* @param {AbortSignal} options.signal A signal object that communicates when the map cancels the tile loading request.
* @returns {Promise<TextureImage | undefined | null>} The promise that resolves to the tile image data as an `HTMLCanvasElement`, `HTMLImageElement`, `ImageData`, `ImageBitmap` or object with `width`, `height`, and `data`.
* If `loadTile` resolves to `undefined`, a map will render an overscaled parent tile in the tile’s space. If `loadTile` resolves to `null`, a map will render nothing in the tile’s space.
*/
export type CustomSourceInterface<T> = {
id: string;
type: 'custom',
dataType: ?DataType,
minzoom: ?number,
maxzoom: ?number,
scheme: ?string;
tileSize: ?number,
attribution: ?string,
bounds: ?[number, number, number, number];
hasTile: ?(tileID: { z: number, x: number, y: number }) => boolean,
loadTile: (tileID: { z: number, x: number, y: number }, options: { signal: AbortSignal }) => Promise<?T>,
unloadTile: ?(tileID: { z: number, x: number, y: number }) => void,
onAdd: ?(map: Map) => void,
onRemove: ?(map: Map) => void,
}
class CustomSource<T> extends Evented implements Source {
id: string;
type: 'custom';
scheme: string;
minzoom: number;
maxzoom: number;
tileSize: number;
attribution: string | void;
roundZoom: boolean | void;
tileBounds: ?TileBounds;
minTileCacheSize: ?number;
maxTileCacheSize: ?number;
_map: Map;
_loaded: boolean;
_dispatcher: Dispatcher;
_dataType: ?DataType;
_implementation: CustomSourceInterface<T>;
constructor(id: string, implementation: CustomSourceInterface<T>, dispatcher: Dispatcher, eventedParent: Evented) {
super();
this.id = id;
this.type = 'custom';
this._dataType = 'raster';
this._dispatcher = dispatcher;
this._implementation = implementation;
this.setEventedParent(eventedParent);
this.scheme = 'xyz';
this.minzoom = 0;
this.maxzoom = 22;
this.tileSize = 512;
this._loaded = false;
this.roundZoom = true;
if (!this._implementation) {
this.fire(new ErrorEvent(new Error(`Missing implementation for ${this.id} custom source`)));
}
if (!this._implementation.loadTile) {
this.fire(new ErrorEvent(new Error(`Missing loadTile implementation for ${this.id} custom source`)));
}
if (this._implementation.bounds) {
this.tileBounds = new TileBounds(this._implementation.bounds, this.minzoom, this.maxzoom);
}
// $FlowFixMe[prop-missing]
// $FlowFixMe[method-unbinding]
implementation.update = this._update.bind(this);
// $FlowFixMe[prop-missing]
// $FlowFixMe[method-unbinding]
implementation.clearTiles = this._clearTiles.bind(this);
// $FlowFixMe[prop-missing]
// $FlowFixMe[method-unbinding]
implementation.coveringTiles = this._coveringTiles.bind(this);
extend(this, pick(implementation, ['dataType', 'scheme', 'minzoom', 'maxzoom', 'tileSize', 'attribution', 'minTileCacheSize', 'maxTileCacheSize']));
}
serialize(): Source {
return pick(this, ['type', 'scheme', 'minzoom', 'maxzoom', 'tileSize', 'attribution']);
}
load() {
this._loaded = true;
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
}
loaded(): boolean {
return this._loaded;
}
// $FlowFixMe[method-unbinding]
onAdd(map: Map): void {
this._map = map;
this._loaded = false;
this.fire(new Event('dataloading', {dataType: 'source'}));
if (this._implementation.onAdd) this._implementation.onAdd(map);
this.load();
}
// $FlowFixMe[method-unbinding]
onRemove(map: Map): void {
if (this._implementation.onRemove) {
this._implementation.onRemove(map);
}
}
// $FlowFixMe[method-unbinding]
hasTile(tileID: OverscaledTileID): boolean {
if (this._implementation.hasTile) {
const {x, y, z} = tileID.canonical;
return this._implementation.hasTile({x, y, z});
}
return !this.tileBounds || this.tileBounds.contains(tileID.canonical);
}
loadTile(tile: Tile, callback: Callback<void>): void {
const {x, y, z} = tile.tileID.canonical;
const controller = new window.AbortController();
const signal = controller.signal;
// $FlowFixMe[prop-missing]
tile.request = Promise
.resolve(this._implementation.loadTile({x, y, z}, {signal}))
.then(tileLoaded.bind(this))
.catch(error => {
// silence AbortError
if (error.code === 20) return;
tile.state = 'errored';
callback(error);
});
// $FlowFixMe[prop-missing]
tile.request.cancel = () => controller.abort();
// $FlowFixMe[missing-this-annot]
function tileLoaded(data: ?T) {
delete tile.request;
if (tile.aborted) {
tile.state = 'unloaded';
return callback(null);
}
// If the implementation returned `undefined` as tile data,
// mark the tile as `errored` to indicate that we have no data for it.
// A map will render an overscaled parent tile in the tile’s space.
if (data === undefined) {
tile.state = 'errored';
return callback(null);
}
// If the implementation returned `null` as tile data,
// mark the tile as `loaded` and use an an empty image as tile data.
// A map will render nothing in the tile’s space.
if (data === null) {
const emptyImage = {width: this.tileSize, height: this.tileSize, data: null};
this.loadTileData(tile, (emptyImage: any));
tile.state = 'loaded';
return callback(null);
}
if (!isRaster(data)) {
tile.state = 'errored';
return callback(new Error(`Can't infer data type for ${this.id}, only raster data supported at the moment`));
}
this.loadTileData(tile, data);
tile.state = 'loaded';
callback(null);
}
}
loadTileData(tile: Tile, data: T): void {
// Only raster data supported at the moment
RasterTileSource.loadTileData(tile, (data: any), this._map.painter);
}
unloadTileData(tile: Tile): void {
// Only raster data supported at the moment
RasterTileSource.unloadTileData(tile, this._map.painter);
}
// $FlowFixMe[method-unbinding]
unloadTile(tile: Tile, callback: Callback<void>): void {
this.unloadTileData(tile);
if (this._implementation.unloadTile) {
const {x, y, z} = tile.tileID.canonical;
this._implementation.unloadTile({x, y, z});
}
callback();
}
// $FlowFixMe[method-unbinding]
abortTile(tile: Tile, callback: Callback<void>): void {
if (tile.request && tile.request.cancel) {
tile.request.cancel();
delete tile.request;
}
callback();
}
hasTransition(): boolean {
return false;
}
_coveringTiles(): { z: number, x: number, y: number }[] {
const tileIDs = this._map.transform.coveringTiles({
tileSize: this.tileSize,
minzoom: this.minzoom,
maxzoom: this.maxzoom,
roundZoom: this.roundZoom
});
return tileIDs.map(tileID => ({x: tileID.canonical.x, y: tileID.canonical.y, z: tileID.canonical.z}));
}
_clearTiles() {
this._map.style._clearSource(this.id);
}
_update() {
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
}
}
export default CustomSource;