leaflet.tilelayer.glcolorscale
Version:
Custom Leaflet TileLayer using WebGL to colorize floating-point pixels according to a specified color scale
646 lines (579 loc) • 20.6 kB
text/typescript
import * as L from 'leaflet';
import {
isUndefined,
mapValues,
noop,
pickBy,
values,
zipWith,
} from 'lodash-es';
import './index.css';
import {
SCALE_MAX_LENGTH,
SENTINEL_MAX_LENGTH,
} from './constants';
import Renderer from './Renderer';
import {
GridLayerTile,
Pair,
PreloadTileCache,
TileCache,
TileCoordinates,
TileDatum,
TileElement,
TileEvent,
} from './types';
import * as util from './util';
import {
Color,
SentinelValue,
} from './types';
/**
* Augment Leaflet GridLayer definition to include some helpful "private" properties.
*
* Unfortunately, using properties that aren't part of GridLayer's public API makes this component
* brittle. If GridLayer's implementation changes significantly, it could break this component.
* I don't see a way around this limitation, however, without reimplementing much of GridLayer.
*/
declare module 'leaflet' {
interface GridLayer {
_globalTileRange: L.Bounds;
_pruneTiles(): void;
}
}
const BYTES_PER_WORD = 4; // four bytes in a 32-bit float
const littleEndian = util.machineIsLittleEndian();
export interface MouseEvent extends L.LeafletMouseEvent {
pixelValue?: number | SentinelValue;
}
interface EventsObject {
[name: string]: (event: L.LeafletEvent) => void;
}
export interface Options extends L.GridLayerOptions {
url: string;
nodataValue: number;
colorScale?: Color[];
sentinelValues?: SentinelValue[];
preloadUrl?: string;
transitions?: boolean;
transitionTimeMs?: number;
// handler for the 'load' event, fired when all tiles loaded.
onload?: (event: { url: string }) => void;
// mouse event handlers
onclick?: (event: MouseEvent) => void;
ondblclick?: (event: MouseEvent) => void;
onmousedown?: (event: MouseEvent) => void;
onmouseup?: (event: MouseEvent) => void;
onmouseover?: (event: MouseEvent) => void;
onmouseout?: (event: MouseEvent) => void;
onmousemove?: (event: MouseEvent) => void;
oncontextmenu?: (event: MouseEvent) => void;
// from TileLayerOptions
minZoom?: number;
maxZoom?: number;
subdomains?: string | string[];
maxNativeZoom?: number;
minNativeZoom?: number;
errorTileUrl?: string;
zoomOffset?: number;
tms?: boolean;
zoomReverse?: boolean;
detectRetina?: boolean;
crossOrigin?: boolean;
}
const defaultOptions = {
colorScale: [],
sentinelValues: [],
transitions: true,
transitionTimeMs: 800,
// default TileLayer options
minZoom: 0,
maxZoom: 18,
subdomains: 'abc',
errorTileUrl: '',
zoomOffset: 0,
tms: false,
zoomReverse: false,
detectRetina: false,
crossOrigin: false,
};
/**
* The options type used internally. Because submitted options are merged with defaults, fewer
* properties have the potential to be `undefined` compared with `Options`.
*/
export type InternalOptions = Options & typeof defaultOptions;
export default class GLColorScale extends L.GridLayer {
static readonly SCALE_MAX_LENGTH: number = SCALE_MAX_LENGTH;
static readonly SENTINEL_MAX_LENGTH: number = SENTINEL_MAX_LENGTH;
static readonly defaultOptions = defaultOptions;
options: InternalOptions;
protected _map: L.Map;
protected _renderer: Renderer;
protected _preloadTileCache?: PreloadTileCache;
// We need to define the `_tiles` cache with the same type as in the base class,
// though our `_tiles` property is actually of type `TileCache`
protected _tiles: L.InternalTiles;
constructor(options: Options) {
// Merge options with defaults and invoke GridLayer's constructor,
// which sets the merged options as `this.options`.
super(Object.assign({}, defaultOptions, options));
this._checkColorScaleAndSentinels();
const {
nodataValue,
preloadUrl,
} = this.options;
const tileSize: number = this._tileSizeAsNumber();
const renderer = new Renderer(tileSize, nodataValue);
// Set instance properties.
Object.assign(this, {
_renderer: renderer,
_preloadTileCache: undefined,
});
this._maybePreload(preloadUrl);
// Listen for 'tileunload' event to remove the tile from the texture.
this.on('tileunload', this._onTileRemove.bind(this));
}
/**
* The GLTileLayerComponent exposes a declarative interface. Changes should be triggered by
* calling this method to update the options. Figuring out how to reconcile the layer's current
* state with the updated options is the responsibility of the component. Unlike many other
* Leaflet components, no other public methods are provided for imperatively changing the
* component's state.
*/
updateOptions(options: Partial<Options>) {
const {
url: prevUrl,
colorScale: prevColorScale,
sentinelValues: prevSentinelValues,
} = this.options;
L.Util.setOptions(this, options);
this._checkColorScaleAndSentinels();
this._maybePreload(this.options.preloadUrl);
if (this.options.url !== prevUrl) {
this.options.transitions
? this._updateTilesWithTransitions(prevColorScale, prevSentinelValues)
: this._updateTiles();
}
}
/**
* We need to register all mouse event handlers on the Leaflet Map component. `Leaflet.Layer`
* does this automatically for any handlers returned from the optional method `getEvents`.
*
* We enhance the `MouseEvent` object Leaflet provides to these handlers with an additional
* property containing the value of the pixel under the cursor.
*/
getEvents() {
const {
onclick: click,
ondblclick: dblclick,
onmousedown: mousedown,
onmouseup: mouseup,
onmouseover: mouseover,
onmouseout: mouseout,
onmousemove: mousemove,
oncontextmenu: contextmenu,
} = this.options;
// Only include handlers that aren't undefined.
const definedHandlers = pickBy({
click,
dblclick,
mousedown,
mouseup,
mouseover,
mouseout,
mousemove,
contextmenu,
}, handler => !isUndefined(handler));
// Combine events defined on this class with events defined on the parent GridLayer.
return {
// Include events from GridLayer.
...(L.GridLayer.prototype.getEvents as () => EventsObject).call(this),
// Wrap each handler to provide property `pixelValue` on the event object.
...mapValues(definedHandlers, val => val && this._wrapMouseEventHandler(val)),
};
}
/**
* adapted from L.TileLayer (v1.2.0):
* modified to accept a `url` parameter to allow loading from a URL other than `this.options.url`
* (needed for preloading)
*/
getTileUrl(coords: TileCoordinates, url: string) {
const data: any = {
r: L.Browser.retina ? '@2x' : '',
s: this._getSubdomain(coords),
x: coords.x,
y: coords.y,
z: this._getZoomForUrl(),
};
if (this._map && !((this._map.options as L.MapOptions).crs as L.CRS).infinite) {
const invertedY = (this._globalTileRange.max as L.Point).y - coords.y;
if (this.options.tms) {
data.y = invertedY;
}
data['-y'] = invertedY;
}
return L.Util.template(url, L.Util.extend(data, this.options));
}
/**
* This function is called by the underlying Leaflet.GridLayer when it creates a new tile. This
* occurs (a) when the layer first loads and (b) when panning or zooming the map.
*/
createTile(coords: TileCoordinates, done: L.DoneCallback): TileElement {
const {
colorScale,
sentinelValues,
tileSize,
url,
} = this.options;
// Create a <canvas> element to contain the rendered image.
const tileCanvas = L.DomUtil.create('canvas') as TileElement;
// Configure the element.
Object.assign(tileCanvas, {
className: 'gl-tilelayer-tile',
width: tileSize,
height: tileSize,
});
// Retrieve and decode Float-32 PNG.
this._fetchTileData(coords, url).then((pixelData) => {
// Render in `renderer`'s WebGL context.
const [sourceX, sourceY] = this._renderer.renderTile(
{ coords, pixelData },
colorScale,
sentinelValues,
);
// Copy pixel data to a property on tile canvas element (for later retrieval).
tileCanvas.pixelData = pixelData;
// Copy contents to tileCanvas.
this._copyToTileCanvas(tileCanvas, sourceX, sourceY);
done(undefined, tileCanvas);
});
return tileCanvas;
}
/**
* Check invariant: Either `colorScale` or `sentinelValues` must be of non-zero length.
*/
protected _checkColorScaleAndSentinels() {
const {
colorScale,
sentinelValues,
} = this.options;
if (colorScale.length === 0 && sentinelValues.length === 0) {
throw new Error('Either `colorScale` or `sentinelValues` must be of non-zero length.');
}
if (colorScale.length > SCALE_MAX_LENGTH) {
throw new Error(
`Color scale length ${colorScale.length} exceeds the maximum, ${SCALE_MAX_LENGTH}.`,
);
}
if (sentinelValues.length > SENTINEL_MAX_LENGTH) {
throw new Error(
`Sentinel values length ${sentinelValues.length} exceeds the maximum, ${SENTINEL_MAX_LENGTH}.`,
);
}
}
/**
* adapted from eponymous private method in L.TileLayer (v1.2.0)
*/
protected _getSubdomain(tilePoint: TileCoordinates): string {
const index = Math.abs(tilePoint.x + tilePoint.y) % this.options.subdomains.length;
return this.options.subdomains[index];
}
/**
* adapted from eponymous private method in L.TileLayer (v1.2.0)
*/
protected _getZoomForUrl(): number {
const {
maxZoom,
zoomReverse,
zoomOffset,
} = this.options;
const tileZoom = this._tileZoom as number;
const zoom = zoomReverse ? maxZoom - tileZoom : tileZoom;
return zoom + zoomOffset;
}
/**
* Handler function for Leaflet.GridLayer's 'tileunload' event.
*/
protected _onTileRemove({ coords, tile }: TileEvent) {
// for https://github.com/Leaflet/Leaflet/issues/137
if (!L.Browser.android) {
tile.onload = noop;
}
this._renderer.removeTile(coords);
}
/**
* Redraw all active tiles.
*/
protected async _updateTiles() {
const activeTiles: GridLayerTile[] = this._getActiveTiles();
// Fetch data from the new URL.
const tilesData: TileDatum[] = await this._getTilesData(activeTiles);
const { colorScale, sentinelValues = [] } = this.options;
// Render using the new data.
const canvasCoordinates = this._renderer.renderTiles(
tilesData,
colorScale,
sentinelValues,
);
// Update tiles.
canvasCoordinates.forEach(([sourceX, sourceY], index) => {
// Copy rendered pixels to the tile canvas.
const tile = activeTiles[index];
this._copyToTileCanvas(tile.el, sourceX, sourceY);
// Copy new pixel data.
tile.el.pixelData = tilesData[index].pixelData;
});
}
/**
* Redraw all active tiles, animating the transition over a time interval specified in
* `options.transitionTimeMs`.
*/
protected async _updateTilesWithTransitions(
prevColorScale: Color[],
prevSentinelValues: SentinelValue[],
) {
const activeTiles: GridLayerTile[] = this._getActiveTiles();
const prevTilesData: TileDatum[] = activeTiles.map(({ coords, el }) => ({
coords,
pixelData: el.pixelData as Uint8Array,
}));
// Fetch data from the new URL.
const newTilesData: TileDatum[] = await this._getTilesData(activeTiles);
// Copy new pixel data to tiles.
activeTiles.forEach((tile, index) => {
tile.el.pixelData = newTilesData[index].pixelData;
});
const {
colorScale: newColorScale,
sentinelValues: newSentinelValues = [],
transitionTimeMs,
} = this.options;
// This function will be passed to the Renderer, which will call it after rendering a frame
// in its offscreen <canvas>.
const onFrameRendered = (canvasCoordinates: Array<Pair<number>>) => {
canvasCoordinates.forEach(([sourceX, sourceY], index) => {
// Copy rendered pixels to the tile <canvas>.
const tile = activeTiles[index];
this._copyToTileCanvas(tile.el, sourceX, sourceY);
});
};
// Renderer hooks the render calls to requestAnimationFrame, calling `onFrameRendered` after each is drawn.
if (newColorScale === prevColorScale) {
this._renderer.renderTilesWithTransition(
prevTilesData,
newTilesData,
newColorScale,
newSentinelValues,
transitionTimeMs,
onFrameRendered,
);
} else {
this._renderer.renderTilesWithTransitionAndNewColorScale(
prevTilesData,
newTilesData,
prevColorScale,
newColorScale,
prevSentinelValues,
newSentinelValues,
transitionTimeMs,
onFrameRendered,
);
}
}
/**
* Preload tiles if it makes sense to do so.
*/
protected _maybePreload(preloadUrl?: string) {
if (
// Preload tiles if a preloadUrl is given and...
preloadUrl && (
// either the preload cache is empty
!this._preloadTileCache
// or its url is out of date.
|| this._preloadTileCache.url !== preloadUrl
)
) {
this._preloadTiles(preloadUrl);
}
}
/**
* Load tiles from the given URL and store them in the preload cache.
*/
protected async _preloadTiles(url: string) {
const activeTiles: GridLayerTile[] = this._getActiveTiles();
const tilesData: TileDatum[] = await this._fetchTilesData(activeTiles, url);
this._preloadTileCache = {
url,
tiles: tilesData,
};
}
/**
* Use Leaflet.GridLayer's _pruneTiles method to clear out any stale tiles, then return the
* remaining (active) tiles, sorted by z, x, y.
*/
protected _getActiveTiles(): GridLayerTile[] {
// Remove inactive tiles from the cache.
this._pruneTiles();
// Any tiles remaining are active tiles.
// We sort them by their tile coordinates (by z, then x, then y) to ensure consistent ordering.
const tiles = util.staticCast<TileCache>(this._tiles);
return values(tiles).sort((a, b) => util.compareTileCoordinates(a.coords, b.coords));
}
/**
* Retrieve pixel data for the given tiles, either from the preload cache or from the server.
*/
protected async _getTilesData(tiles: GridLayerTile[]): Promise<TileDatum[]> {
const preloadTileCache: PreloadTileCache | undefined = this._preloadTileCache;
if (
preloadTileCache
&& this.options.url === preloadTileCache.url
&& util.sameTiles(
preloadTileCache.tiles.map(({ coords }) => coords),
tiles.map(({ coords }) => coords),
)
) {
// Clear the preload cache and return its contents.
this._preloadTileCache = undefined;
return Promise.resolve(preloadTileCache.tiles);
} else {
return this._fetchTilesData(tiles, this.options.url);
}
}
/**
* Fetch pixel data for the supplied tiles from the supplied URL.
*/
protected async _fetchTilesData(tiles: GridLayerTile[], url: string): Promise<TileDatum[]> {
const pixelData = await Promise.all(tiles.map(({ coords }) => this._fetchTileData(coords, url)));
// Fire the 'load' event to notify any listeners that the tiles have finished loading.
this.fire('load', { url });
return zipWith<GridLayerTile | Uint8Array, TileDatum>(
tiles,
pixelData,
({ coords }: GridLayerTile, data: Uint8Array) => ({
coords,
pixelData: data,
}),
);
}
/**
* Fetch pixel data for an individual tile from the given URL.
*/
protected _fetchTileData(coords: TileCoordinates, url: string): Promise<Uint8Array> {
return util.fetchPNGData(this.getTileUrl(coords, url), this.options.nodataValue, this._tileSizeAsNumber());
}
/**
* L.GridLayer's `tileSize` option can be either a number or a Point object.
* For this tile layer, we assume tiles will have equal width and height, so to simplify things
* we normalize `tileSize` as a number.
*/
protected _tileSizeAsNumber(): number {
const { tileSize } = this.options;
return (
typeof tileSize === 'number'
? tileSize
: (tileSize as L.Point).x
);
}
/**
* Copy pixels from the Renderer's (offscreen) <canvas> to a tile's (onscreen) canvas.
*/
protected _copyToTileCanvas(tile: TileElement, sourceX: number, sourceY: number) {
const tileSize = this._tileSizeAsNumber();
const tileCanvas2DContext = tile.getContext('2d');
if (tileCanvas2DContext === null) {
throw new Error('Tile canvas 2D context is null.');
}
// Clear the current contents of the canvas. Otherwise, the new image will be composited with
// the existing image.
tileCanvas2DContext.clearRect(0, 0, tileSize, tileSize);
// Copy the image data from the Renderer's canvas to the tile's canvas.
tileCanvas2DContext.drawImage(
this._renderer.canvas,
sourceX, sourceY, tileSize, tileSize, // source canvas offset (x, y) and size (x, y)
0, 0, tileSize, tileSize, // destination canvas offset (x, y) and size (x, y)
);
}
/**
* Wraps a handler for a Leaflet MouseEvent, providing an extra property, `pixelValue`, to the
* event object.
*/
protected _wrapMouseEventHandler(handler: (event: MouseEvent) => void): (event: L.LeafletMouseEvent) => void {
return (event) => {
const { latlng } = event;
const pixelCoords: L.Point = this._map.project(latlng, this._tileZoom as number).floor();
// Find the tile containing the point.
const containingTile: GridLayerTile | undefined = this._getTileContainingPoint(pixelCoords);
// Find position within tile.
const coordsInTile: L.Point | undefined = containingTile && this._getCoordsInTile(containingTile, pixelCoords);
// Get pixel value.
const pixelValue = coordsInTile && this._getPixelValue(containingTile as GridLayerTile, coordsInTile);
// Call handler with pixel value.
handler({ ...event, pixelValue });
};
}
/**
* Get the tile containing the given point (in pixel coordinates) or `undefined` if no tile
* contains the point.
*/
protected _getTileContainingPoint(point: L.Point): GridLayerTile | undefined {
const tiles = util.staticCast<TileCache>(this._tiles);
return values(tiles).find(tile => {
return tile.coords.z === this._tileZoom && this._tileBounds(tile).contains(point);
});
}
/**
* Compute the bounds (in projected pixel coordinates) of the given tile.
*/
protected _tileBounds(tile: GridLayerTile) {
const { x, y } = tile.coords;
const tileSize = this._tileSizeAsNumber();
const topLeft = L.point(x * tileSize, y * tileSize);
const bottomRight = L.point(
topLeft.x + (tileSize - 1),
topLeft.y + (tileSize - 1),
);
return L.bounds(topLeft, bottomRight);
}
/**
* Convert absolute pixel coordinates to pixel coordinates relative to a given tile's upper left
* corner.
*/
protected _getCoordsInTile(tile: GridLayerTile, pixelCoords: L.Point): L.Point {
const { x: tileX, y: tileY } = tile.coords;
const tileSize = this._tileSizeAsNumber();
return L.point(
pixelCoords.x - (tileX * tileSize),
pixelCoords.y - (tileY * tileSize),
);
}
/**
* Get the floating-point value of the pixel at the given coordinates in the given tile.
* Returns `undefined` if the value is equal to `nodataValue`.
* If the value matches a sentinel value, returns the corresponding `SentinelValue` object.
*/
protected _getPixelValue(tile: GridLayerTile, coordsInTile: L.Point): number | SentinelValue | undefined {
const { pixelData } = tile.el;
if (!pixelData) {
return undefined;
}
const {
nodataValue,
sentinelValues,
} = this.options;
const tileDataView = new DataView(pixelData.buffer);
// To find the byte index:
// (1) get the index of the start of the row in which the pixel is located
// (2) add to that the column index
// (3) multiply by the number of bytes used for each pixel
const byteIndex = (coordsInTile.y * this._tileSizeAsNumber() + coordsInTile.x) * BYTES_PER_WORD;
const pixelValue = tileDataView.getFloat32(byteIndex, littleEndian);
// Check for nodata value.
if (pixelValue === nodataValue) {
return undefined;
}
// Check for sentinel value.
const sentinel = sentinelValues && sentinelValues.find(({ offset }) => offset === pixelValue);
// If pixelValue matches no sentinel, just return pixelValue.
return sentinel || pixelValue;
}
}