291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
import * as turf from "@turf/turf"
|
|
import { TileRange, Tiles } from "../Models/TileRange"
|
|
import { GeoOperations } from "./GeoOperations"
|
|
import { Feature, Polygon } from "geojson"
|
|
|
|
export class BBox {
|
|
static global: BBox = new BBox([
|
|
[-180, -90],
|
|
[180, 90],
|
|
])
|
|
readonly maxLat: number
|
|
readonly maxLon: number
|
|
readonly minLat: number
|
|
readonly minLon: number
|
|
|
|
/***
|
|
* Coordinates should be [[lon, lat],[lon, lat]]
|
|
* @param coordinates
|
|
*/
|
|
constructor(coordinates) {
|
|
this.maxLat = -90
|
|
this.maxLon = -180
|
|
this.minLat = 90
|
|
this.minLon = 180
|
|
|
|
for (const coordinate of coordinates) {
|
|
this.maxLon = Math.max(this.maxLon, coordinate[0])
|
|
this.maxLat = Math.max(this.maxLat, coordinate[1])
|
|
this.minLon = Math.min(this.minLon, coordinate[0])
|
|
this.minLat = Math.min(this.minLat, coordinate[1])
|
|
}
|
|
|
|
this.maxLon = Math.min(this.maxLon, 180)
|
|
this.maxLat = Math.min(this.maxLat, 90)
|
|
this.minLon = Math.max(this.minLon, -180)
|
|
this.minLat = Math.max(this.minLat, -90)
|
|
|
|
this.check()
|
|
}
|
|
|
|
static fromLeafletBounds(bounds) {
|
|
return new BBox([
|
|
[bounds.getWest(), bounds.getNorth()],
|
|
[bounds.getEast(), bounds.getSouth()],
|
|
])
|
|
}
|
|
|
|
static get(feature): BBox {
|
|
if (feature.bbox?.overlapsWith === undefined) {
|
|
const turfBbox: number[] = turf.bbox(feature)
|
|
feature.bbox = new BBox([
|
|
[turfBbox[0], turfBbox[1]],
|
|
[turfBbox[2], turfBbox[3]],
|
|
])
|
|
}
|
|
return feature.bbox
|
|
}
|
|
|
|
static bboxAroundAll(bboxes: BBox[]): BBox {
|
|
let maxLat: number = -90
|
|
let maxLon: number = -180
|
|
let minLat: number = 80
|
|
let minLon: number = 180
|
|
|
|
for (const bbox of bboxes) {
|
|
maxLat = Math.max(maxLat, bbox.maxLat)
|
|
maxLon = Math.max(maxLon, bbox.maxLon)
|
|
minLat = Math.min(minLat, bbox.minLat)
|
|
minLon = Math.min(minLon, bbox.minLon)
|
|
}
|
|
return new BBox([
|
|
[maxLon, maxLat],
|
|
[minLon, minLat],
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Calculates the BBox based on a slippy map tile number
|
|
*
|
|
* const bbox = BBox.fromTile(16, 32754, 21785)
|
|
* bbox.minLon // => -0.076904296875
|
|
* bbox.maxLon // => -0.0714111328125
|
|
* bbox.minLat // => 51.5292513551899
|
|
* bbox.maxLat // => 51.53266860674158
|
|
*/
|
|
static fromTile(z: number, x: number, y: number): BBox {
|
|
return new BBox(Tiles.tile_bounds_lon_lat(z, x, y))
|
|
}
|
|
|
|
static fromTileIndex(i: number): BBox {
|
|
if (i === 0) {
|
|
return BBox.global
|
|
}
|
|
return BBox.fromTile(...Tiles.tile_from_index(i))
|
|
}
|
|
|
|
public unionWith(other: BBox) {
|
|
return new BBox([
|
|
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
|
|
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Constructs a tilerange which fully contains this bbox (thus might be a bit larger)
|
|
* @param zoomlevel
|
|
*/
|
|
public containingTileRange(zoomlevel: number): TileRange {
|
|
return Tiles.TileRangeBetween(zoomlevel, this.minLat, this.minLon, this.maxLat, this.maxLon)
|
|
}
|
|
|
|
public overlapsWith(other: BBox) {
|
|
if (this.maxLon < other.minLon) {
|
|
return false
|
|
}
|
|
if (this.maxLat < other.minLat) {
|
|
return false
|
|
}
|
|
if (this.minLon > other.maxLon) {
|
|
return false
|
|
}
|
|
return this.minLat <= other.maxLat
|
|
}
|
|
|
|
public isContainedIn(other: BBox) {
|
|
if (this.maxLon > other.maxLon) {
|
|
return false
|
|
}
|
|
if (this.maxLat > other.maxLat) {
|
|
return false
|
|
}
|
|
if (this.minLon < other.minLon) {
|
|
return false
|
|
}
|
|
if (this.minLat < other.minLat) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
squarify(): BBox {
|
|
const w = this.maxLon - this.minLon
|
|
const h = this.maxLat - this.minLat
|
|
const s = Math.sqrt(w * h)
|
|
const lon = (this.maxLon + this.minLon) / 2
|
|
const lat = (this.maxLat + this.minLat) / 2
|
|
// we want to have a more-or-less equal surface, so the new side 's' should be
|
|
// w * h = s * s
|
|
// The ratio for w is:
|
|
|
|
return new BBox([
|
|
[lon - s / 2, lat - s / 2],
|
|
[lon + s / 2, lat + s / 2],
|
|
])
|
|
}
|
|
|
|
isNearby(location: [number, number], maxRange: number): boolean {
|
|
if (this.contains(location)) {
|
|
return true
|
|
}
|
|
const [lon, lat] = location
|
|
// We 'project' the point onto the near edges. If they are close to a horizontal _and_ vertical edge, it is nearby
|
|
// Vertically nearby: either wihtin minLat range or at most maxRange away
|
|
const nearbyVertical =
|
|
(this.minLat <= lat &&
|
|
this.maxLat >= lat &&
|
|
GeoOperations.distanceBetween(location, [lon, this.minLat]) <= maxRange) ||
|
|
GeoOperations.distanceBetween(location, [lon, this.maxLat]) <= maxRange
|
|
if (!nearbyVertical) {
|
|
return false
|
|
}
|
|
const nearbyHorizontal =
|
|
(this.minLon <= lon &&
|
|
this.maxLon >= lon &&
|
|
GeoOperations.distanceBetween(location, [this.minLon, lat]) <= maxRange) ||
|
|
GeoOperations.distanceBetween(location, [this.maxLon, lat]) <= maxRange
|
|
return nearbyHorizontal
|
|
}
|
|
|
|
getEast() {
|
|
return this.maxLon
|
|
}
|
|
|
|
getNorth() {
|
|
return this.maxLat
|
|
}
|
|
|
|
getWest() {
|
|
return this.minLon
|
|
}
|
|
|
|
getSouth() {
|
|
return this.minLat
|
|
}
|
|
|
|
contains(lonLat: [number, number]) {
|
|
return (
|
|
this.minLat <= lonLat[1] &&
|
|
lonLat[1] <= this.maxLat &&
|
|
this.minLon <= lonLat[0] &&
|
|
lonLat[0] <= this.maxLon
|
|
)
|
|
}
|
|
|
|
pad(factor: number, maxIncrease = 2): BBox {
|
|
const latDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLat - this.minLat) * factor)
|
|
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
|
|
return new BBox([
|
|
[this.minLon - lonDiff, this.minLat - latDiff],
|
|
[this.maxLon + lonDiff, this.maxLat + latDiff],
|
|
])
|
|
}
|
|
|
|
padAbsolute(degrees: number): BBox {
|
|
return new BBox([
|
|
[this.minLon - degrees, this.minLat - degrees],
|
|
[this.maxLon + degrees, this.maxLat + degrees],
|
|
])
|
|
}
|
|
|
|
toLngLat(): [[number, number], [number, number]] {
|
|
return [
|
|
[this.minLon, this.minLat],
|
|
[this.maxLon, this.maxLat],
|
|
]
|
|
}
|
|
|
|
public asGeojsonCached() {
|
|
if (this["geojsonCache"] === undefined) {
|
|
this["geojsonCache"] = this.asGeoJson({})
|
|
}
|
|
return this["geojsonCache"]
|
|
}
|
|
|
|
public asGeoJson<T = {}>(properties?: T): Feature<Polygon, T> {
|
|
return {
|
|
type: "Feature",
|
|
properties: properties,
|
|
geometry: this.asGeometry(),
|
|
}
|
|
}
|
|
|
|
public asGeometry(): Polygon {
|
|
return {
|
|
type: "Polygon",
|
|
coordinates: [
|
|
[
|
|
[this.minLon, this.minLat],
|
|
[this.maxLon, this.minLat],
|
|
[this.maxLon, this.maxLat],
|
|
[this.minLon, this.maxLat],
|
|
[this.minLon, this.minLat],
|
|
],
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expands the BBOx so that it contains complete tiles for the given zoomlevel
|
|
* @param zoomlevel
|
|
*/
|
|
expandToTileBounds(zoomlevel: number): BBox {
|
|
if (zoomlevel === undefined) {
|
|
return this
|
|
}
|
|
const ul = Tiles.embedded_tile(this.minLat, this.minLon, zoomlevel)
|
|
const lr = Tiles.embedded_tile(this.maxLat, this.maxLon, zoomlevel)
|
|
const boundsul = Tiles.tile_bounds_lon_lat(ul.z, ul.x, ul.y)
|
|
const boundslr = Tiles.tile_bounds_lon_lat(lr.z, lr.x, lr.y)
|
|
return new BBox([].concat(boundsul, boundslr))
|
|
}
|
|
|
|
toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } {
|
|
const [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
|
|
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
|
|
|
|
return {
|
|
minLon,
|
|
maxLon,
|
|
minLat,
|
|
maxLat,
|
|
}
|
|
}
|
|
|
|
private check() {
|
|
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
|
|
console.trace("BBox with NaN detected:", this)
|
|
throw "BBOX has NAN"
|
|
}
|
|
}
|
|
}
|