@mailwoman/spatial 4.15.0 → 4.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/spatial",
3
- "version": "4.15.0",
3
+ "version": "4.16.1",
4
4
  "description": "Spatial analysis, geocoding, and other geo-related utilities.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "contributors": [
@@ -22,13 +22,14 @@
22
22
  ".": "./out/index.js"
23
23
  },
24
24
  "dependencies": {
25
- "@mailwoman/core": "4.15.0",
25
+ "@mailwoman/core": "4.16.1",
26
26
  "geo-coordinates-parser": "^1.7.4",
27
27
  "h3-js": "^4.4.0",
28
28
  "wkx": "^0.5.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@googlemaps/google-maps-services-js": "^3.4.2",
32
+ "@mailwoman/annotations": "4.16.1",
32
33
  "@types/google.maps": "^3.65.1",
33
34
  "type-fest": "^5.7.0"
34
35
  },
@@ -37,7 +38,7 @@
37
38
  "@types/google.maps": "^3.65.1"
38
39
  },
39
40
  "engines": {
40
- "node": ">=22.5.1"
41
+ "node": ">=24.18.0"
41
42
  },
42
43
  "keywords": [
43
44
  "geo",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ import { haversineKm } from "@mailwoman/spatial"
8
+ import { expect, test } from "vitest"
9
+
10
+ // Earth mean radius the formula uses (RADII.km). Reference distances below are derived from it, not
11
+ // looked up — so they pin the exact constant + formula, not an approximation.
12
+ const R = 6371
13
+
14
+ test("haversineKm: a point to itself is zero", () => {
15
+ expect(haversineKm(40.7128, -74.006, 40.7128, -74.006)).toBe(0)
16
+ })
17
+
18
+ test("haversineKm: one degree along the equator / a meridian is R·(π/180)", () => {
19
+ const oneDegree = (R * Math.PI) / 180 // ≈ 111.195 km
20
+ expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(oneDegree, 3) // 1° longitude at the equator
21
+ expect(haversineKm(0, 0, 1, 0)).toBeCloseTo(oneDegree, 3) // 1° latitude along a meridian
22
+ })
23
+
24
+ test("haversineKm: antipodal points are half the great circle (R·π)", () => {
25
+ expect(haversineKm(0, 0, 0, 180)).toBeCloseTo(R * Math.PI, 2) // ≈ 20015.09 km
26
+ })
27
+
28
+ test("haversineKm: a real-world pair lands in the right ballpark (NYC ↔ LA)", () => {
29
+ const d = haversineKm(40.7128, -74.006, 34.0522, -118.2437)
30
+ expect(d).toBeGreaterThan(3900)
31
+ expect(d).toBeLessThan(3970) // ~3936 km
32
+ })
33
+
34
+ test("haversineKm: symmetric in its arguments", () => {
35
+ const ab = haversineKm(51.5074, -0.1278, 48.8566, 2.3522) // London → Paris
36
+ const ba = haversineKm(48.8566, 2.3522, 51.5074, -0.1278)
37
+ expect(ab).toBeCloseTo(ba, 10)
38
+ expect(ab).toBeGreaterThan(330)
39
+ expect(ab).toBeLessThan(355) // ~343 km
40
+ })
41
+
42
+ test("haversineKm: (0,0) is a real point (Gulf of Guinea), not a missing-coordinate sentinel", () => {
43
+ // Unlike the object-form `haversine`, the raw-scalar form has no Null-Island sentinel — 0/0 is a
44
+ // real coordinate, so this returns a finite distance rather than NaN.
45
+ const d = haversineKm(0, 0, 0.5, 0.5)
46
+ expect(Number.isNaN(d)).toBe(false)
47
+ expect(d).toBeGreaterThan(0)
48
+ })
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ import type { GeometryLiteral } from "@mailwoman/spatial"
8
+ import { expect, test } from "vitest"
9
+ import wkx from "wkx"
10
+ import {
11
+ geometryToEWKB,
12
+ geometryToEWKH,
13
+ geometryToSQL,
14
+ geometryToWKB,
15
+ geometryToWKT,
16
+ wellKnownGeometryToGeoJSON,
17
+ } from "./well-known-text.js"
18
+
19
+ const POINT: GeometryLiteral = { type: "Point", coordinates: [30, 10] }
20
+
21
+ // A closed exterior ring (last vertex repeats the first), as GeoJSON requires.
22
+ const POLYGON: GeometryLiteral = {
23
+ type: "Polygon",
24
+ coordinates: [
25
+ [
26
+ [30, 10],
27
+ [40, 40],
28
+ [20, 40],
29
+ [10, 20],
30
+ [30, 10],
31
+ ],
32
+ ],
33
+ }
34
+
35
+ // The 4-level MultiPolygon literal doesn't narrow against the GeometryLiteral union; cast it.
36
+ const MULTIPOLYGON = {
37
+ type: "MultiPolygon",
38
+ coordinates: [
39
+ [
40
+ [
41
+ [30, 20],
42
+ [45, 40],
43
+ [10, 40],
44
+ [30, 20],
45
+ ],
46
+ ],
47
+ [
48
+ [
49
+ [15, 5],
50
+ [40, 10],
51
+ [10, 20],
52
+ [5, 10],
53
+ [15, 5],
54
+ ],
55
+ ],
56
+ ],
57
+ } as unknown as GeometryLiteral
58
+
59
+ test("wellKnownGeometryToGeoJSON: parses a WKT POINT into exact GeoJSON coordinates", () => {
60
+ const geo = wellKnownGeometryToGeoJSON<GeometryLiteral>("POINT(30 10)")
61
+ expect(geo).toEqual(POINT)
62
+ })
63
+
64
+ test("wellKnownGeometryToGeoJSON: parses a WKT POLYGON, preserving ring order and closure", () => {
65
+ const geo = wellKnownGeometryToGeoJSON<GeometryLiteral>("POLYGON((30 10,40 40,20 40,10 20,30 10))")
66
+ expect(geo).toEqual(POLYGON)
67
+ })
68
+
69
+ test("wellKnownGeometryToGeoJSON: parses a WKT MULTIPOLYGON into two distinct polygons", () => {
70
+ const geo = wellKnownGeometryToGeoJSON<GeometryLiteral>(
71
+ "MULTIPOLYGON(((30 20,45 40,10 40,30 20)),((15 5,40 10,10 20,5 10,15 5)))"
72
+ )
73
+ expect(geo).toEqual(MULTIPOLYGON)
74
+ })
75
+
76
+ test("wellKnownGeometryToGeoJSON: parses an EWKB buffer back into the source geometry", () => {
77
+ // Produce a known EWKB buffer, then round-trip it through the parser.
78
+ const ewkb = wkx.Geometry.parseGeoJSON(POINT).toEwkb()
79
+ const geo = wellKnownGeometryToGeoJSON<GeometryLiteral>(ewkb)
80
+ expect(geo).toEqual(POINT)
81
+ })
82
+
83
+ test("wellKnownGeometryToGeoJSON: throws on malformed WKT", () => {
84
+ expect(() => wellKnownGeometryToGeoJSON("NOT A GEOMETRY")).toThrow()
85
+ })
86
+
87
+ test("geometryToWKT: serializes GeoJSON back to its canonical WKT string", () => {
88
+ expect(geometryToWKT(POINT)).toBe("POINT(30 10)")
89
+ expect(geometryToWKT(POLYGON)).toBe("POLYGON((30 10,40 40,20 40,10 20,30 10))")
90
+ expect(geometryToWKT(MULTIPOLYGON)).toBe("MULTIPOLYGON(((30 20,45 40,10 40,30 20)),((15 5,40 10,10 20,5 10,15 5)))")
91
+ })
92
+
93
+ test("geometryToWKT ↔ wellKnownGeometryToGeoJSON: lossless round-trip for a polygon", () => {
94
+ const wkt = geometryToWKT(POLYGON)
95
+ const back = wellKnownGeometryToGeoJSON<GeometryLiteral>(wkt)
96
+ expect(back).toEqual(POLYGON)
97
+ })
98
+
99
+ test("geometryToWKB: produces the exact little-endian WKB byte string for a POINT", () => {
100
+ // 01 (LE) | 01000000 (type=Point) | 30 as float64 LE | 10 as float64 LE
101
+ const wkb = geometryToWKB(POINT)
102
+ expect(wkb.toString("hex")).toBe("01010000000000000000003e400000000000002440")
103
+ })
104
+
105
+ test("geometryToWKB: round-trips through the parser back to source GeoJSON", () => {
106
+ const wkb = geometryToWKB(MULTIPOLYGON)
107
+ const back = wkx.Geometry.parse(wkb).toGeoJSON()
108
+ expect(back).toEqual(MULTIPOLYGON)
109
+ })
110
+
111
+ test("geometryToEWKB: tags the geometry with the SRID-flag the plain WKB lacks", () => {
112
+ // EWKB sets the 0x20000000 SRID flag on the type word, so the hex differs from plain WKB.
113
+ const ewkb = geometryToEWKB(POINT)
114
+ expect(ewkb.toString("hex")).toBe("0101000020e61000000000000000003e400000000000002440")
115
+ // Distinct from plain WKB.
116
+ expect(ewkb.toString("hex")).not.toBe(geometryToWKB(POINT).toString("hex"))
117
+ })
118
+
119
+ test("geometryToEWKH: is the hex-encoded form of the EWKB buffer", () => {
120
+ expect(geometryToEWKH(POINT)).toBe(geometryToEWKB(POINT).toString("hex"))
121
+ expect(geometryToEWKH(POINT)).toBe("0101000020e61000000000000000003e400000000000002440")
122
+ })
123
+
124
+ test("geometryToSQL: returns a thunk emitting a GeomFromEWKB literal for a real geometry", () => {
125
+ const thunk = geometryToSQL(POINT)
126
+ expect(typeof thunk).toBe("function")
127
+ expect(thunk()).toBe(`GeomFromEWKB('${geometryToEWKH(POINT)}')`)
128
+ })
129
+
130
+ test("geometryToSQL: null/undefined geometry yields a thunk that emits the SQL literal NULL", () => {
131
+ expect(geometryToSQL(null)()).toBe("NULL")
132
+ expect(geometryToSQL(undefined)()).toBe("NULL")
133
+ })
package/tsconfig.json CHANGED
@@ -7,9 +7,10 @@
7
7
  "emitDeclarationOnly": false,
8
8
  "allowImportingTsExtensions": false
9
9
  },
10
- "exclude": ["./out/**/*"],
10
+ "exclude": ["./out/**/*", "./**/*.test.ts", "./**/*.test.tsx"],
11
11
  "references": [
12
12
  // ---
13
+ { "path": "../annotations" },
13
14
  { "path": "../core" }
14
15
  ]
15
16
  }