leaflet-canvaslayer-field
Version:
A set of layers using canvas to draw ASCIIGrid or GeoTIFF files. This includes a basic raster layer (*ScalaField*) and an animated layer for vector fields, such as wind or currents (*VectorFieldAnim*)
471 lines (420 loc) • 13.8 kB
JavaScript
import Cell from './Cell';
import inside from '@turf/inside';
/**
* Abstract class for a set of values (Vector | Scalar)
* assigned to a regular 2D-grid (lon-lat), aka 'a Raster source'
*/
export default class Field {
constructor(params) {
this.params = params;
this.nCols = params['nCols'];
this.nRows = params['nRows'];
// alias
this.width = params['nCols'];
this.height = params['nRows'];
// ll = lower-left
this.xllCorner = params['xllCorner'];
this.yllCorner = params['yllCorner'];
// ur = upper-right
this.xurCorner =
params['xllCorner'] + params['nCols'] * params['cellXSize'];
this.yurCorner =
params['yllCorner'] + params['nRows'] * params['cellYSize'];
this.cellXSize = params['cellXSize'];
this.cellYSize = params['cellYSize'];
this.grid = null; // to be defined by subclasses
this.isContinuous = this.xurCorner - this.xllCorner >= 360;
this.longitudeNeedsToBeWrapped = this.xurCorner > 180; // [0, 360] --> [-180, 180]
this._inFilter = null;
this._spatialMask = null;
}
/**
* Builds a grid with a value at each point (either Vector or Number)
* Original params must include the required input values, following
* x-ascending & y-descending order (same as in ASCIIGrid)
* @abstract
* @private
* @returns {Array.<Array.<Vector|Number>>} - grid[row][column]--> Vector|Number
*/
_buildGrid() {
throw new TypeError('Must be overriden');
}
_updateRange() {
this.range = this._calculateRange();
}
/**
* Number of cells in the grid (rows * cols)
* @returns {Number}
*/
numCells() {
return this.nRows * this.nCols;
}
/**
* A list with every cell
* @returns {Array<Cell>} - cells (x-ascending & y-descending order)
*/
getCells(stride = 1) {
let cells = [];
for (let j = 0; j < this.nRows; j = j + stride) {
for (let i = 0; i < this.nCols; i = i + stride) {
let [lon, lat] = this._lonLatAtIndexes(i, j);
let center = L.latLng(lat, lon);
let value = this._valueAtIndexes(i, j);
let c = new Cell(center, value, this.cellXSize, this.cellYSize);
cells.push(c); // <<
}
}
return cells;
}
/**
* Apply a filter function to field values
* @param {Function} f - boolean function
*/
setFilter(f) {
this._inFilter = f;
this._updateRange();
}
/**
* Apply a spatial mask to field values
* @param {L.GeoJSON} m
*/
setSpatialMask(m) {
this._spatialMask = m;
}
/**
* Grid extent
* @returns {Number[]} [xmin, ymin, xmax, ymax]
*/
extent() {
let [xmin, xmax] = this._getWrappedLongitudes();
return [xmin, this.yllCorner, xmax, this.yurCorner];
}
/**
* [xmin, xmax] in [-180, 180] range
*/
_getWrappedLongitudes() {
let xmin = this.xllCorner;
let xmax = this.xurCorner;
if (this.longitudeNeedsToBeWrapped) {
if (this.isContinuous) {
xmin = -180;
xmax = 180;
} else {
// not sure about this (just one particular case, but others...?)
xmax = this.xurCorner - 360;
xmin = this.xllCorner - 360;
/* eslint-disable no-console */
// console.warn(`are these xmin: ${xmin} & xmax: ${xmax} OK?`);
// TODO: Better throw an exception on no-controlled situations.
/* eslint-enable no-console */
}
}
return [xmin, xmax];
}
/**
* Returns whether or not the grid contains the point, considering
* the spatialMask if it has been previously set
* @param {Number} lon - longitude
* @param {Number} lat - latitude
* @returns {Boolean}
*/
contains(lon, lat) {
if (this._spatialMask) {
return this._pointInMask(lon, lat);
}
return this._pointInExtent(lon, lat);
}
/**
* Checks if coordinates are inside the Extent (considering wrapped longitudes if needed)
* @param {Number} lon
* @param {Number} lat
*/
_pointInExtent(lon, lat) {
let [xmin, xmax] = this._getWrappedLongitudes();
let longitudeIn = lon >= xmin && lon <= xmax;
let latitudeIn = lat >= this.yllCorner && lat <= this.yurCorner;
return longitudeIn && latitudeIn;
}
/**
* Check if coordinates are inside the spatialMask (Point in Polygon analysis)
* @param {Number} lon
* @param {Number} lat
*/
_pointInMask(lon, lat) {
const pt = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [lon, lat] // geojson, lon-lat order !
},
properties: {}
};
const poly = this._spatialMask;
return inside(pt, poly);
}
/**
* Returns if the grid doesn't contain the point
* @param {Number} lon - longitude
* @param {Number} lat - latitude
* @returns {Boolean}
*/
notContains(lon, lat) {
return !this.contains(lon, lat);
}
/**
* Interpolated value at lon-lat coordinates (bilinear method)
* @param {Number} longitude
* @param {Number} latitude
* @returns {Vector|Number} [u, v, magnitude]
*
* Source: https://github.com/cambecc/earth > product.js
*/
interpolatedValueAt(lon, lat) {
if (this.notContains(lon, lat)) return null;
let [i, j] = this._getDecimalIndexes(lon, lat);
return this.interpolatedValueAtIndexes(i, j);
}
/**
* Interpolated value at i-j indexes (bilinear method)
* @param {Number} i
* @param {Number} j
* @returns {Vector|Number} [u, v, magnitude]
*
* Source: https://github.com/cambecc/earth > product.js
*/
interpolatedValueAtIndexes(i, j) {
// 1 2 After converting λ and φ to fractional grid indexes i and j, we find the
// fi i ci four points 'G' that enclose point (i, j). These points are at the four
// | =1.4 | corners specified by the floor and ceiling of i and j. For example, given
// ---G--|---G--- fj 8 i = 1.4 and j = 8.3, the four surrounding grid points are (1, 8), (2, 8),
// j ___|_ . | (1, 9) and (2, 9).
// =8.3 | |
// ---G------G--- cj 9 Note that for wrapped grids, the first column is duplicated as the last
// | | column, so the index ci can be used without taking a modulo.
let indexes = this._getFourSurroundingIndexes(i, j);
let [fi, ci, fj, cj] = indexes;
let values = this._getFourSurroundingValues(fi, ci, fj, cj);
if (values) {
let [g00, g10, g01, g11] = values;
return this._doInterpolation(i - fi, j - fj, g00, g10, g01, g11);
}
return null;
}
/**
* Get decimal indexes
* @private
* @param {Number} lon
* @param {Number} lat
* @returns {Array} [[Description]]
*/
_getDecimalIndexes(lon, lat) {
if (this.longitudeNeedsToBeWrapped && lon < this.xllCorner) {
lon = lon + 360;
}
let i = (lon - this.xllCorner) / this.cellXSize;
let j = (this.yurCorner - lat) / this.cellYSize;
return [i, j];
}
/**
* Get surrounding indexes (integer), clampling on borders
* @private
* @param {Number} i - decimal index
* @param {Number} j - decimal index
* @returns {Array} [fi, ci, fj, cj]
*/
_getFourSurroundingIndexes(i, j) {
let fi = Math.floor(i);
let ci = fi + 1;
// duplicate colum to simplify interpolation logic (wrapped value)
if (this.isContinuous && ci >= this.nCols) {
ci = 0;
}
ci = this._clampColumnIndex(ci);
let fj = this._clampRowIndex(Math.floor(j));
let cj = this._clampRowIndex(fj + 1);
return [fi, ci, fj, cj];
}
/**
* Get four surrounding values or null if not available,
* from 4 integer indexes
* @private
* @param {Number} fi
* @param {Number} ci
* @param {Number} fj
* @param {Number} cj
* @returns {Array}
*/
_getFourSurroundingValues(fi, ci, fj, cj) {
var row;
if ((row = this.grid[fj])) {
// upper row ^^
var g00 = row[fi]; // << left
var g10 = row[ci]; // right >>
if (
this._isValid(g00) &&
this._isValid(g10) &&
(row = this.grid[cj])
) {
// lower row vv
var g01 = row[fi]; // << left
var g11 = row[ci]; // right >>
if (this._isValid(g01) && this._isValid(g11)) {
return [g00, g10, g01, g11]; // 4 values found!
}
}
}
return null;
}
/**
* Nearest value at lon-lat coordinates
* @param {Number} longitude
* @param {Number} latitude
* @returns {Vector|Number}
*/
valueAt(lon, lat) {
if (this.notContains(lon, lat)) return null;
let [i, j] = this._getDecimalIndexes(lon, lat);
let ii = Math.floor(i);
let jj = Math.floor(j);
const ci = this._clampColumnIndex(ii);
const cj = this._clampRowIndex(jj);
let value = this._valueAtIndexes(ci, cj);
if (this._inFilter) {
if (!this._inFilter(value)) return null;
}
return value;
}
/**
* Returns whether or not the field has a value at the point
* @param {Number} lon - longitude
* @param {Number} lat - latitude
* @returns {Boolean}
*/
hasValueAt(lon, lat) {
let value = this.valueAt(lon, lat);
let hasValue = value !== null;
let included = true;
if (this._inFilter) {
included = this._inFilter(value);
}
return hasValue && included;
}
/**
* Returns if the grid has no value at the point
* @param {Number} lon - longitude
* @param {Number} lat - latitude
* @returns {Boolean}
*/
notHasValueAt(lon, lat) {
return !this.hasValueAt(lon, lat);
}
/**
* Gives a random position to 'o' inside the grid
* @param {Object} [o] - an object (eg. a particle)
* @returns {{x: Number, y: Number}} - object with x, y (lon, lat)
*/
randomPosition(o = {}) {
let i = (Math.random() * this.nCols) | 0;
let j = (Math.random() * this.nRows) | 0;
o.x = this._longitudeAtX(i);
o.y = this._latitudeAtY(j);
return o;
}
/**
* Value for grid indexes
* @param {Number} i - column index (integer)
* @param {Number} j - row index (integer)
* @returns {Vector|Number}
*/
_valueAtIndexes(i, j) {
return this.grid[j][i]; // <-- j,i !!
}
/**
* Lon-Lat for grid indexes
* @param {Number} i - column index (integer)
* @param {Number} j - row index (integer)
* @returns {Number[]} [lon, lat]
*/
_lonLatAtIndexes(i, j) {
let lon = this._longitudeAtX(i);
let lat = this._latitudeAtY(j);
return [lon, lat];
}
/**
* Longitude for grid-index
* @param {Number} i - column index (integer)
* @returns {Number} longitude at the center of the cell
*/
_longitudeAtX(i) {
let halfXPixel = this.cellXSize / 2.0;
let lon = this.xllCorner + halfXPixel + i * this.cellXSize;
if (this.longitudeNeedsToBeWrapped) {
lon = lon > 180 ? lon - 360 : lon;
}
return lon;
}
/**
* Latitude for grid-index
* @param {Number} j - row index (integer)
* @returns {Number} latitude at the center of the cell
*/
_latitudeAtY(j) {
let halfYPixel = this.cellYSize / 2.0;
return this.yurCorner - halfYPixel - j * this.cellYSize;
}
/**
* Apply the interpolation
* @abstract
* @private
*/
/* eslint-disable no-unused-vars */
_doInterpolation(x, y, g00, g10, g01, g11) {
throw new TypeError('Must be overriden');
}
/* eslint-disable no-unused-vars */
/**
* Check the column index is inside the field,
* adjusting to min or max when needed
* @private
* @param {Number} ii - index
* @returns {Number} i - inside the allowed indexes
*/
_clampColumnIndex(ii) {
let i = ii;
if (ii < 0) {
i = 0;
}
let maxCol = this.nCols - 1;
if (ii > maxCol) {
i = maxCol;
}
return i;
}
/**
* Check the row index is inside the field,
* adjusting to min or max when needed
* @private
* @param {Number} jj index
* @returns {Number} j - inside the allowed indexes
*/
_clampRowIndex(jj) {
let j = jj;
if (jj < 0) {
j = 0;
}
let maxRow = this.nRows - 1;
if (jj > maxRow) {
j = maxRow;
}
return j;
}
/**
* Is valid (not 'null' nor 'undefined')
* @private
* @param {Object} x object
* @returns {Boolean}
*/
_isValid(x) {
return x !== null && x !== undefined;
}
}