UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

773 lines (730 loc) 24.9 kB
import { Box3, Vector2, Vector3 } from 'three'; import ProjUtils from '../../utils/ProjUtils'; import { nonNull } from '../../utils/tsutils'; import OffsetScale from '../OffsetScale'; import Coordinates, { assertCrsIsValid, crsIsGeocentric, crsIsGeographic, is4326 } from './Coordinates'; const tmpXY = new Vector2(); const CARDINAL = { WEST: 0, EAST: 1, SOUTH: 2, NORTH: 3 }; export function reasonnableEpsilonForCRS(crs, width, height) { if (is4326(crs)) { return 0.01; } return 0.01 * Math.min(width, height); } const cardinals = [new Vector2(), new Vector2(), new Vector2(), new Vector2(), new Vector2(), new Vector2(), new Vector2(), new Vector2()]; /** * Possible values to define an extent. * The following combinations are supported: * - 2 coordinates for the min and max corners of the extent * - 4 numerical values for the `minx`, `maxx`, `miny`, `maxy` * - an object with `west`, `east`, `south`, `north` properties */ /** * An object representing a spatial extent. It encapsulates a Coordinate Reference System id (CRS) * and coordinates. * * It leverages [proj4js](https://github.com/proj4js/proj4js) to do the heavy-lifting of defining * and transforming coordinates between reference systems. As a consequence, every EPSG code known * by proj4js can be used out of the box, as such: * * // an extent defined by bottom-left longitude 0 and latitude 0 and top-right longitude 1 and * // latitude 1 * const extent = new Extent('EPSG:4326', 0, 0, 1, 1); * * For other EPSG codes, you must register them with `Instance.registerCRS()` : * * ```js * Instance.registerCRS('EPSG:3946', * '+proj=lcc +lat_1=45.25 +lat_2=46.75 +lat_0=46 +lon_0=3 +x_0=1700000 +y_0=5200000 + \ * ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'); * * extent = new Extent( * 'EPSG:3946', * 1837816.94334, 1847692.32501, * 5170036.4587, 5178412.82698); * ``` */ class Extent { /** * Constructs an Extent object. * * @param crs - The CRS code the coordinates are expressed in. Every EPSG code known by * [proj4js](https://github.com/proj4js/proj4js) can be used directly. * For others, you must manually register them. * Please refer to [proj4js](https://github.com/proj4js/proj4js) doc for more information. * @param values - The extent values. */ constructor(crs, ...values) { this._values = new Float64Array(4); this._crs = crs; this.set(crs, ...values); } /** * Returns an extent centered at the specified coordinate, and with the specified size. * * @param crs - The CRS identifier. * @param center - The center. * @param width - The width, in CRS units. * @param height - The height, in CRS units. * @returns The produced extent. */ static fromCenterAndSize(crs, center, width, height) { const minX = center.x - width / 2; const maxX = center.x + width / 2; const minY = center.y - height / 2; const maxY = center.y + height / 2; return new Extent(crs, minX, maxX, minY, maxY); } get values() { return this._values; } /** * Returns `true` if the two extents are equal. * * @param other - The extent to compare. * @param epsilon - The optional comparison epsilon. * @returns `true` if the extents are equal, otherwise `false`. */ equals(other, epsilon = 0.00001) { return other._crs === this._crs && Math.abs(other._values[0] - this._values[0]) <= epsilon && Math.abs(other._values[1] - this._values[1]) <= epsilon && Math.abs(other._values[2] - this._values[2]) <= epsilon && Math.abs(other._values[3] - this._values[3]) <= epsilon; } /** * Checks the validity of the extent. * * @returns `true` if the extent is valid, `false` otherwise. */ isValid() { if (!(Number.isFinite(this.west) && Number.isFinite(this.east) && Number.isFinite(this.south) && Number.isFinite(this.north))) { return false; } // Geographic coordinate systems may allow a greater "west" than "east" // to account for the wrap around the 180° longitude line. if (!crsIsGeographic(this.crs)) { if (this.west > this.east) { return false; } } if (this.south > this.north) { return false; } return true; } /** * Clones this object. * * @returns a copy of this object. */ clone() { const minx = this._values[CARDINAL.WEST]; const maxx = this._values[CARDINAL.EAST]; const miny = this._values[CARDINAL.SOUTH]; const maxy = this._values[CARDINAL.NORTH]; const result = new Extent(this._crs, minx, maxx, miny, maxy); return result; } /** * Returns an extent with a relative margin added. * * @param marginRatio - The margin, in normalized value ([0, 1]). * A margin of 1 means 100% of the width or height of the extent. * @example * const extent = new Extent('EPSG:3857', 0, 100, 0, 100); * const margin = extent.withRelativeMargin(0.1); * // new Extent('EPSG:3857', -10, 110, -10, 110); * @returns a new extent with a specified margin applied. */ withRelativeMargin(marginRatio) { const w = Math.abs(this.west - this.east); const h = Math.abs(this.north - this.south); return this.withMargin(marginRatio * w, marginRatio * h); } /** * Returns an extent with a margin. * * @param x - The horizontal margin, in CRS units. * @param y - The vertical margin, in CRS units. * @example * const extent = new Extent('EPSG:3857', 0, 100, 0, 100); * const margin = extent.withMargin(10, 15); * // new Extent('EPSG:3857', -10, 110, -15, 115); * @returns a new extent with a specified margin applied. */ withMargin(x, y) { const w = this.west - x; const e = this.east + x; const n = this.north + y; const s = this.south - y; return new Extent(this.crs, w, e, s, n); } /** * Converts this extent into another CRS. * If `crs` is the same as the current CRS, the original object is returned. * * @param crs - the new CRS * @returns the converted extent. */ as(crs) { assertCrsIsValid(crs); if (this._crs !== crs && !(is4326(this._crs) && is4326(crs))) { // Compute min/max in x/y by projecting 8 cardinal points, // and then taking the min/max of each coordinates. const c = this.centerAsVector2(tmpXY); const cx = c.x; const cy = c.y; const e = this.east; const w = this.west; const n = this.north; const s = this.south; cardinals[0].set(w, n); cardinals[1].set(cx, n); cardinals[2].set(e, n); cardinals[3].set(e, cy); cardinals[4].set(e, s); cardinals[5].set(cx, s); cardinals[6].set(w, s); cardinals[7].set(w, cy); let north = -Infinity; let south = Infinity; let east = -Infinity; let west = Infinity; // convert the coordinates ProjUtils.transformVectors(this._crs, crs, cardinals); // loop over the coordinates for (let i = 0; i < cardinals.length; i++) { north = Math.max(north, cardinals[i].y); south = Math.min(south, cardinals[i].y); east = Math.max(east, cardinals[i].x); west = Math.min(west, cardinals[i].x); } return new Extent(crs, { north, south, east, west }); } return this; } offsetToParent(other, target = new OffsetScale()) { if (this.crs !== other.crs) { throw new Error('unsupported mix'); } const oDim = other.dimensions(); const dim = this.dimensions(); const originX = Math.round(1000 * (this.west - other.west) / oDim.x) * 0.001; const originY = Math.round(1000 * (this.south - other.south) / oDim.y) * 0.001; const scaleX = Math.round(1000 * dim.x / oDim.x) * 0.001; const scaleY = Math.round(1000 * dim.y / oDim.y) * 0.001; return target.set(originX, originY, scaleX, scaleY); } /** * @returns the horizontal coordinate of the westernmost side */ get west() { return this._values[CARDINAL.WEST]; } /** * @returns the horizontal coordinate of the easternmost side */ get east() { return this._values[CARDINAL.EAST]; } /** * @returns the horizontal coordinate of the northernmost side */ get north() { return this._values[CARDINAL.NORTH]; } /** * @returns the horizontal coordinate of the southermost side */ get south() { return this._values[CARDINAL.SOUTH]; } /** * @returns the coordinates of the top left corner */ topLeft() { return new Coordinates(this.crs, this.west, this.north, 0); } /** * @returns the coordinates of the top right corner */ topRight() { return new Coordinates(this.crs, this.east, this.north, 0); } /** * @returns the coordinates of the bottom right corner */ bottomRight() { return new Coordinates(this.crs, this.east, this.south, 0); } /** * @returns the coordinates of the bottom right corner */ bottomLeft() { return new Coordinates(this.crs, this.west, this.south, 0); } /** * Gets the coordinate reference system of this extent. */ get crs() { return this._crs; } /** * Sets `target` with the center of this extent. * * @param target - the coordinate to set with the center's coordinates. * If none provided, a new one is created. * @returns the modified object passed in argument. */ center(target) { const center = this.centerAsVector2(tmpXY); let result; if (target) { result = target; result.set(this._crs, center.x, center.y, 0); } else { result = new Coordinates(this._crs, center.x, center.y, 0); } return result; } /** * Sets `target` with the center of this extent. * * @param target - the vector to set with the center's coordinates. * If none provided, a new one is created. * @returns the modified object passed in argument. */ centerAsVector2(target) { const dim = this.dimensions(tmpXY); const x = this._values[0] + dim.x * 0.5; const y = this._values[2] + dim.y * 0.5; let result; if (target) { result = target; result.set(x, y); } else { result = new Vector2(x, y); } return result; } /** * Sets `target` with the center of this extent. * Note: The z coordinate of the resulting vector will be set to zero. * * @param target - the vector to set with the center's coordinates. * If none provided, a new one is created. * @returns the modified object passed in argument. */ centerAsVector3(target) { const center = this.centerAsVector2(tmpXY); let result; if (target) { result = target; result.set(center.x, center.y, 0); } else { result = new Vector3(center.x, center.y, 0); } return result; } /** * Sets the target with the width and height of this extent. * The `x` property will be set with the width, * and the `y` property will be set with the height. * * @param target - the optional target to set with the result. * @returns the modified object passed in argument, * or a new object if none was provided. */ dimensions(target = new Vector2()) { target.x = Math.abs(this.east - this.west); target.y = Math.abs(this.north - this.south); return target; } /** * Checks whether the specified coordinate is inside this extent. * * @param coord - the coordinate to test * @param epsilon - the precision delta (+/- epsilon) * @returns `true` if the coordinate is inside the bounding box */ isPointInside(coord, epsilon = 0) { const c = this.crs === coord.crs ? coord : coord.as(this.crs); // TODO this ignores altitude if (crsIsGeographic(this.crs)) { return c.longitude <= this.east + epsilon && c.longitude >= this.west - epsilon && c.latitude <= this.north + epsilon && c.latitude >= this.south - epsilon; } return c.x <= this.east + epsilon && c.x >= this.west - epsilon && c.y <= this.north + epsilon && c.y >= this.south - epsilon; } /** * Tests whether this extent is contained in another extent. * * @param other - the other extent to test * @param epsilon - the precision delta (+/- epsilon). * If this value is not provided, a reasonable epsilon will be computed. * @returns `true` if this extent is contained in the other extent. */ isInside(other, epsilon = null) { const o = other.as(this._crs); // 0 is an acceptable value for epsilon: const dims = this.dimensions(tmpXY); epsilon = epsilon == null ? reasonnableEpsilonForCRS(this._crs, dims.x, dims.y) : epsilon; return this.east - o.east <= epsilon && o.west - this.west <= epsilon && this.north - o.north <= epsilon && o.south - this.south <= epsilon; } /** * Returns `true` if this bounding box intersect with the bouding box parameter * * @param bbox - the bounding box to test * @returns `true` if this bounding box intersects with the provided bounding box */ intersectsExtent(bbox) { const other = bbox.as(this.crs); return !(this.west >= other.east || this.east <= other.west || this.south >= other.north || this.north <= other.south); } /** * Set this extent to the intersection of itself and other * * @param other - the bounding box to intersect * @returns the modified extent */ intersect(other) { if (!this.intersectsExtent(other)) { this.set(this.crs, 0, 0, 0, 0); return this; } // TODO use an intermediate tmp instance for .as if (other.crs !== this.crs) { other = other.as(this.crs); } this.set(this.crs, Math.max(this.west, other.west), Math.min(this.east, other.east), Math.max(this.south, other.south), Math.min(this.north, other.north)); return this; } /** * Returns an extent that is adjusted so that its edges land exactly at the border * of the grid pixels. Optionally, you can specify the minimum pixel size of the * resulting extent. * * @param gridExtent - The grid extent. * @param gridWidth - The grid width, in pixels. * @param gridHeight - The grid height, in pixels. * @param minPixWidth - The minimum width, in pixels, of the resulting extent. * @param minPixHeight - The minimum height, in pixels, of the resulting extent. * @returns The adjusted extent and pixel * size of the adjusted extent. */ fitToGrid(gridExtent, gridWidth, gridHeight, minPixWidth, minPixHeight) { const gridDims = gridExtent.dimensions(tmpXY); const pixelWidth = gridDims.x / gridWidth; const pixelHeight = gridDims.y / gridHeight; let leftPixels = (this.west - gridExtent.west) / pixelWidth; let rightPixels = (this.east - gridExtent.west) / pixelWidth; let bottomPixels = (this.south - gridExtent.south) / pixelHeight; let topPixels = (this.north - gridExtent.south) / pixelHeight; if (minPixWidth !== undefined && minPixHeight !== undefined) { const pixelCountX = rightPixels - leftPixels; const pixelCountY = topPixels - bottomPixels; if (pixelCountX < minPixWidth) { const margin = (minPixWidth - pixelCountX) / 2; leftPixels -= margin; rightPixels += margin; } if (pixelCountY < minPixHeight) { const margin = (minPixHeight - pixelCountY) / 2; bottomPixels -= margin; topPixels += margin; } } leftPixels = Math.max(0, Math.floor(leftPixels)); rightPixels = Math.min(gridWidth, Math.ceil(rightPixels)); bottomPixels = Math.max(0, Math.floor(bottomPixels)); topPixels = Math.min(gridHeight, Math.ceil(topPixels)); const west = gridExtent.west + leftPixels * pixelWidth; const east = gridExtent.west + rightPixels * pixelWidth; const south = gridExtent.south + bottomPixels * pixelHeight; const north = gridExtent.south + topPixels * pixelHeight; return { extent: new Extent(this.crs, west, east, south, north), width: rightPixels - leftPixels, height: topPixels - bottomPixels }; } /** * Set the coordinate reference system and values of this * extent. * * @param crs - the new CRS * @param values - the new values * @returns this object modified */ set(crs, ...values) { this._crs = crs; if (values.length === 2 && values[0] instanceof Coordinates && values[1] instanceof Coordinates) { [this._values[CARDINAL.WEST], this._values[CARDINAL.SOUTH]] = values[0].values; [this._values[CARDINAL.EAST], this._values[CARDINAL.NORTH]] = values[1].values; } else if (values.length === 1 && values[0].west !== undefined) { this._values[CARDINAL.WEST] = values[0].west; this._values[CARDINAL.EAST] = values[0].east; this._values[CARDINAL.SOUTH] = values[0].south; this._values[CARDINAL.NORTH] = values[0].north; } else if (values.length === 4) { this._values[CARDINAL.WEST] = values[CARDINAL.WEST]; this._values[CARDINAL.EAST] = values[CARDINAL.EAST]; this._values[CARDINAL.SOUTH] = values[CARDINAL.SOUTH]; this._values[CARDINAL.NORTH] = values[CARDINAL.NORTH]; } else { throw new Error(`Unsupported constructor args '${values}'`); } return this; } copy(other) { this._crs = other.crs; this._values[CARDINAL.WEST] = other._values[CARDINAL.WEST]; this._values[CARDINAL.EAST] = other._values[CARDINAL.EAST]; this._values[CARDINAL.SOUTH] = other._values[CARDINAL.SOUTH]; this._values[CARDINAL.NORTH] = other._values[CARDINAL.NORTH]; return this; } /** @internal */ static unionMany(...extents) { if (extents == null || extents.length === 0) { return null; } if (extents.length === 1) { return extents[0].clone(); } let south = +Infinity; let north = -Infinity; let east = -Infinity; let west = +Infinity; let valid = false; let crs = null; for (let i = 0; i < extents.length; i++) { const e = nonNull(extents[i]); valid = true; if (crs != null) { if (crs !== e.crs) { throw new Error(`Unsupported union between different CRSes (${e.crs} and ${crs} differ)`); } } else { crs = e.crs; } south = Math.min(e.south, south); north = Math.max(e.north, north); east = Math.max(e.east, east); west = Math.min(e.west, west); } if (valid) { return new Extent(extents[0].crs, west, east, south, north); } else { return null; } } union(extent) { if (extent == null) { return; } if (extent.crs !== this.crs) { throw new Error(`unsupported union between different CRSes (${extent.crs} and ${this.crs} differ)`); } const west = extent.west; if (west < this.west) { this._values[CARDINAL.WEST] = west; } const east = extent.east; if (east > this.east) { this._values[CARDINAL.EAST] = east; } const south = extent.south; if (south < this.south) { this._values[CARDINAL.SOUTH] = south; } const north = extent.north; if (north > this.north) { this._values[CARDINAL.NORTH] = north; } } /** * Expands the extent to contain the specified coordinates. * * @param coordinates - The coordinates to include */ expandByPoint(coordinates) { const coords = coordinates.as(this.crs); const we = coords.values[0]; if (we < this.west) { this._values[CARDINAL.WEST] = we; } if (we > this.east) { this._values[CARDINAL.EAST] = we; } const sn = coords.values[1]; if (sn < this.south) { this._values[CARDINAL.SOUTH] = sn; } if (sn > this.north) { this._values[CARDINAL.NORTH] = sn; } } /** * Moves the extent by the provided `x` and `y` values. * * @param x - the horizontal shift * @param y - the vertical shift * @returns the modified extent. */ shift(x, y) { this._values[CARDINAL.WEST] += x; this._values[CARDINAL.EAST] += x; this._values[CARDINAL.SOUTH] += y; this._values[CARDINAL.NORTH] += y; return this; } /** * Constructs an extent from the specified box. * * @param crs - the coordinate reference system of the new extent. * @param box - the box to read values from * @returns the constructed extent. */ static fromBox3(crs, box) { return new this(crs, { west: box.min.x, east: box.max.x, south: box.min.y, north: box.max.y }); } /** * Returns a [Box3](https://threejs.org/docs/?q=box3#api/en/math/Box3) that matches this extent. * * @param minHeight - The min height of the box. * @param maxHeight - The max height of the box. * @returns The box. */ toBox3(minHeight, maxHeight) { const min = new Vector3(this.west, this.south, minHeight); const max = new Vector3(this.east, this.north, maxHeight); const box = new Box3(min, max); return box; } /** * Returns the normalized offset from bottom-left in extent of this Coordinates * * @param coordinate - the coordinate * @param target - optional `Vector2` target. * If not present a new one will be created. * @returns normalized offset in extent * @example * extent.offsetInExtent(extent.center()) * // returns `(0.5, 0.5)`. */ offsetInExtent(coordinate, target = new Vector2()) { if (coordinate.crs !== this.crs) { throw new Error('unsupported mix'); } const dimX = Math.abs(this.east - this.west); const dimY = Math.abs(this.north - this.south); const x = crsIsGeocentric(coordinate.crs) ? coordinate.x : coordinate.longitude; const y = crsIsGeocentric(coordinate.crs) ? coordinate.y : coordinate.latitude; const originX = (x - this.west) / dimX; const originY = (y - this.south) / dimY; target.set(originX, originY); return target; } /** * Divides this extent into a regular grid. * The number of points in each direction is equal to the number of subdivisions + 1. * The points are laid out row-wise, from west to east, and north to south: * * ``` * 1 -- 2 * | | * 3 -- 4 * ``` * * @param xSubdivs - The number of grid subdivisions in the x-axis. * @param ySubdivs - The number of grid subdivisions in the y-axis. * @param target - The array to fill. * @param stride - The number of elements per item (2 for XY, 3 for XYZ). * @returns the target. */ toGrid(xSubdivs, ySubdivs, target, stride) { const dims = this.dimensions(tmpXY); const west = this.west; const north = this.north; // The size of an horizontal/vertical step const xStep = dims.x / xSubdivs; const yStep = dims.y / ySubdivs; // The number of vertices in each direction const xCount = xSubdivs + 1; for (let j = 0; j < ySubdivs + 1; j++) { for (let i = 0; i < xCount; i++) { const x = west + xStep * i; const y = north - yStep * j; const index = stride * (xCount * j + i); target[index + 0] = x; target[index + 1] = y; } } return target; } /** * Subdivides this extents into x and y subdivisions. * * Notes: * - Subdivisions must be strictly positive. * - If both subvisions are `1`, an array of one element is returned, * containing a copy of this extent. * * @param xSubdivs - The number of subdivisions on the X/longitude axis. * @param ySubdivs - The number of subdivisions on the Y/latitude axis. * @returns the resulting extents. * @example * const extent = new Extent('EPSG:3857', 0, 100, 0, 100); * extent.split(2, 1); * // [0, 50, 0, 50], [50, 100, 50, 100] */ split(xSubdivs, ySubdivs) { if (xSubdivs < 1 || ySubdivs < 1) { throw new Error('Invalid subdivisions. Must be strictly positive.'); } if (xSubdivs === 1 && ySubdivs === 1) { return [this.clone()]; } const dims = this.dimensions(); const minX = this.west; const minY = this.south; const w = dims.x / xSubdivs; const h = dims.y / ySubdivs; const crs = this.crs; const result = []; for (let x = 0; x < xSubdivs; x++) { for (let y = 0; y < ySubdivs; y++) { const west = minX + x * w; const south = minY + y * h; const extent = new Extent(crs, west, west + w, south, south + h); result.push(extent); } } return result; } /** * The bounds of the Web Mercator (EPSG:3857) projection. */ static get webMercator() { return new Extent('EPSG:3857', -20037508.34, 20037508.34, -20048966.1, 20048966.1); } /** * The bounds of the whole world in the EPSG:4326 projection. */ static get WGS84() { return new Extent('EPSG:4326', -180, 180, -90, 90); } } export default Extent;