leaflet.tilelayer.glcolorscale
Version:
Custom Leaflet TileLayer using WebGL to colorize floating-point pixels according to a specified color scale
216 lines (199 loc) • 6.83 kB
text/typescript
import { memoize } from 'lodash-es';
import REGL from 'regl';
import { decode } from 'upng-js';
import {
Color,
Dictionary,
TextureBounds,
TileCoordinates,
WebGLColorStop,
} from './types';
/**
* Converts a color scale (or array of SentinelValues) to a format usable by WebGL.
*/
export function convertColorScale(colorScale: Color[]): WebGLColorStop[] {
return colorScale.map(({ color, offset }) => ({
color: colorStringToWebGLFloats(color),
offset,
}));
}
const RGB_REGEX = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
/**
* Parses a color string of the form 'rgb({rVal}, {gVal}, {bVal})' and converts the resulting values
* to a Vec4 consumable by WebGL. Each color value is normalized to the range 0.0 to 1.0.
*/
export function colorStringToWebGLFloats(rgb: string): REGL.Vec4 {
if (rgb === 'transparent') {
return [1, 1, 1, 0];
}
const match = rgb.match(RGB_REGEX);
if (match === null) {
throw new Error(`'${rgb}' is not a valid RGB color expression.`);
}
const [, r, g, b] = match;
return [+r / 255, +g / 255, +b / 255, 1];
}
/**
* Create an object representing the elements and properties of a WebGL struct array to be passed
* to Regl. Each property is a "dynamic prop" in Regl parlance. That is, it's a function that is
* evaluated when the drawing function is called. When each dynamic prop (function) is evaluated,
* it's passed as second argument a `props` object (similar to `props` in a React component).
*/
export function bindStructArray<
Struct extends Dictionary<any>,
Props extends Dictionary<any[]> = {}
>(
structPropertyNames: Array<keyof Struct>,
defaultValue: Struct,
maxArrayLength: number,
glslIdentifier: string,
propName: keyof Props = (glslIdentifier as keyof Props),
) {
const output = {} as Dictionary<any>;
for (let i = 0; i < maxArrayLength; ++i) {
for (const key of structPropertyNames) {
output[`${glslIdentifier}[${i}].${key}`] = (_: any, props: Props) => {
const inputArray = props[propName];
return (
i < inputArray.length
? inputArray[i][key]
: defaultValue[key]
);
};
}
}
return output;
}
export function machineIsLittleEndian() {
const uint8Array = new Uint8Array([0xAA, 0xBB]);
const uint16array = new Uint16Array(uint8Array.buffer);
return uint16array[0] === 0xBBAA;
}
/**
* Cribbed from Python's built-in `range` function.
*/
export function range(...args: number[]) {
if (args.length === 1) {
const [until] = args;
return new Array(until).fill(undefined).map((_, i) => i);
} else {
const [from, until, step = 1] = args;
if (step === 0) {
throw new Error('Argument step must be nonzero.');
}
const output = [];
for (let val = from; (step > 0) ? val < until : val > until; val += step) {
output.push(val);
}
return output;
}
}
export async function fetchPNGData(url: string, nodataValue: number, tileDimension: number): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "arraybuffer";
xhr.addEventListener('load', () => {
resolve(xhr.response);
});
xhr.addEventListener('error', reject);
xhr.send(null);
}).then((data: ArrayBuffer) => {
return new Uint8Array(decode(data).data);
}).catch(() => createNoDataTile(nodataValue, tileDimension));
}
/**
* The matrix output by this function transforms coordinates in pixel space within the drawing
* buffer (with upper left corner (0, 0) and lower right corner (buffer width, buffer height))
* to WebGL "clipspace", with upper left corner (-1, 1) and lower right corner (1, -1).
*/
export function getTransformMatrix(
drawingBufferWidth: number,
drawingBufferHeight: number,
): REGL.Mat4 {
// To scale horizontally, divide by width (in pixels) and multiply by 2, because width is 2 in clipspace.
const sx = 2 / drawingBufferWidth;
// To scale vertically, divide by height (in pixels) and multiply by -2, because height is 2 in clipspace,
// and the direction is flipped (positive is up, negative is down).
const sy = -2 / drawingBufferHeight;
// We translate by -1 horizontally (so the range 0 to 2 maps to the range -1 to 1).
const tx = -1;
// We translate by 1 horizontally (so the range -2 to 0 maps to the range -1 to 1).
const ty = 1;
// Matrix must be in column-major order for WebGL.
return [
sx, 0, 0, 0,
0, sy, 0, 0,
0, 0, 1, 0,
tx, ty, 0, 1,
];
}
/**
* From a TextureBounds object, this function generates the four vertices WebGL needs to draw the
* corresponding rectangle (as two conjoined triangles generated with the triangle strip primitive).
*/
export function getTexCoordVertices(textureBounds: TextureBounds): REGL.Vec2[] {
const [{ x: left, y: top }, { x: right, y: bottom }] = textureBounds;
return [
[left, top ],
[right, top ],
[left, bottom],
[right, bottom],
];
}
/**
* Produces a Promise that resolves when the desired `duration` has expired.
*/
export function Timer(duration: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, duration));
}
/**
* Useful for sorting TileCoorindates objects.
*/
export function compareTileCoordinates(a: TileCoordinates, b: TileCoordinates): number {
const z = a.z - b.z;
const x = a.x - b.x;
const y = a.y - b.y;
if (z !== 0) {
// First compare z values.
return z;
} else if (x !== 0) {
// If z values are the same, compare x values.
return x;
} else {
// If x values are the same, compare y values.
return y;
}
}
/**
* Determines whether two arrays of TileCoordinates are the same.
*/
export function sameTiles(a: TileCoordinates[], b: TileCoordinates[]): boolean {
return (
// arrays are of the same length
a.length === b.length
// and corresponding elements have the same tile coordinates
&& a.every((tileA, index) => compareTileCoordinates(tileA, b[index]) === 0)
);
}
const createNoDataTile = memoize((nodataValue: number, tileDimension: number = 256): Uint8Array => {
// Create a float 32 array.
const float32Tile = new Float32Array(tileDimension * tileDimension);
// Fill the tile array with the no data value
float32Tile.fill(nodataValue);
// return the no data tile.
return new Uint8Array(float32Tile.buffer);
});
/**
* Force TypeScript to interpret value `val` as type `T`.
*/
export function staticCast<T>(val: any): T {
return val as T;
}
/**
* Add one or more macro definitions to a GLSL source string.
*/
export function defineMacros(src: string, macros: { [key: string]: any }): string {
const defs = Object.keys(macros).map((key) => `#define ${key} ${macros[key]}\n`).join('');
return `${defs}\n${src}`;
}