@mailwoman/spatial 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bbox.test.ts +39 -0
- package/bbox.ts +3 -3
- package/coordinate-formats.test.ts +84 -0
- package/coordinate-formats.ts +215 -0
- package/feature.ts +3 -3
- package/geometries/point.test.ts +144 -0
- package/geometries/polygon.test.ts +62 -0
- package/google/place-id.test.ts +54 -0
- package/index.ts +1 -0
- package/out/bbox.d.ts +2 -2
- package/out/bbox.js +2 -2
- package/out/coordinate-formats.d.ts +43 -0
- package/out/coordinate-formats.d.ts.map +1 -0
- package/out/coordinate-formats.js +194 -0
- package/out/coordinate-formats.js.map +1 -0
- package/out/feature.d.ts +3 -3
- package/out/feature.d.ts.map +1 -1
- package/out/index.d.ts +1 -0
- package/out/index.d.ts.map +1 -1
- package/out/index.js +1 -0
- package/out/index.js.map +1 -1
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/position.test.ts +48 -0
- package/sdk/well-known-text.test.ts +133 -0
- package/tsconfig.json +2 -1
package/bbox.test.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { is2DBBox, is3DBBox, isBBox } from "@mailwoman/spatial"
|
|
8
|
+
import { expect, test } from "vitest"
|
|
9
|
+
|
|
10
|
+
// 2D GeoJSON bbox: [west, south, east, north]. 3D adds min/max altitude: [w, s, minA, e, n, maxA].
|
|
11
|
+
const BBOX_2D = [-74.1, 40.6, -73.9, 40.9]
|
|
12
|
+
const BBOX_3D = [-74.1, 40.6, 0, -73.9, 40.9, 100]
|
|
13
|
+
|
|
14
|
+
test("is2DBBox: a length-4 tuple is 2D; a length-6 tuple is not", () => {
|
|
15
|
+
// Regression: this guard used to check length === 6 (a copy of is3DBBox), so it never
|
|
16
|
+
// recognized a real 2D bbox.
|
|
17
|
+
expect(is2DBBox(BBOX_2D)).toBe(true)
|
|
18
|
+
expect(is2DBBox(BBOX_3D)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("is3DBBox: a length-6 tuple is 3D; a length-4 tuple is not", () => {
|
|
22
|
+
expect(is3DBBox(BBOX_3D)).toBe(true)
|
|
23
|
+
expect(is3DBBox(BBOX_2D)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("isBBox: accepts length 4 or 6, rejects other shapes", () => {
|
|
27
|
+
expect(isBBox(BBOX_2D)).toBe(true)
|
|
28
|
+
expect(isBBox(BBOX_3D)).toBe(true)
|
|
29
|
+
expect(isBBox([1, 2, 3, 4, 5])).toBe(false) // length 5
|
|
30
|
+
expect(isBBox([1, 2, 3])).toBe(false) // length 3
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("the bbox guards reject non-array input", () => {
|
|
34
|
+
for (const input of [null, undefined, {}, "bbox", 4, { length: 4 }]) {
|
|
35
|
+
expect(is2DBBox(input)).toBe(false)
|
|
36
|
+
expect(is3DBBox(input)).toBe(false)
|
|
37
|
+
expect(isBBox(input)).toBe(false)
|
|
38
|
+
}
|
|
39
|
+
})
|
package/bbox.ts
CHANGED
|
@@ -109,15 +109,15 @@ export type BBox3DLiteral = [
|
|
|
109
109
|
//#region Type Predicates
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Type-predicate for
|
|
112
|
+
* Type-predicate for 2-dimensional bounding boxes (`[west, south, east, north]`).
|
|
113
113
|
*
|
|
114
114
|
* This is useful when determining the type of bounding box in a GeoJSON object.
|
|
115
115
|
*
|
|
116
116
|
* @category GeoJSON
|
|
117
117
|
* @category Bounding Box
|
|
118
118
|
*/
|
|
119
|
-
export function is2DBBox(input: unknown): input is
|
|
120
|
-
return Array.isArray(input) && input.length ===
|
|
119
|
+
export function is2DBBox(input: unknown): input is BBox2DLiteral {
|
|
120
|
+
return Array.isArray(input) && input.length === 4
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "vitest"
|
|
8
|
+
import {
|
|
9
|
+
coordinateFormatAnnotator,
|
|
10
|
+
qiblaBearing,
|
|
11
|
+
sunTimes,
|
|
12
|
+
toDMS,
|
|
13
|
+
toGeohash,
|
|
14
|
+
toMaidenhead,
|
|
15
|
+
toMercator,
|
|
16
|
+
toMGRS,
|
|
17
|
+
} from "./coordinate-formats.js"
|
|
18
|
+
|
|
19
|
+
// The White House (38.8977, -77.0365) — reference values cross-checked against published converters.
|
|
20
|
+
const LAT = 38.8977
|
|
21
|
+
const LON = -77.0365
|
|
22
|
+
|
|
23
|
+
test("toDMS: signed decimals → D° M′ S″ with hemisphere", () => {
|
|
24
|
+
const dms = toDMS(LAT, LON)
|
|
25
|
+
expect(dms.lat).toBe("38° 53′ 51.72″ N")
|
|
26
|
+
expect(dms.lon).toBe("77° 2′ 11.40″ W")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("toMaidenhead: 6-char locator with lowercase subsquare", () => {
|
|
30
|
+
expect(toMaidenhead(LAT, LON)).toBe("FM18lv")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("toGeohash: precision-9, deterministic, DC prefix", () => {
|
|
34
|
+
const gh = toGeohash(LAT, LON)
|
|
35
|
+
expect(gh).toHaveLength(9)
|
|
36
|
+
expect(gh.startsWith("dqc")).toBe(true)
|
|
37
|
+
expect(toGeohash(LAT, LON)).toBe(gh)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("toMercator: EPSG:3857 projection in range", () => {
|
|
41
|
+
const { x, y } = toMercator(LAT, LON)
|
|
42
|
+
expect(x).toBeGreaterThan(-8576000)
|
|
43
|
+
expect(x).toBeLessThan(-8575000)
|
|
44
|
+
expect(y).toBeGreaterThan(4690000)
|
|
45
|
+
expect(y).toBeLessThan(4730000)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("qiblaBearing: from DC the Kaaba is ~58° (ENE)", () => {
|
|
49
|
+
const b = qiblaBearing(LAT, LON)
|
|
50
|
+
expect(b).toBeGreaterThan(54)
|
|
51
|
+
expect(b).toBeLessThan(62)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("sunTimes: NYC summer solstice — sunrise ~09:26 UTC, ordered, ~15h day", () => {
|
|
55
|
+
const s = sunTimes(40.7128, -74.006, new Date("2026-06-21T12:00:00Z"))
|
|
56
|
+
const expectedRise = new Date("2026-06-21T09:26:00Z").getTime() / 1000
|
|
57
|
+
expect(Math.abs(s.rise! - expectedRise)).toBeLessThan(600) // within 10 min
|
|
58
|
+
expect(s.rise!).toBeLessThan(s.noon)
|
|
59
|
+
expect(s.noon).toBeLessThan(s.set!)
|
|
60
|
+
expect((s.set! - s.rise!) / 3600).toBeGreaterThan(14.5)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("sunTimes: polar day has no sunrise/sunset, only solar noon", () => {
|
|
64
|
+
const s = sunTimes(80, 0, new Date("2026-06-21T12:00:00Z"))
|
|
65
|
+
expect(s.rise).toBeUndefined()
|
|
66
|
+
expect(s.set).toBeUndefined()
|
|
67
|
+
expect(typeof s.noon).toBe("number")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("toMGRS: Washington Monument matches Wikipedia's vector (~4m); zone+band elsewhere; empty in polar bands", () => {
|
|
71
|
+
// Wikipedia MGRS article cites 18S UJ 23487 06483 for the monument; we match to ~4m.
|
|
72
|
+
expect(toMGRS(38.88949, -77.03524)).toBe("18SUJ2348306482")
|
|
73
|
+
expect(toMGRS(-33.8688, 151.2093).startsWith("56H")).toBe(true) // Sydney, zone 56 band H
|
|
74
|
+
expect(toMGRS(85, 0)).toBe("") // above 84°N — MGRS bands stop
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("coordinateFormatAnnotator: fills the coordinate-format slice of an AnnotationSet", () => {
|
|
78
|
+
const set = coordinateFormatAnnotator({ lat: LAT, lon: LON })
|
|
79
|
+
expect(set.maidenhead).toBe("FM18lv")
|
|
80
|
+
expect(set.geohash).toHaveLength(9)
|
|
81
|
+
expect(set.dms?.lat).toContain("N")
|
|
82
|
+
expect(typeof set.qiblaBearing).toBe("number")
|
|
83
|
+
expect(set.mercator?.x).toBeLessThan(0)
|
|
84
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Coordinate-format conversions — the pure-math annotators OpenCage exposes (DMS, geohash,
|
|
7
|
+
* Maidenhead, MGRS, Web Mercator, qibla bearing, sun times). No data, no I/O; each is a
|
|
8
|
+
* deterministic function of a `{lat, lon}`. {@link coordinateFormatAnnotator} packages them as an
|
|
9
|
+
* `@mailwoman/annotations` `Annotator`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AnnotationSet, Annotator } from "@mailwoman/annotations"
|
|
13
|
+
|
|
14
|
+
const toRad = (d: number): number => (d * Math.PI) / 180
|
|
15
|
+
const toDeg = (r: number): number => (r * 180) / Math.PI
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render a single signed degree as `D° M′ S″ H` with the given hemisphere letters `[positive,
|
|
19
|
+
* negative]`.
|
|
20
|
+
*/
|
|
21
|
+
function dmsComponent(value: number, hemispheres: [string, string], secondsDp = 2): string {
|
|
22
|
+
const hemisphere = value >= 0 ? hemispheres[0] : hemispheres[1]
|
|
23
|
+
const abs = Math.abs(value)
|
|
24
|
+
const degrees = Math.floor(abs)
|
|
25
|
+
const minutesFull = (abs - degrees) * 60
|
|
26
|
+
const minutes = Math.floor(minutesFull)
|
|
27
|
+
const seconds = (minutesFull - minutes) * 60
|
|
28
|
+
return `${degrees}° ${minutes}′ ${seconds.toFixed(secondsDp)}″ ${hemisphere}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Degrees-minutes-seconds for a coordinate. */
|
|
32
|
+
export function toDMS(lat: number, lon: number): { lat: string; lon: string } {
|
|
33
|
+
return { lat: dmsComponent(lat, ["N", "S"]), lon: dmsComponent(lon, ["E", "W"]) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WEB_MERCATOR_R = 6378137
|
|
37
|
+
|
|
38
|
+
/** Web Mercator (EPSG:3857) projection of a coordinate. */
|
|
39
|
+
export function toMercator(lat: number, lon: number): { x: number; y: number } {
|
|
40
|
+
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat))
|
|
41
|
+
return {
|
|
42
|
+
x: WEB_MERCATOR_R * toRad(lon),
|
|
43
|
+
y: WEB_MERCATOR_R * Math.log(Math.tan(Math.PI / 4 + toRad(clampedLat) / 2)),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const KAABA = { lat: 21.4225, lon: 39.8262 }
|
|
48
|
+
|
|
49
|
+
/** Initial great-circle bearing (degrees from true north) from a coordinate toward the Kaaba. */
|
|
50
|
+
export function qiblaBearing(lat: number, lon: number): number {
|
|
51
|
+
const phi1 = toRad(lat)
|
|
52
|
+
const phi2 = toRad(KAABA.lat)
|
|
53
|
+
const dLon = toRad(KAABA.lon - lon)
|
|
54
|
+
const y = Math.sin(dLon)
|
|
55
|
+
const x = Math.cos(phi1) * Math.tan(phi2) - Math.sin(phi1) * Math.cos(dLon)
|
|
56
|
+
return (toDeg(Math.atan2(y, x)) + 360) % 360
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const GEOHASH_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
|
|
60
|
+
|
|
61
|
+
/** Encode a coordinate as a geohash of the given precision (default 9 ≈ 4.8 m). */
|
|
62
|
+
export function toGeohash(lat: number, lon: number, precision = 9): string {
|
|
63
|
+
let latMin = -90
|
|
64
|
+
let latMax = 90
|
|
65
|
+
let lonMin = -180
|
|
66
|
+
let lonMax = 180
|
|
67
|
+
let hash = ""
|
|
68
|
+
let bits = 0
|
|
69
|
+
let bit = 0
|
|
70
|
+
let evenBit = true
|
|
71
|
+
|
|
72
|
+
while (hash.length < precision) {
|
|
73
|
+
if (evenBit) {
|
|
74
|
+
const mid = (lonMin + lonMax) / 2
|
|
75
|
+
if (lon >= mid) {
|
|
76
|
+
bits = bits * 2 + 1
|
|
77
|
+
lonMin = mid
|
|
78
|
+
} else {
|
|
79
|
+
bits *= 2
|
|
80
|
+
lonMax = mid
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const mid = (latMin + latMax) / 2
|
|
84
|
+
if (lat >= mid) {
|
|
85
|
+
bits = bits * 2 + 1
|
|
86
|
+
latMin = mid
|
|
87
|
+
} else {
|
|
88
|
+
bits *= 2
|
|
89
|
+
latMax = mid
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
evenBit = !evenBit
|
|
93
|
+
if (++bit === 5) {
|
|
94
|
+
hash += GEOHASH_BASE32[bits]
|
|
95
|
+
bit = 0
|
|
96
|
+
bits = 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return hash
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const A_CODE = "A".charCodeAt(0)
|
|
103
|
+
|
|
104
|
+
/** Maidenhead grid locator (default 6-char: field uppercase, square digits, subsquare lowercase). */
|
|
105
|
+
export function toMaidenhead(lat: number, lon: number, pairs = 3): string {
|
|
106
|
+
const lonAdj = lon + 180
|
|
107
|
+
const latAdj = lat + 90
|
|
108
|
+
const out = [
|
|
109
|
+
String.fromCharCode(A_CODE + Math.floor(lonAdj / 20)),
|
|
110
|
+
String.fromCharCode(A_CODE + Math.floor(latAdj / 10)),
|
|
111
|
+
String(Math.floor((lonAdj % 20) / 2)),
|
|
112
|
+
String(Math.floor(latAdj % 10)),
|
|
113
|
+
String.fromCharCode(A_CODE + Math.floor((lonAdj % 2) * 12)).toLowerCase(),
|
|
114
|
+
String.fromCharCode(A_CODE + Math.floor((latAdj % 1) * 24)).toLowerCase(),
|
|
115
|
+
]
|
|
116
|
+
return out.slice(0, pairs * 2).join("")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const J2000 = 2451545.0
|
|
120
|
+
const unixEpochJulian = 2440587.5
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sunrise / solar-noon / sunset for a coordinate on a date, as UTC epoch seconds, via the standard
|
|
124
|
+
* sunrise equation. `rise` and `set` are absent during polar day or polar night (the sun never
|
|
125
|
+
* crosses the horizon); `noon` (solar transit) is always present.
|
|
126
|
+
*/
|
|
127
|
+
export function sunTimes(
|
|
128
|
+
lat: number,
|
|
129
|
+
lon: number,
|
|
130
|
+
date: Date = new Date()
|
|
131
|
+
): { rise?: number; set?: number; noon: number } {
|
|
132
|
+
const julian = date.getTime() / 86400000 + unixEpochJulian
|
|
133
|
+
const n = Math.round(julian - J2000 - 0.0009 + lon / 360)
|
|
134
|
+
const meanSolarTime = n + 0.0009 - lon / 360
|
|
135
|
+
const M = (357.5291 + 0.98560028 * meanSolarTime) % 360
|
|
136
|
+
const Mr = toRad(M)
|
|
137
|
+
const center = 1.9148 * Math.sin(Mr) + 0.02 * Math.sin(2 * Mr) + 0.0003 * Math.sin(3 * Mr)
|
|
138
|
+
const lambda = toRad((M + center + 180 + 102.9372) % 360)
|
|
139
|
+
const transit = J2000 + meanSolarTime + 0.0053 * Math.sin(Mr) - 0.0069 * Math.sin(2 * lambda)
|
|
140
|
+
const declination = Math.asin(Math.sin(lambda) * Math.sin(toRad(23.4397)))
|
|
141
|
+
const latR = toRad(lat)
|
|
142
|
+
const cosH =
|
|
143
|
+
(Math.sin(toRad(-0.833)) - Math.sin(latR) * Math.sin(declination)) / (Math.cos(latR) * Math.cos(declination))
|
|
144
|
+
const toEpoch = (j: number): number => Math.round((j - unixEpochJulian) * 86400)
|
|
145
|
+
const noon = toEpoch(transit)
|
|
146
|
+
if (cosH >= 1 || cosH <= -1) return { noon }
|
|
147
|
+
const hourAngle = toDeg(Math.acos(cosH))
|
|
148
|
+
return { rise: toEpoch(transit - hourAngle / 360), set: toEpoch(transit + hourAngle / 360), noon }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// MGRS / UTM (WGS84). The forward Transverse Mercator series + the military grid lettering.
|
|
152
|
+
const UTM_A = 6378137.0
|
|
153
|
+
const UTM_F = 1 / 298.257223563
|
|
154
|
+
const UTM_K0 = 0.9996
|
|
155
|
+
const UTM_E2 = UTM_F * (2 - UTM_F)
|
|
156
|
+
const UTM_EP2 = UTM_E2 / (1 - UTM_E2)
|
|
157
|
+
|
|
158
|
+
function latLonToUtm(lat: number, lon: number): { zone: number; easting: number; northing: number } {
|
|
159
|
+
const zone = Math.floor((lon + 180) / 6) + 1
|
|
160
|
+
const lon0 = toRad((zone - 1) * 6 - 180 + 3)
|
|
161
|
+
const phi = toRad(lat)
|
|
162
|
+
const N = UTM_A / Math.sqrt(1 - UTM_E2 * Math.sin(phi) ** 2)
|
|
163
|
+
const T = Math.tan(phi) ** 2
|
|
164
|
+
const C = UTM_EP2 * Math.cos(phi) ** 2
|
|
165
|
+
const A = Math.cos(phi) * (toRad(lon) - lon0)
|
|
166
|
+
const M =
|
|
167
|
+
UTM_A *
|
|
168
|
+
((1 - UTM_E2 / 4 - (3 * UTM_E2 ** 2) / 64 - (5 * UTM_E2 ** 3) / 256) * phi -
|
|
169
|
+
((3 * UTM_E2) / 8 + (3 * UTM_E2 ** 2) / 32 + (45 * UTM_E2 ** 3) / 1024) * Math.sin(2 * phi) +
|
|
170
|
+
((15 * UTM_E2 ** 2) / 256 + (45 * UTM_E2 ** 3) / 1024) * Math.sin(4 * phi) -
|
|
171
|
+
((35 * UTM_E2 ** 3) / 3072) * Math.sin(6 * phi))
|
|
172
|
+
const easting =
|
|
173
|
+
UTM_K0 * N * (A + ((1 - T + C) * A ** 3) / 6 + ((5 - 18 * T + T ** 2 + 72 * C - 58 * UTM_EP2) * A ** 5) / 120) +
|
|
174
|
+
500000
|
|
175
|
+
let northing =
|
|
176
|
+
UTM_K0 *
|
|
177
|
+
(M +
|
|
178
|
+
N *
|
|
179
|
+
Math.tan(phi) *
|
|
180
|
+
(A ** 2 / 2 +
|
|
181
|
+
((5 - T + 9 * C + 4 * C ** 2) * A ** 4) / 24 +
|
|
182
|
+
((61 - 58 * T + T ** 2 + 600 * C - 330 * UTM_EP2) * A ** 6) / 720))
|
|
183
|
+
if (lat < 0) northing += 10000000
|
|
184
|
+
return { zone, easting, northing }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const MGRS_LAT_BANDS = "CDEFGHJKLMNPQRSTUVWX"
|
|
188
|
+
const MGRS_COL_SETS = ["ABCDEFGH", "JKLMNPQR", "STUVWXYZ"]
|
|
189
|
+
const MGRS_ROW_LETTERS = "ABCDEFGHJKLMNPQRSTUV"
|
|
190
|
+
|
|
191
|
+
/** Military Grid Reference System for a coordinate (`"18SUJ2340806479"`); `""` outside MGRS bands
|
|
192
|
+
(±80°/84°). */
|
|
193
|
+
export function toMGRS(lat: number, lon: number): string {
|
|
194
|
+
if (lat < -80 || lat > 84) return ""
|
|
195
|
+
const band = MGRS_LAT_BANDS[Math.floor((lat + 80) / 8)]!
|
|
196
|
+
const { zone, easting, northing } = latLonToUtm(lat, lon)
|
|
197
|
+
const colLetter = MGRS_COL_SETS[(zone - 1) % 3]![Math.floor(easting / 100000) - 1]!
|
|
198
|
+
let row = Math.floor(northing / 100000) % 20
|
|
199
|
+
if (zone % 2 === 0) row = (row + 5) % 20
|
|
200
|
+
const rowLetter = MGRS_ROW_LETTERS[row]!
|
|
201
|
+
const e = String(Math.floor(easting % 100000)).padStart(5, "0")
|
|
202
|
+
const n = String(Math.floor(northing % 100000)).padStart(5, "0")
|
|
203
|
+
return `${zone}${band}${colLetter}${rowLetter}${e}${n}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fill the coordinate-format slice of an {@link AnnotationSet} from a `{lat, lon}`. */
|
|
207
|
+
export const coordinateFormatAnnotator: Annotator = ({ lat, lon, date }): Partial<AnnotationSet> => ({
|
|
208
|
+
dms: toDMS(lat, lon),
|
|
209
|
+
geohash: toGeohash(lat, lon),
|
|
210
|
+
maidenhead: toMaidenhead(lat, lon),
|
|
211
|
+
mgrs: toMGRS(lat, lon) || undefined,
|
|
212
|
+
mercator: toMercator(lat, lon),
|
|
213
|
+
qiblaBearing: qiblaBearing(lat, lon),
|
|
214
|
+
sun: sunTimes(lat, lon, date),
|
|
215
|
+
})
|
package/feature.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { type GeometryLiteral } from "./geometries/index.js"
|
|
9
9
|
|
|
10
10
|
export interface IdentifiableGeoFeature {
|
|
11
|
-
GEOID:
|
|
11
|
+
GEOID: string | number
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -16,7 +16,7 @@ export interface IdentifiableGeoFeature {
|
|
|
16
16
|
*
|
|
17
17
|
* @see https://tools.ietf.org/html/rfc7946#section-3.2
|
|
18
18
|
*/
|
|
19
|
-
export interface GeoFeature<G = GeometryLiteral, P extends object | null =
|
|
19
|
+
export interface GeoFeature<G = GeometryLiteral, P extends object | null = never> {
|
|
20
20
|
/**
|
|
21
21
|
* Declares the type of GeoJSON object as a `Feature`.
|
|
22
22
|
*/
|
|
@@ -31,7 +31,7 @@ export interface GeoFeature<G = GeometryLiteral, P extends object | null = null>
|
|
|
31
31
|
/**
|
|
32
32
|
* A unique identifier for the feature, such as a UUID, a serial number, or a name.
|
|
33
33
|
*/
|
|
34
|
-
id
|
|
34
|
+
id?: P extends IdentifiableGeoFeature ? P["GEOID"] : never
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Additional properties associated with a GeoJSON object.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GeoPoint } from "@mailwoman/spatial"
|
|
8
|
+
import { expect, test } from "vitest"
|
|
9
|
+
|
|
10
|
+
// GeoPoint stores GeoJSON [longitude, latitude(, altitude)] order. `from` recognizes several input
|
|
11
|
+
// shapes and treats a 0/0 coordinate (Null Island) as a "missing coordinate" sentinel → null.
|
|
12
|
+
|
|
13
|
+
test("GeoPoint.from: a 2-tuple is read as GeoJSON [longitude, latitude]", () => {
|
|
14
|
+
// coordA = -74 is in [-90, 90] so inferGeoJSONCoordOrder can't disambiguate; it falls through to
|
|
15
|
+
// the default, leaving the pair as-is: longitude = -74, latitude = 40.7.
|
|
16
|
+
const point = GeoPoint.from([-74.006, 40.7128])!
|
|
17
|
+
|
|
18
|
+
expect(point).not.toBeNull()
|
|
19
|
+
expect(point.longitude).toBe(-74.006)
|
|
20
|
+
expect(point.latitude).toBe(40.7128)
|
|
21
|
+
expect(point.altitude).toBe(0)
|
|
22
|
+
expect(point.is2D()).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("GeoPoint.from: an out-of-lat-range longitude is recognized as longitude", () => {
|
|
26
|
+
// coordA = -118.24 is outside [-90, 90] → unambiguously the longitude; coordB = 34.05 is latitude.
|
|
27
|
+
const point = GeoPoint.from([-118.2437, 34.0522])!
|
|
28
|
+
|
|
29
|
+
expect(point.longitude).toBe(-118.2437)
|
|
30
|
+
expect(point.latitude).toBe(34.0522)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("GeoPoint.from: a 3-tuple is used directly as [longitude, latitude, altitude]", () => {
|
|
34
|
+
const point = GeoPoint.from([-74.006, 40.7128, 125])!
|
|
35
|
+
|
|
36
|
+
expect(point.longitude).toBe(-74.006)
|
|
37
|
+
expect(point.latitude).toBe(40.7128)
|
|
38
|
+
expect(point.altitude).toBe(125)
|
|
39
|
+
expect(point.is3D()).toBe(true)
|
|
40
|
+
expect(point.coordinates).toEqual([-74.006, 40.7128, 125])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("GeoPoint.from: a PointLiteral copies coordinates verbatim (no axis inference)", () => {
|
|
44
|
+
const point = GeoPoint.from({ type: "Point", coordinates: [-74.006, 40.7128] })!
|
|
45
|
+
|
|
46
|
+
expect(point.type).toBe("Point")
|
|
47
|
+
expect(point.longitude).toBe(-74.006)
|
|
48
|
+
expect(point.latitude).toBe(40.7128)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("GeoPoint.from: a Google Maps LatLngLiteral maps lng→longitude, lat→latitude", () => {
|
|
52
|
+
const point = GeoPoint.from({ lat: 40.7128, lng: -74.006 })!
|
|
53
|
+
|
|
54
|
+
expect(point.longitude).toBe(-74.006)
|
|
55
|
+
expect(point.latitude).toBe(40.7128)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("GeoPoint.from: a GeolocationCoordinates-like object carries altitude through", () => {
|
|
59
|
+
const point = GeoPoint.from({ latitude: 48.8566, longitude: 2.3522, altitude: 35 })!
|
|
60
|
+
|
|
61
|
+
expect(point.longitude).toBe(2.3522)
|
|
62
|
+
expect(point.latitude).toBe(48.8566)
|
|
63
|
+
expect(point.altitude).toBe(35)
|
|
64
|
+
expect(point.is3D()).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("GeoPoint.from: internal {x, y} coordinates map x→longitude, y→latitude", () => {
|
|
68
|
+
const point = GeoPoint.from({ x: -74.006, y: 40.7128 })!
|
|
69
|
+
|
|
70
|
+
expect(point.longitude).toBe(-74.006)
|
|
71
|
+
expect(point.latitude).toBe(40.7128)
|
|
72
|
+
expect(point.altitude).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("GeoPoint.from: a bracketless coordinate string is parsed into a pair", () => {
|
|
76
|
+
// "-74.006,40.7128" isn't valid JSON, so `from` retries as "[-74.006,40.7128]".
|
|
77
|
+
const point = GeoPoint.from("-74.006,40.7128")!
|
|
78
|
+
|
|
79
|
+
expect(point).not.toBeNull()
|
|
80
|
+
expect(point.longitude).toBe(-74.006)
|
|
81
|
+
expect(point.latitude).toBe(40.7128)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("GeoPoint.from: a JSON array string is parsed", () => {
|
|
85
|
+
const point = GeoPoint.from("[2.3522, 48.8566]")!
|
|
86
|
+
|
|
87
|
+
expect(point.longitude).toBe(2.3522)
|
|
88
|
+
expect(point.latitude).toBe(48.8566)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("GeoPoint.from: an existing GeoPoint is returned unchanged", () => {
|
|
92
|
+
const original = GeoPoint.from([12.4924, 41.8902])!
|
|
93
|
+
const passed = GeoPoint.from(original)
|
|
94
|
+
|
|
95
|
+
expect(passed).toBe(original)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("GeoPoint.from: the 0/0 (Null Island) sentinel resolves to null", () => {
|
|
99
|
+
expect(GeoPoint.from([0, 0])).toBeNull()
|
|
100
|
+
expect(GeoPoint.from({ type: "Point", coordinates: [0, 0] })).toBeNull()
|
|
101
|
+
expect(GeoPoint.from({ lat: 0, lng: 0 })).toBeNull()
|
|
102
|
+
expect(GeoPoint.from({ x: 0, y: 0 })).toBeNull()
|
|
103
|
+
expect(GeoPoint.from("0,0")).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("GeoPoint.from: falsy and unparseable input resolves to null", () => {
|
|
107
|
+
expect(GeoPoint.from(null)).toBeNull()
|
|
108
|
+
expect(GeoPoint.from(undefined)).toBeNull()
|
|
109
|
+
expect(GeoPoint.from("")).toBeNull()
|
|
110
|
+
expect(GeoPoint.from(0)).toBeNull()
|
|
111
|
+
// A garbage string that is neither valid JSON nor a wrappable pair falls back to the default 0/0
|
|
112
|
+
// coordinate, which the Null-Island sentinel then rejects.
|
|
113
|
+
expect(GeoPoint.from("not-a-coordinate")).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("GeoPoint.from: a non-zero point that is NOT Null Island survives", () => {
|
|
117
|
+
// Guards against an over-eager sentinel: a real coordinate near, but not at, the origin.
|
|
118
|
+
const point = GeoPoint.from([0.0001, 0.0001])!
|
|
119
|
+
|
|
120
|
+
expect(point).not.toBeNull()
|
|
121
|
+
expect(point.isNullIsland()).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("GeoPoint: longitude wraps and latitude clamps on assignment", () => {
|
|
125
|
+
const point = new GeoPoint([0, 0])
|
|
126
|
+
|
|
127
|
+
point.longitude = 190 // 190 wraps to -170
|
|
128
|
+
point.latitude = 100 // clamped to the north pole
|
|
129
|
+
|
|
130
|
+
expect(point.longitude).toBe(-170)
|
|
131
|
+
expect(point.latitude).toBe(90)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("GeoPoint is iterable, yielding its coordinate tuple", () => {
|
|
135
|
+
const point = GeoPoint.from([-74.006, 40.7128, 5])!
|
|
136
|
+
|
|
137
|
+
expect([...point]).toEqual([-74.006, 40.7128, 5])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("GeoPoint.toJSON emits a GeoJSON Point literal", () => {
|
|
141
|
+
const point = GeoPoint.from([-74.006, 40.7128])!
|
|
142
|
+
|
|
143
|
+
expect(point.toJSON()).toEqual({ type: "Point", coordinates: [-74.006, 40.7128] })
|
|
144
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "vitest"
|
|
8
|
+
import {
|
|
9
|
+
isPolygonLiteral,
|
|
10
|
+
isResidentialElement,
|
|
11
|
+
isSolidPolygonPath,
|
|
12
|
+
polygonToOSMFilter,
|
|
13
|
+
type OSMOverpassElement,
|
|
14
|
+
type PolygonLiteral,
|
|
15
|
+
} from "./polygon.js"
|
|
16
|
+
|
|
17
|
+
const SOLID: PolygonLiteral = {
|
|
18
|
+
type: "Polygon",
|
|
19
|
+
coordinates: [
|
|
20
|
+
[
|
|
21
|
+
[100, 0],
|
|
22
|
+
[101, 0],
|
|
23
|
+
[101, 1],
|
|
24
|
+
[100, 1],
|
|
25
|
+
[100, 0],
|
|
26
|
+
],
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("isPolygonLiteral: only a {type:'Polygon', coordinates: []} object qualifies", () => {
|
|
31
|
+
expect(isPolygonLiteral(SOLID)).toBe(true)
|
|
32
|
+
expect(isPolygonLiteral({ type: "Point", coordinates: [0, 0] })).toBe(false)
|
|
33
|
+
expect(isPolygonLiteral({ type: "Polygon" })).toBe(false) // no coordinates
|
|
34
|
+
expect(isPolygonLiteral(null)).toBe(false)
|
|
35
|
+
expect(isPolygonLiteral("Polygon")).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("isSolidPolygonPath: one ring = solid, more rings = has holes", () => {
|
|
39
|
+
expect(isSolidPolygonPath(SOLID)).toBe(true)
|
|
40
|
+
const withHole = {
|
|
41
|
+
type: "Polygon",
|
|
42
|
+
coordinates: [SOLID.coordinates[0], SOLID.coordinates[0]],
|
|
43
|
+
} as unknown as PolygonLiteral
|
|
44
|
+
expect(isSolidPolygonPath(withHole)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("polygonToOSMFilter: emits the exterior ring as Overpass 'lat lon' pairs (NOT GeoJSON lon,lat)", () => {
|
|
48
|
+
// GeoJSON positions are [lon, lat]; Overpass wants "lat lon" — this swap is the foot-gun.
|
|
49
|
+
expect(polygonToOSMFilter(SOLID)).toBe("poly:'0 100 0 101 1 101 1 100 0 100'")
|
|
50
|
+
expect(polygonToOSMFilter({ type: "Point" } as unknown as PolygonLiteral)).toBe("") // non-polygon → empty
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("isResidentialElement: rejects commercial tags + restaurants, accepts a plain address node", () => {
|
|
54
|
+
const el = (tags: Record<string, string>): OSMOverpassElement =>
|
|
55
|
+
({ type: "node", id: 1, lat: 0, lon: 0, tags }) as unknown as OSMOverpassElement
|
|
56
|
+
|
|
57
|
+
expect(isResidentialElement(el({ "addr:housenumber": "5", "addr:street": "Main" }))).toBe(true)
|
|
58
|
+
expect(isResidentialElement(el({ shop: "bakery" }))).toBe(false) // forbidden commercial tag
|
|
59
|
+
expect(isResidentialElement(el({ office: "company" }))).toBe(false)
|
|
60
|
+
expect(isResidentialElement(el({ amenity: "restaurant" }))).toBe(false) // restaurant special-case
|
|
61
|
+
expect(isResidentialElement(el({ amenity: "bench" }))).toBe(true) // a non-forbidden amenity is fine
|
|
62
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GOOGLE_PLACE_ID_PATTERN, isGooglePlaceID } from "@mailwoman/spatial"
|
|
8
|
+
import { expect, test } from "vitest"
|
|
9
|
+
|
|
10
|
+
// A Google Place ID is a non-empty string of base64url-style characters: letters, digits, "_", "-".
|
|
11
|
+
// The pattern is anchored end-to-end, so any other character (or an empty string) is rejected.
|
|
12
|
+
|
|
13
|
+
test("isGooglePlaceID: a real 27-char Place ID is accepted", () => {
|
|
14
|
+
// Google's documented example ID for the Sydney Opera House.
|
|
15
|
+
expect(isGooglePlaceID("ChIJN1t_tDeuEmsRUsoyG83frY4")).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("isGooglePlaceID: underscores and dashes are valid characters", () => {
|
|
19
|
+
expect(isGooglePlaceID("a_b-c_D-9")).toBe(true)
|
|
20
|
+
expect(isGooglePlaceID("____")).toBe(true)
|
|
21
|
+
expect(isGooglePlaceID("----")).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("isGooglePlaceID: alphanumerics of any length are accepted", () => {
|
|
25
|
+
expect(isGooglePlaceID("a")).toBe(true)
|
|
26
|
+
expect(isGooglePlaceID("ABC123xyz")).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("isGooglePlaceID: an empty string is rejected (minLength 1)", () => {
|
|
30
|
+
expect(isGooglePlaceID("")).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("isGooglePlaceID: characters outside [A-Za-z0-9_-] are rejected", () => {
|
|
34
|
+
expect(isGooglePlaceID("has space")).toBe(false)
|
|
35
|
+
expect(isGooglePlaceID("has.dot")).toBe(false)
|
|
36
|
+
expect(isGooglePlaceID("plus+sign")).toBe(false)
|
|
37
|
+
expect(isGooglePlaceID("slash/here")).toBe(false)
|
|
38
|
+
expect(isGooglePlaceID("equals=pad")).toBe(false)
|
|
39
|
+
expect(isGooglePlaceID("emoji😀")).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("isGooglePlaceID: the pattern is fully anchored (a bad char anywhere fails)", () => {
|
|
43
|
+
// A leading or trailing invalid character must fail even when the rest is valid — proving the
|
|
44
|
+
// regex is anchored at both ends rather than merely "contains a valid run".
|
|
45
|
+
expect(isGooglePlaceID(" ChIJN1t_tDeuEmsRUsoyG83frY4")).toBe(false)
|
|
46
|
+
expect(isGooglePlaceID("ChIJN1t_tDeuEmsRUsoyG83frY4 ")).toBe(false)
|
|
47
|
+
expect(isGooglePlaceID("valid\ninvalid")).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("GOOGLE_PLACE_ID_PATTERN is the anchored character-class pattern", () => {
|
|
51
|
+
expect(GOOGLE_PLACE_ID_PATTERN.source).toBe("^[A-Za-z0-9_-]+$")
|
|
52
|
+
expect(GOOGLE_PLACE_ID_PATTERN.test("ChIJN1t_tDeuEmsRUsoyG83frY4")).toBe(true)
|
|
53
|
+
expect(GOOGLE_PLACE_ID_PATTERN.test("bad char")).toBe(false)
|
|
54
|
+
})
|
package/index.ts
CHANGED
package/out/bbox.d.ts
CHANGED
|
@@ -96,14 +96,14 @@ export type BBox3DLiteral = [
|
|
|
96
96
|
maxAltitude: number
|
|
97
97
|
];
|
|
98
98
|
/**
|
|
99
|
-
* Type-predicate for
|
|
99
|
+
* Type-predicate for 2-dimensional bounding boxes (`[west, south, east, north]`).
|
|
100
100
|
*
|
|
101
101
|
* This is useful when determining the type of bounding box in a GeoJSON object.
|
|
102
102
|
*
|
|
103
103
|
* @category GeoJSON
|
|
104
104
|
* @category Bounding Box
|
|
105
105
|
*/
|
|
106
|
-
export declare function is2DBBox(input: unknown): input is
|
|
106
|
+
export declare function is2DBBox(input: unknown): input is BBox2DLiteral;
|
|
107
107
|
/**
|
|
108
108
|
* Type-predicate for 3-dimensional bounding boxes.
|
|
109
109
|
*
|