@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 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 3-dimensional bounding boxes.
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 BBox3DLiteral {
120
- return Array.isArray(input) && input.length === 6
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: any
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 = 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: P extends IdentifiableGeoFeature ? P["GEOID"] : string | number | undefined | null
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
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  export * from "./bbox.js"
8
+ export * from "./coordinate-formats.js"
8
9
  export * from "./countries/index.js"
9
10
  export * from "./feature.js"
10
11
  export * from "./geometries/index.js"
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 3-dimensional bounding boxes.
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 BBox3DLiteral;
106
+ export declare function is2DBBox(input: unknown): input is BBox2DLiteral;
107
107
  /**
108
108
  * Type-predicate for 3-dimensional bounding boxes.
109
109
  *