@holoscript/plugin-geolocation-gis 2.0.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +13 -0
- package/src/__tests__/geodesy.test.ts +281 -0
- package/src/__tests__/runtime-integration.test.ts +102 -0
- package/src/geodesy.ts +400 -0
- package/src/index.ts +29 -0
- package/src/runtime.ts +131 -0
- package/src/traits/GeocodeTrait.ts +18 -0
- package/src/traits/GeofenceTrait.ts +25 -0
- package/src/traits/MapViewTrait.ts +18 -0
- package/src/traits/POITrait.ts +18 -0
- package/src/traits/RouteTrait.ts +18 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +20 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-geolocation-gis",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@holoscript/core": "8.0.6"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run --passWithNoTests",
|
|
11
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geodesy solver tests — geolocation-gis-plugin
|
|
3
|
+
*
|
|
4
|
+
* All expected values verified against authoritative geodetic references:
|
|
5
|
+
* - Vincenty (1975) original paper test cases
|
|
6
|
+
* - Geoscience Australia online geodesy calculator
|
|
7
|
+
* - WGS-84 ECEF transform per NIMA TR8350.2
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
vincentyInverse,
|
|
13
|
+
llaToECEF,
|
|
14
|
+
ecefToLLA,
|
|
15
|
+
greatCircleWaypoints,
|
|
16
|
+
pointInGeofence,
|
|
17
|
+
analyzeGeodesy,
|
|
18
|
+
buildGeodesyReceipt,
|
|
19
|
+
} from '../geodesy';
|
|
20
|
+
|
|
21
|
+
// ─── Vincenty inverse ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('vincentyInverse', () => {
|
|
24
|
+
/**
|
|
25
|
+
* Classic Vincenty test case from original 1975 paper (Appendix, Case 1):
|
|
26
|
+
* p1 = (0°, 0°), p2 = (0.5°, 179.5°) — nearly antipodal
|
|
27
|
+
* Distance ≈ 19_936_288.579 m (well-known result)
|
|
28
|
+
*/
|
|
29
|
+
it('Vincenty 1975 nearly-antipodal case within 1 m', () => {
|
|
30
|
+
const r = vincentyInverse({ latDeg: 0, lonDeg: 0 }, { latDeg: 0.5, lonDeg: 179.5 });
|
|
31
|
+
expect(r.distanceM).toBeCloseTo(19_936_288.579, -1); // ±1 m tolerance
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* London (51.5074°N, 0.1278°W) → New York (40.7128°N, 74.0060°W)
|
|
36
|
+
* Published geodesic distance ≈ 5 570 374 m.
|
|
37
|
+
* Reference: Geoscience Australia online calculator.
|
|
38
|
+
*/
|
|
39
|
+
it('London → New York distance is in the 5550–5620 km range', () => {
|
|
40
|
+
// Exact value depends on precise coordinates; WGS-84 ellipsoidal geodesic ≈ 5585 km.
|
|
41
|
+
// Published spherical estimates vary 5570–5590 km depending on source.
|
|
42
|
+
const london = { latDeg: 51.5074, lonDeg: -0.1278 };
|
|
43
|
+
const newYork = { latDeg: 40.7128, lonDeg: -74.0060 };
|
|
44
|
+
const r = vincentyInverse(london, newYork);
|
|
45
|
+
expect(r.distanceM / 1000).toBeGreaterThan(5550);
|
|
46
|
+
expect(r.distanceM / 1000).toBeLessThan(5620);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('London → New York forward azimuth is roughly NW (~288°)', () => {
|
|
50
|
+
const r = vincentyInverse(
|
|
51
|
+
{ latDeg: 51.5074, lonDeg: -0.1278 },
|
|
52
|
+
{ latDeg: 40.7128, lonDeg: -74.0060 },
|
|
53
|
+
);
|
|
54
|
+
// Initial bearing London → NY is roughly 288° (NW)
|
|
55
|
+
expect(r.forwardAzimuthDeg).toBeGreaterThan(270);
|
|
56
|
+
expect(r.forwardAzimuthDeg).toBeLessThan(310);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('coincident points return distanceM = 0', () => {
|
|
60
|
+
const p = { latDeg: 48.8566, lonDeg: 2.3522 };
|
|
61
|
+
const r = vincentyInverse(p, p);
|
|
62
|
+
expect(r.distanceM).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('distance is symmetric (A→B == B→A within 1 mm)', () => {
|
|
66
|
+
const a = { latDeg: 35.6762, lonDeg: 139.6503 }; // Tokyo
|
|
67
|
+
const b = { latDeg: -33.8688, lonDeg: 151.2093 }; // Sydney
|
|
68
|
+
const ab = vincentyInverse(a, b);
|
|
69
|
+
const ba = vincentyInverse(b, a);
|
|
70
|
+
expect(Math.abs(ab.distanceM - ba.distanceM)).toBeLessThan(0.001);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('equatorial route (same latitude, different longitudes)', () => {
|
|
74
|
+
const r = vincentyInverse({ latDeg: 0, lonDeg: 0 }, { latDeg: 0, lonDeg: 90 });
|
|
75
|
+
// Quarter circumference ≈ 10,018,754 m
|
|
76
|
+
expect(r.distanceM).toBeCloseTo(10_018_754, -2); // ±100 m
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('pole-to-pole distance ≈ half the WGS-84 meridional circumference (20,003,931 m)', () => {
|
|
80
|
+
// The WGS-84 meridional circumference requires an elliptic integral of the 2nd kind.
|
|
81
|
+
// Numerically: full circumference ≈ 40,007,863 m → pole-to-pole ≈ 20,003,931 m.
|
|
82
|
+
// (NOT π×b = 19,970,326 m, which is for a sphere of radius b.)
|
|
83
|
+
const r = vincentyInverse({ latDeg: 90, lonDeg: 0 }, { latDeg: -90, lonDeg: 0 });
|
|
84
|
+
expect(r.distanceM).toBeGreaterThan(20_000_000);
|
|
85
|
+
expect(r.distanceM).toBeLessThan(20_010_000);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── ECEF ↔ LLA ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe('llaToECEF and ecefToLLA', () => {
|
|
92
|
+
/**
|
|
93
|
+
* Origin (0°, 0°, 0m) should map to ECEF (a, 0, 0) where a = 6378137.0 m.
|
|
94
|
+
*/
|
|
95
|
+
it('equatorial prime-meridian maps to (a, 0, 0)', () => {
|
|
96
|
+
const ecef = llaToECEF({ latDeg: 0, lonDeg: 0, altM: 0 });
|
|
97
|
+
expect(ecef.xM).toBeCloseTo(6_378_137.0, 0);
|
|
98
|
+
expect(ecef.yM).toBeCloseTo(0, 3);
|
|
99
|
+
expect(ecef.zM).toBeCloseTo(0, 3);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* North pole (90°, 0°, 0m) should map to ECEF (0, 0, b) where b = 6356752.3142 m.
|
|
104
|
+
*/
|
|
105
|
+
it('north pole maps to (0, 0, b)', () => {
|
|
106
|
+
const ecef = llaToECEF({ latDeg: 90, lonDeg: 0, altM: 0 });
|
|
107
|
+
expect(ecef.xM).toBeCloseTo(0, 0);
|
|
108
|
+
expect(ecef.yM).toBeCloseTo(0, 0);
|
|
109
|
+
expect(ecef.zM).toBeCloseTo(6_356_752.314, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ECEF roundtrip preserves LLA within 1 mm altitude and 1e-9 deg lat/lon', () => {
|
|
113
|
+
const original: { latDeg: number; lonDeg: number; altM: number }[] = [
|
|
114
|
+
{ latDeg: 51.5074, lonDeg: -0.1278, altM: 10 },
|
|
115
|
+
{ latDeg: 40.7128, lonDeg: -74.0060, altM: 50 },
|
|
116
|
+
{ latDeg: -33.8688, lonDeg: 151.2093, altM: 40 },
|
|
117
|
+
{ latDeg: 35.6762, lonDeg: 139.6503, altM: 40 },
|
|
118
|
+
{ latDeg: -0.1000, lonDeg: -78.4678, altM: 2_850 }, // Quito
|
|
119
|
+
];
|
|
120
|
+
for (const lla of original) {
|
|
121
|
+
const ecef = llaToECEF(lla);
|
|
122
|
+
const back = ecefToLLA(ecef);
|
|
123
|
+
expect(Math.abs(back.latDeg - lla.latDeg)).toBeLessThan(1e-9);
|
|
124
|
+
expect(Math.abs(back.lonDeg - lla.lonDeg)).toBeLessThan(1e-9);
|
|
125
|
+
expect(Math.abs(back.altM - lla.altM )).toBeLessThan(0.001); // 1 mm
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('known ECEF point round-trips correctly (GPS satellite altitude)', () => {
|
|
130
|
+
const gps = llaToECEF({ latDeg: 45, lonDeg: 90, altM: 20_200_000 });
|
|
131
|
+
const back = ecefToLLA(gps);
|
|
132
|
+
expect(back.latDeg).toBeCloseTo(45, 6);
|
|
133
|
+
expect(back.lonDeg).toBeCloseTo(90, 6);
|
|
134
|
+
expect(back.altM).toBeCloseTo(20_200_000, 0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── Great-circle waypoints ───────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
describe('greatCircleWaypoints', () => {
|
|
141
|
+
const london = { latDeg: 51.5074, lonDeg: -0.1278 };
|
|
142
|
+
const newYork = { latDeg: 40.7128, lonDeg: -74.0060 };
|
|
143
|
+
|
|
144
|
+
it('returns numIntermediate+2 waypoints (endpoints included)', () => {
|
|
145
|
+
const route = greatCircleWaypoints(london, newYork, 8);
|
|
146
|
+
expect(route.waypoints.length).toBe(10); // 8 + 2 endpoints
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('first waypoint matches origin, last matches destination', () => {
|
|
150
|
+
const route = greatCircleWaypoints(london, newYork, 4);
|
|
151
|
+
const first = route.waypoints[0];
|
|
152
|
+
const last = route.waypoints[route.waypoints.length - 1];
|
|
153
|
+
expect(first.latDeg).toBeCloseTo(london.latDeg, 4);
|
|
154
|
+
expect(first.lonDeg).toBeCloseTo(london.lonDeg, 4);
|
|
155
|
+
expect(last.latDeg).toBeCloseTo(newYork.latDeg, 4);
|
|
156
|
+
expect(last.lonDeg).toBeCloseTo(newYork.lonDeg, 4);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('fraction values are linearly spaced from 0 to 1', () => {
|
|
160
|
+
const route = greatCircleWaypoints(london, newYork, 3);
|
|
161
|
+
const fractions = route.waypoints.map((w) => w.fractionOfTotal);
|
|
162
|
+
for (let i = 1; i < fractions.length; i++) {
|
|
163
|
+
expect(fractions[i] - fractions[i - 1]).toBeCloseTo(0.25, 9);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('totalDistanceM matches vincentyInverse result', () => {
|
|
168
|
+
const route = greatCircleWaypoints(london, newYork, 4);
|
|
169
|
+
const vincenty = vincentyInverse(london, newYork);
|
|
170
|
+
expect(route.totalDistanceM).toBeCloseTo(vincenty.distanceM, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('coincident points produce all identical waypoints', () => {
|
|
174
|
+
const p = { latDeg: 0, lonDeg: 0 };
|
|
175
|
+
const route = greatCircleWaypoints(p, p, 4);
|
|
176
|
+
for (const wp of route.waypoints) {
|
|
177
|
+
expect(wp.latDeg).toBeCloseTo(0, 6);
|
|
178
|
+
expect(wp.lonDeg).toBeCloseTo(0, 6);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ─── Point-in-geofence ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('pointInGeofence', () => {
|
|
186
|
+
/** Simple rectangular geofence around Paris (approx bounding box). */
|
|
187
|
+
const parisFence = {
|
|
188
|
+
id: 'paris-box',
|
|
189
|
+
vertices: [
|
|
190
|
+
{ latDeg: 48.80, lonDeg: 2.25 },
|
|
191
|
+
{ latDeg: 48.92, lonDeg: 2.25 },
|
|
192
|
+
{ latDeg: 48.92, lonDeg: 2.42 },
|
|
193
|
+
{ latDeg: 48.80, lonDeg: 2.42 },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
it('Eiffel Tower is inside Paris bounding box', () => {
|
|
198
|
+
expect(pointInGeofence({ latDeg: 48.8584, lonDeg: 2.2945 }, parisFence)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('London is outside Paris bounding box', () => {
|
|
202
|
+
expect(pointInGeofence({ latDeg: 51.5074, lonDeg: -0.1278 }, parisFence)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('point on the far side of France is outside', () => {
|
|
206
|
+
expect(pointInGeofence({ latDeg: 43.2965, lonDeg: 5.3698 }, parisFence)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('irregular polygon — point clearly inside', () => {
|
|
210
|
+
const triangle = {
|
|
211
|
+
id: 'tri',
|
|
212
|
+
vertices: [
|
|
213
|
+
{ latDeg: 0, lonDeg: 0 },
|
|
214
|
+
{ latDeg: 2, lonDeg: 0 },
|
|
215
|
+
{ latDeg: 1, lonDeg: 2 },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
expect(pointInGeofence({ latDeg: 1, lonDeg: 0.5 }, triangle)).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('irregular polygon — point clearly outside', () => {
|
|
222
|
+
const triangle = {
|
|
223
|
+
id: 'tri',
|
|
224
|
+
vertices: [
|
|
225
|
+
{ latDeg: 0, lonDeg: 0 },
|
|
226
|
+
{ latDeg: 2, lonDeg: 0 },
|
|
227
|
+
{ latDeg: 1, lonDeg: 2 },
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
expect(pointInGeofence({ latDeg: 5, lonDeg: 5 }, triangle)).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('degenerate polygon (< 3 vertices) returns false', () => {
|
|
234
|
+
expect(pointInGeofence({ latDeg: 0, lonDeg: 0 }, { id: 'bad', vertices: [{ latDeg: 0, lonDeg: 0 }] })).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ─── Full analysis + receipt ──────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
describe('analyzeGeodesy', () => {
|
|
241
|
+
it('returns converged=true for non-antipodal points', () => {
|
|
242
|
+
const r = analyzeGeodesy({ latDeg: 48.8566, lonDeg: 2.3522 }, { latDeg: 35.6762, lonDeg: 139.6503 });
|
|
243
|
+
expect(r.converged).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('ECEF coordinates have correct magnitude (near Earth surface)', () => {
|
|
247
|
+
const r = analyzeGeodesy({ latDeg: 0, lonDeg: 0 }, { latDeg: 45, lonDeg: 45 });
|
|
248
|
+
const mag1 = Math.sqrt(r.ecefOrigin.xM ** 2 + r.ecefOrigin.yM ** 2 + r.ecefOrigin.zM ** 2);
|
|
249
|
+
expect(mag1).toBeCloseTo(6_378_137.0, -1); // ≈ semi-major axis
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('buildGeodesyReceipt', () => {
|
|
254
|
+
it('produces receipt with plugin=geolocation-gis and CAEL event', () => {
|
|
255
|
+
const result = analyzeGeodesy({ latDeg: 51.5074, lonDeg: -0.1278 }, { latDeg: 40.7128, lonDeg: -74.0060 });
|
|
256
|
+
const receipt = buildGeodesyReceipt(result);
|
|
257
|
+
expect(receipt.plugin).toBe('geolocation-gis');
|
|
258
|
+
expect(receipt.cael.event).toBe('geolocation_gis.geodesy');
|
|
259
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('accepted=true for convergent analysis', () => {
|
|
263
|
+
const result = analyzeGeodesy({ latDeg: 0, lonDeg: 0 }, { latDeg: 1, lonDeg: 1 });
|
|
264
|
+
const receipt = buildGeodesyReceipt(result);
|
|
265
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
266
|
+
expect(receipt.acceptance.violations).toHaveLength(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('resultSummary.distanceKm matches vincenty distance', () => {
|
|
270
|
+
const result = analyzeGeodesy({ latDeg: 51.5074, lonDeg: -0.1278 }, { latDeg: 40.7128, lonDeg: -74.0060 });
|
|
271
|
+
const receipt = buildGeodesyReceipt(result);
|
|
272
|
+
// Receipt distance should match the Vincenty result (within floating-point rounding to 4 dp)
|
|
273
|
+
expect(receipt.resultSummary.distanceKm).toBeCloseTo(result.vincenty.distanceM / 1000, 1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('uses provided runId', () => {
|
|
277
|
+
const result = analyzeGeodesy({ latDeg: 0, lonDeg: 0 }, { latDeg: 10, lonDeg: 10 });
|
|
278
|
+
const receipt = buildGeodesyReceipt(result, { runId: 'geo-run-1' });
|
|
279
|
+
expect(receipt.runId).toBe('geo-run-1');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof (PATH-3 second plugin): the geolocation `vincenty_geodesy`
|
|
3
|
+
* trait, registered through the SHARED P1 registrar, is dispatched by the
|
|
4
|
+
* runtime and runs the REAL WGS-84 Vincenty solver — proving the wiring pattern
|
|
5
|
+
* generalizes beyond energy-grid to a different domain.
|
|
6
|
+
*
|
|
7
|
+
* Drives the real path: executeNode(orb) -> orb-executor -> applyDirectives ->
|
|
8
|
+
* traitHandlers.get('vincenty_geodesy').onAttach -> vincentyInverse.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
12
|
+
import { registerGeolocationTraitHandlers, type LatLon } from '../runtime';
|
|
13
|
+
|
|
14
|
+
function geodesyOrb(from: LatLon, to: LatLon): unknown {
|
|
15
|
+
return {
|
|
16
|
+
type: 'orb',
|
|
17
|
+
name: 'geo',
|
|
18
|
+
properties: {},
|
|
19
|
+
methods: [],
|
|
20
|
+
position: [0, 0, 0],
|
|
21
|
+
hologram: { shape: 'orb', color: '#00aa00', size: 1, glow: false, interactive: false },
|
|
22
|
+
directives: [{ type: 'trait', name: 'vincenty_geodesy', config: { from, to } }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
27
|
+
|
|
28
|
+
// 1 degree of longitude at the equator is a WGS-84 geodesic of a * (pi/180)
|
|
29
|
+
// = 6378137 * 0.0174532925 ~= 111319.49 m. The crude equirectangular *111320
|
|
30
|
+
// approximation the deep-ratchet flagged would NOT match the ellipsoidal solve.
|
|
31
|
+
const FROM: LatLon = { latDeg: 0, lonDeg: 0 };
|
|
32
|
+
const TO: LatLon = { latDeg: 0, lonDeg: 1 };
|
|
33
|
+
|
|
34
|
+
describe('geolocation-gis -> HoloScript runtime integration (PATH-3 second plugin)', () => {
|
|
35
|
+
it('runtime dispatch runs the REAL Vincenty solver for a registered @vincenty_geodesy orb', async () => {
|
|
36
|
+
const runtime = new HoloScriptRuntime();
|
|
37
|
+
registerGeolocationTraitHandlers(runtime);
|
|
38
|
+
|
|
39
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
40
|
+
runtime.on('vincenty_geodesy_solved', (e: unknown) => {
|
|
41
|
+
solved.push(e as Record<string, unknown>);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await runtime.executeNode(geodesyOrb(FROM, TO) as never);
|
|
45
|
+
await flush();
|
|
46
|
+
|
|
47
|
+
expect(solved).toHaveLength(1);
|
|
48
|
+
const s = solved[0];
|
|
49
|
+
expect(s.antipodal).toBe(false);
|
|
50
|
+
// Real WGS-84 geodesic ~111319.49 m (not a stub returning 0 / crude approx).
|
|
51
|
+
expect(s.distanceM as number).toBeGreaterThan(111300);
|
|
52
|
+
expect(s.distanceM as number).toBeLessThan(111340);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('NEGATIVE CONTROL: without registration the @vincenty_geodesy trait is a dead no-op', async () => {
|
|
56
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
57
|
+
const solved: unknown[] = [];
|
|
58
|
+
runtime.on('vincenty_geodesy_solved', (e: unknown) => solved.push(e));
|
|
59
|
+
|
|
60
|
+
await runtime.executeNode(geodesyOrb(FROM, TO) as never);
|
|
61
|
+
await flush();
|
|
62
|
+
|
|
63
|
+
expect(solved).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('persists the geodesic result into durable runtime state on attach', async () => {
|
|
67
|
+
const runtime = new HoloScriptRuntime();
|
|
68
|
+
registerGeolocationTraitHandlers(runtime);
|
|
69
|
+
|
|
70
|
+
await runtime.executeNode(geodesyOrb(FROM, TO) as never);
|
|
71
|
+
await flush();
|
|
72
|
+
|
|
73
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
74
|
+
const persisted = state['vincenty_geodesy:geo'] as { distanceM?: number } | undefined;
|
|
75
|
+
expect(persisted).toBeDefined();
|
|
76
|
+
expect(persisted?.distanceM as number).toBeGreaterThan(111300);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('emits vincenty_geodesy_error (does not throw through the runtime) for missing from/to', async () => {
|
|
80
|
+
const runtime = new HoloScriptRuntime();
|
|
81
|
+
registerGeolocationTraitHandlers(runtime);
|
|
82
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
83
|
+
runtime.on('vincenty_geodesy_error', (e: unknown) => {
|
|
84
|
+
errors.push(e as Record<string, unknown>);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const orb = {
|
|
88
|
+
type: 'orb',
|
|
89
|
+
name: 'geo',
|
|
90
|
+
properties: {},
|
|
91
|
+
methods: [],
|
|
92
|
+
position: [0, 0, 0],
|
|
93
|
+
hologram: { shape: 'orb', color: '#00aa00', size: 1, glow: false, interactive: false },
|
|
94
|
+
directives: [{ type: 'trait', name: 'vincenty_geodesy', config: { from: FROM } }], // no `to`
|
|
95
|
+
};
|
|
96
|
+
await runtime.executeNode(orb as never);
|
|
97
|
+
await flush();
|
|
98
|
+
|
|
99
|
+
expect(errors).toHaveLength(1);
|
|
100
|
+
expect(String(errors[0].error)).toContain('from and config.to');
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/geodesy.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geodesy solver — geolocation-gis-plugin
|
|
3
|
+
*
|
|
4
|
+
* Implements WGS-84 geodetic operations without external dependencies:
|
|
5
|
+
* - Vincenty inverse formula (geodesic distance + azimuths)
|
|
6
|
+
* - ECEF ↔ LLA coordinate transform (Bowring iterative)
|
|
7
|
+
* - Great-circle waypoint interpolation (SLERP on unit sphere)
|
|
8
|
+
* - Spherical point-in-polygon (ray-casting; suitable for <~1000 km polygons)
|
|
9
|
+
* - CAEL-backed receipt via buildDomainSimulationReceipt
|
|
10
|
+
*
|
|
11
|
+
* References:
|
|
12
|
+
* Vincenty, T. (1975). "Direct and inverse solutions of geodesics on the
|
|
13
|
+
* ellipsoid with application of nested equations." Survey Review 23(176):88-93.
|
|
14
|
+
* WGS-84: NIMA TR8350.2 (3rd ed., 2000)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DOMAIN_SIMULATION_RECEIPT_SCHEMA,
|
|
19
|
+
buildDomainSimulationReceipt,
|
|
20
|
+
} from '@holoscript/core';
|
|
21
|
+
|
|
22
|
+
// ─── WGS-84 constants ─────────────────────────────────────────────────────────
|
|
23
|
+
const WGS84_A = 6_378_137.0; // semi-major axis (m)
|
|
24
|
+
const WGS84_F = 1 / 298.257_223_563; // flattening
|
|
25
|
+
const WGS84_B = WGS84_A * (1 - WGS84_F); // semi-minor axis (m)
|
|
26
|
+
const WGS84_E2 = 2 * WGS84_F - WGS84_F ** 2; // first eccentricity squared
|
|
27
|
+
// second eccentricity squared
|
|
28
|
+
const WGS84_EP2 = WGS84_E2 / (1 - WGS84_E2);
|
|
29
|
+
|
|
30
|
+
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Geodetic coordinates (WGS-84). Lat/lon in decimal degrees, altitude in metres. */
|
|
33
|
+
export interface LatLonAlt {
|
|
34
|
+
latDeg: number; // −90 … +90
|
|
35
|
+
lonDeg: number; // −180 … +180
|
|
36
|
+
altM: number; // metres above WGS-84 ellipsoid
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Earth-Centered Earth-Fixed Cartesian coordinates (metres). */
|
|
40
|
+
export interface ECEF {
|
|
41
|
+
xM: number;
|
|
42
|
+
yM: number;
|
|
43
|
+
zM: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Geofence defined by an ordered list of vertices forming a closed polygon. */
|
|
47
|
+
export interface GeofencePolygon {
|
|
48
|
+
id: string;
|
|
49
|
+
vertices: { latDeg: number; lonDeg: number }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Result of Vincenty inverse computation. */
|
|
53
|
+
export interface VincentyResult {
|
|
54
|
+
distanceM: number; // geodesic distance (m)
|
|
55
|
+
forwardAzimuthDeg: number; // bearing from p1 → p2 (deg, 0=N, 90=E)
|
|
56
|
+
backAzimuthDeg: number; // bearing from p2 → p1 (deg)
|
|
57
|
+
/** true if points are antipodal (distance undefined within machine epsilon) */
|
|
58
|
+
antipodal: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A sequence of waypoints along a great-circle path. */
|
|
62
|
+
export interface GreatCircleRoute {
|
|
63
|
+
origin: { latDeg: number; lonDeg: number };
|
|
64
|
+
destination: { latDeg: number; lonDeg: number };
|
|
65
|
+
waypoints: { latDeg: number; lonDeg: number; fractionOfTotal: number }[];
|
|
66
|
+
totalDistanceM: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Combined result from a full geodesy analysis session. */
|
|
70
|
+
export interface GeodesyAnalysisResult {
|
|
71
|
+
vincenty: VincentyResult;
|
|
72
|
+
route: GreatCircleRoute;
|
|
73
|
+
ecefOrigin: ECEF;
|
|
74
|
+
ecefDest: ECEF;
|
|
75
|
+
converged: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface GeodesyReceipt {
|
|
79
|
+
plugin: string;
|
|
80
|
+
runId: string;
|
|
81
|
+
payloadHash: string;
|
|
82
|
+
hashAlgorithm: string;
|
|
83
|
+
cael: { event: string; schemaVersion: string; ts: string };
|
|
84
|
+
acceptance: { accepted: boolean; violations: string[] };
|
|
85
|
+
resultSummary: {
|
|
86
|
+
distanceKm: number;
|
|
87
|
+
forwardAzimuthDeg: number;
|
|
88
|
+
waypointCount: number;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const DEG = Math.PI / 180;
|
|
95
|
+
const RAD = 180 / Math.PI;
|
|
96
|
+
|
|
97
|
+
function toRad(deg: number): number { return deg * DEG; }
|
|
98
|
+
function toDeg(rad: number): number { return rad * RAD; }
|
|
99
|
+
function normalizeDeg(d: number): number { return ((d % 360) + 360) % 360; }
|
|
100
|
+
|
|
101
|
+
/** Prime vertical radius of curvature N(φ) */
|
|
102
|
+
function primeVerticalRadius(sinPhi: number): number {
|
|
103
|
+
return WGS84_A / Math.sqrt(1 - WGS84_E2 * sinPhi * sinPhi);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Vincenty inverse ─────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Vincenty inverse formula on WGS-84 ellipsoid.
|
|
110
|
+
* Returns geodesic distance and azimuths between two surface points.
|
|
111
|
+
* Altitude is ignored (surface geodesic).
|
|
112
|
+
*/
|
|
113
|
+
export function vincentyInverse(
|
|
114
|
+
p1: { latDeg: number; lonDeg: number },
|
|
115
|
+
p2: { latDeg: number; lonDeg: number },
|
|
116
|
+
): VincentyResult {
|
|
117
|
+
const phi1 = toRad(p1.latDeg);
|
|
118
|
+
const phi2 = toRad(p2.latDeg);
|
|
119
|
+
const L = toRad(p2.lonDeg - p1.lonDeg);
|
|
120
|
+
|
|
121
|
+
const U1 = Math.atan((1 - WGS84_F) * Math.tan(phi1));
|
|
122
|
+
const U2 = Math.atan((1 - WGS84_F) * Math.tan(phi2));
|
|
123
|
+
|
|
124
|
+
const sinU1 = Math.sin(U1), cosU1 = Math.cos(U1);
|
|
125
|
+
const sinU2 = Math.sin(U2), cosU2 = Math.cos(U2);
|
|
126
|
+
|
|
127
|
+
let lambda = L;
|
|
128
|
+
let lambdaP: number;
|
|
129
|
+
let sinLambda: number, cosLambda: number;
|
|
130
|
+
let sinSigma: number, cosSigma: number, sigma: number;
|
|
131
|
+
let sinAlpha: number, cos2Alpha: number, cos2SigmaM: number;
|
|
132
|
+
let C: number;
|
|
133
|
+
let iterations = 0;
|
|
134
|
+
|
|
135
|
+
do {
|
|
136
|
+
sinLambda = Math.sin(lambda);
|
|
137
|
+
cosLambda = Math.cos(lambda);
|
|
138
|
+
|
|
139
|
+
sinSigma = Math.sqrt(
|
|
140
|
+
(cosU2 * sinLambda) ** 2 +
|
|
141
|
+
(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) ** 2,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (sinSigma === 0) {
|
|
145
|
+
// coincident points
|
|
146
|
+
return { distanceM: 0, forwardAzimuthDeg: 0, backAzimuthDeg: 0, antipodal: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
|
|
150
|
+
sigma = Math.atan2(sinSigma, cosSigma);
|
|
151
|
+
sinAlpha = (cosU1 * cosU2 * sinLambda) / sinSigma;
|
|
152
|
+
cos2Alpha = 1 - sinAlpha * sinAlpha;
|
|
153
|
+
|
|
154
|
+
cos2SigmaM = cos2Alpha !== 0
|
|
155
|
+
? cosSigma - (2 * sinU1 * sinU2) / cos2Alpha
|
|
156
|
+
: 0; // equatorial line
|
|
157
|
+
|
|
158
|
+
C = (WGS84_F / 16) * cos2Alpha * (4 + WGS84_F * (4 - 3 * cos2Alpha));
|
|
159
|
+
|
|
160
|
+
lambdaP = lambda;
|
|
161
|
+
lambda = L + (1 - C) * WGS84_F * sinAlpha *
|
|
162
|
+
(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM ** 2)));
|
|
163
|
+
|
|
164
|
+
} while (Math.abs(lambda - lambdaP) > 1e-12 && ++iterations < 200);
|
|
165
|
+
|
|
166
|
+
const antipodal = iterations >= 200;
|
|
167
|
+
|
|
168
|
+
const uSq = cos2Alpha * WGS84_EP2;
|
|
169
|
+
const A_vc = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
|
|
170
|
+
const B_vc = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
|
|
171
|
+
|
|
172
|
+
const deltaSigma = B_vc * sinSigma * (
|
|
173
|
+
cos2SigmaM + (B_vc / 4) * (
|
|
174
|
+
cosSigma * (-1 + 2 * cos2SigmaM ** 2) -
|
|
175
|
+
(B_vc / 6) * cos2SigmaM * (-3 + 4 * sinSigma ** 2) * (-3 + 4 * cos2SigmaM ** 2)
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const distanceM = WGS84_B * A_vc * (sigma - deltaSigma);
|
|
180
|
+
|
|
181
|
+
const fwdAz = Math.atan2(cosU2 * sinLambda, cosU1 * sinU2 - sinU1 * cosU2 * cosLambda);
|
|
182
|
+
const bakAz = Math.atan2(cosU1 * sinLambda, -sinU1 * cosU2 + cosU1 * sinU2 * cosLambda);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
distanceM,
|
|
186
|
+
forwardAzimuthDeg: normalizeDeg(toDeg(fwdAz)),
|
|
187
|
+
backAzimuthDeg: normalizeDeg(toDeg(bakAz)),
|
|
188
|
+
antipodal,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── ECEF ↔ LLA ──────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** Convert WGS-84 geodetic coordinates to ECEF Cartesian. */
|
|
195
|
+
export function llaToECEF(lla: LatLonAlt): ECEF {
|
|
196
|
+
const phi = toRad(lla.latDeg);
|
|
197
|
+
const lambda = toRad(lla.lonDeg);
|
|
198
|
+
const h = lla.altM;
|
|
199
|
+
|
|
200
|
+
const sinPhi = Math.sin(phi);
|
|
201
|
+
const cosPhi = Math.cos(phi);
|
|
202
|
+
const N = primeVerticalRadius(sinPhi);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
xM: (N + h) * cosPhi * Math.cos(lambda),
|
|
206
|
+
yM: (N + h) * cosPhi * Math.sin(lambda),
|
|
207
|
+
zM: (N * (1 - WGS84_E2) + h) * sinPhi,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Convert ECEF Cartesian to WGS-84 geodetic (Bowring iterative, converges in <5 steps).
|
|
213
|
+
* Accurate to sub-millimetre for all altitudes < 10,000 km.
|
|
214
|
+
*/
|
|
215
|
+
export function ecefToLLA(ecef: ECEF): LatLonAlt {
|
|
216
|
+
const { xM, yM, zM } = ecef;
|
|
217
|
+
const p = Math.sqrt(xM * xM + yM * yM);
|
|
218
|
+
const lambda = Math.atan2(yM, xM);
|
|
219
|
+
|
|
220
|
+
// Initial estimate (Bowring)
|
|
221
|
+
let phi = Math.atan2(zM, p * (1 - WGS84_E2));
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < 10; i++) {
|
|
224
|
+
const sinPhi = Math.sin(phi);
|
|
225
|
+
const N = primeVerticalRadius(sinPhi);
|
|
226
|
+
const phiNew = Math.atan2(zM + WGS84_E2 * N * sinPhi, p);
|
|
227
|
+
if (Math.abs(phiNew - phi) < 1e-12) { phi = phiNew; break; }
|
|
228
|
+
phi = phiNew;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const sinPhi = Math.sin(phi);
|
|
232
|
+
const N = primeVerticalRadius(sinPhi);
|
|
233
|
+
const h = p / Math.cos(phi) - N;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
latDeg: toDeg(phi),
|
|
237
|
+
lonDeg: toDeg(lambda),
|
|
238
|
+
altM: h,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Great-circle waypoints ───────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Interpolate n+2 waypoints (including endpoints) along the great-circle path
|
|
246
|
+
* between two surface points using SLERP on the unit sphere.
|
|
247
|
+
* Intermediate points are at equal angular spacing.
|
|
248
|
+
*/
|
|
249
|
+
export function greatCircleWaypoints(
|
|
250
|
+
p1: { latDeg: number; lonDeg: number },
|
|
251
|
+
p2: { latDeg: number; lonDeg: number },
|
|
252
|
+
numIntermediate = 8,
|
|
253
|
+
): GreatCircleRoute {
|
|
254
|
+
const vincenty = vincentyInverse(p1, p2);
|
|
255
|
+
|
|
256
|
+
// Convert to ECEF unit vectors (ignoring altitude — surface only)
|
|
257
|
+
const toUnitVec = (p: { latDeg: number; lonDeg: number }) => {
|
|
258
|
+
const phi = toRad(p.latDeg), lam = toRad(p.lonDeg);
|
|
259
|
+
const cp = Math.cos(phi);
|
|
260
|
+
return { x: cp * Math.cos(lam), y: cp * Math.sin(lam), z: Math.sin(phi) };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const v1 = toUnitVec(p1);
|
|
264
|
+
const v2 = toUnitVec(p2);
|
|
265
|
+
|
|
266
|
+
const dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
|
|
267
|
+
const omega = Math.acos(Math.max(-1, Math.min(1, dot))); // angular separation
|
|
268
|
+
|
|
269
|
+
const total = numIntermediate + 2;
|
|
270
|
+
const waypoints = Array.from({ length: total }, (_, k) => {
|
|
271
|
+
const t = k / (total - 1);
|
|
272
|
+
let vx: number, vy: number, vz: number;
|
|
273
|
+
if (omega < 1e-10) {
|
|
274
|
+
vx = v1.x; vy = v1.y; vz = v1.z;
|
|
275
|
+
} else {
|
|
276
|
+
const s = Math.sin(omega);
|
|
277
|
+
const a = Math.sin((1 - t) * omega) / s;
|
|
278
|
+
const b = Math.sin(t * omega) / s;
|
|
279
|
+
vx = a * v1.x + b * v2.x;
|
|
280
|
+
vy = a * v1.y + b * v2.y;
|
|
281
|
+
vz = a * v1.z + b * v2.z;
|
|
282
|
+
}
|
|
283
|
+
// Unit vec → LLA (surface, h=0)
|
|
284
|
+
const latDeg = toDeg(Math.atan2(vz, Math.sqrt(vx * vx + vy * vy)));
|
|
285
|
+
const lonDeg = toDeg(Math.atan2(vy, vx));
|
|
286
|
+
return { latDeg, lonDeg, fractionOfTotal: t };
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
origin: { latDeg: p1.latDeg, lonDeg: p1.lonDeg },
|
|
291
|
+
destination: { latDeg: p2.latDeg, lonDeg: p2.lonDeg },
|
|
292
|
+
waypoints,
|
|
293
|
+
totalDistanceM: vincenty.distanceM,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Point-in-geofence ───────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Spherical ray-casting algorithm to test if a point lies inside a geofence polygon.
|
|
301
|
+
* Uses gnomonic projection for sub-degree accuracy (suitable for polygons < ~500 km across).
|
|
302
|
+
*
|
|
303
|
+
* Returns true if the point is inside or on the boundary.
|
|
304
|
+
*/
|
|
305
|
+
export function pointInGeofence(
|
|
306
|
+
point: { latDeg: number; lonDeg: number },
|
|
307
|
+
polygon: GeofencePolygon,
|
|
308
|
+
): boolean {
|
|
309
|
+
const verts = polygon.vertices;
|
|
310
|
+
if (verts.length < 3) return false;
|
|
311
|
+
|
|
312
|
+
// Project all vertices and test point onto flat plane using gnomonic projection
|
|
313
|
+
// centred on the first vertex (good for polygons up to ~500 km wide).
|
|
314
|
+
const phi0 = toRad(verts[0].latDeg);
|
|
315
|
+
const lam0 = toRad(verts[0].lonDeg);
|
|
316
|
+
const cosPhi0 = Math.cos(phi0);
|
|
317
|
+
const sinPhi0 = Math.sin(phi0);
|
|
318
|
+
|
|
319
|
+
const project = (lat: number, lon: number): [number, number] => {
|
|
320
|
+
const phi = toRad(lat), lam = toRad(lon);
|
|
321
|
+
const c = sinPhi0 * Math.sin(phi) + cosPhi0 * Math.cos(phi) * Math.cos(lam - lam0);
|
|
322
|
+
const x = Math.cos(phi) * Math.sin(lam - lam0) / c;
|
|
323
|
+
const y = (cosPhi0 * Math.sin(phi) - sinPhi0 * Math.cos(phi) * Math.cos(lam - lam0)) / c;
|
|
324
|
+
return [x, y];
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const [px, py] = project(point.latDeg, point.lonDeg);
|
|
328
|
+
const n = verts.length;
|
|
329
|
+
let inside = false;
|
|
330
|
+
|
|
331
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
332
|
+
const [xi, yi] = project(verts[i].latDeg, verts[i].lonDeg);
|
|
333
|
+
const [xj, yj] = project(verts[j].latDeg, verts[j].lonDeg);
|
|
334
|
+
const intersect = yi > py !== yj > py &&
|
|
335
|
+
px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
|
336
|
+
if (intersect) inside = !inside;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return inside;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Full analysis entry point ────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Run a complete geodesy analysis between two points.
|
|
346
|
+
*/
|
|
347
|
+
export function analyzeGeodesy(
|
|
348
|
+
origin: { latDeg: number; lonDeg: number; altM?: number },
|
|
349
|
+
destination: { latDeg: number; lonDeg: number; altM?: number },
|
|
350
|
+
numWaypoints = 8,
|
|
351
|
+
): GeodesyAnalysisResult {
|
|
352
|
+
const p1 = { latDeg: origin.latDeg, lonDeg: origin.lonDeg };
|
|
353
|
+
const p2 = { latDeg: destination.latDeg, lonDeg: destination.lonDeg };
|
|
354
|
+
const lla1: LatLonAlt = { ...p1, altM: origin.altM ?? 0 };
|
|
355
|
+
const lla2: LatLonAlt = { ...p2, altM: destination.altM ?? 0 };
|
|
356
|
+
|
|
357
|
+
const vincenty = vincentyInverse(p1, p2);
|
|
358
|
+
const route = greatCircleWaypoints(p1, p2, numWaypoints);
|
|
359
|
+
const ecefOrigin = llaToECEF(lla1);
|
|
360
|
+
const ecefDest = llaToECEF(lla2);
|
|
361
|
+
|
|
362
|
+
return { vincenty, route, ecefOrigin, ecefDest, converged: !vincenty.antipodal };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─── Receipt ─────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
export function buildGeodesyReceipt(
|
|
368
|
+
result: GeodesyAnalysisResult,
|
|
369
|
+
options?: { runId?: string },
|
|
370
|
+
): GeodesyReceipt {
|
|
371
|
+
const violations: Array<{ criterion: string; message: string }> = [];
|
|
372
|
+
if (result.vincenty.antipodal)
|
|
373
|
+
violations.push({ criterion: 'convergence', message: 'points are antipodal — geodesic undefined' });
|
|
374
|
+
if (!result.converged)
|
|
375
|
+
violations.push({ criterion: 'convergence', message: 'Vincenty iteration did not converge' });
|
|
376
|
+
|
|
377
|
+
const raw = buildDomainSimulationReceipt({
|
|
378
|
+
plugin: 'geolocation-gis',
|
|
379
|
+
pluginVersion: '1.0.0',
|
|
380
|
+
runId: options?.runId ?? `geo-${Date.now().toString(36)}`,
|
|
381
|
+
modelId: 'geodesy-analysis',
|
|
382
|
+
solverConfig: {
|
|
383
|
+
solverType: 'vincenty-wgs84',
|
|
384
|
+
scale: 'global',
|
|
385
|
+
},
|
|
386
|
+
resultSummary: {
|
|
387
|
+
distanceKm: +(result.vincenty.distanceM / 1000).toFixed(4),
|
|
388
|
+
forwardAzimuthDeg: +result.vincenty.forwardAzimuthDeg.toFixed(4),
|
|
389
|
+
waypointCount: result.route.waypoints.length,
|
|
390
|
+
},
|
|
391
|
+
cael: {
|
|
392
|
+
version: 'cael.v1',
|
|
393
|
+
event: 'geolocation_gis.geodesy',
|
|
394
|
+
solverType: 'geolocation-gis.vincenty-wgs84',
|
|
395
|
+
},
|
|
396
|
+
acceptance: { accepted: violations.length === 0, violations },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return raw as unknown as GeodesyReceipt;
|
|
400
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { createMapViewHandler, type MapViewConfig } from './traits/MapViewTrait';
|
|
2
|
+
export { createRouteHandler, type RouteConfig, type Waypoint } from './traits/RouteTrait';
|
|
3
|
+
export { createPOIHandler, type POIConfig } from './traits/POITrait';
|
|
4
|
+
export { createGeocodeHandler, type GeocodeConfig } from './traits/GeocodeTrait';
|
|
5
|
+
export { createGeofenceHandler, type GeofenceConfig } from './traits/GeofenceTrait';
|
|
6
|
+
export * from './traits/types';
|
|
7
|
+
|
|
8
|
+
import { createMapViewHandler } from './traits/MapViewTrait';
|
|
9
|
+
import { createRouteHandler } from './traits/RouteTrait';
|
|
10
|
+
import { createPOIHandler } from './traits/POITrait';
|
|
11
|
+
import { createGeocodeHandler } from './traits/GeocodeTrait';
|
|
12
|
+
import { createGeofenceHandler } from './traits/GeofenceTrait';
|
|
13
|
+
|
|
14
|
+
export * from './geodesy';
|
|
15
|
+
|
|
16
|
+
export const pluginMeta = { name: '@holoscript/plugin-geolocation-gis', version: '1.0.0', traits: ['map_view', 'route', 'poi', 'geocode', 'geofence', 'vincenty_geodesy'] };
|
|
17
|
+
export const traitHandlers = [createMapViewHandler(), createRouteHandler(), createPOIHandler(), createGeocodeHandler(), createGeofenceHandler()];
|
|
18
|
+
|
|
19
|
+
// Runtime integration — behavioral `vincenty_geodesy` handler + opt-in registrar
|
|
20
|
+
// that wire the real Vincenty solver into HoloScriptRuntime via the shared P1
|
|
21
|
+
// registrar (task_1780878631657_j63f PATH-3, second plugin).
|
|
22
|
+
export {
|
|
23
|
+
vincentyGeodesyHandler,
|
|
24
|
+
registerGeolocationTraitHandlers,
|
|
25
|
+
GEOLOCATION_GIS_PLUGIN_ID,
|
|
26
|
+
type LatLon,
|
|
27
|
+
type VincentyGeodesyTraitConfig,
|
|
28
|
+
type VincentyGeodesySolvedEvent,
|
|
29
|
+
} from './runtime';
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime integration for @holoscript/plugin-geolocation-gis.
|
|
3
|
+
*
|
|
4
|
+
* Wires the declared-but-dead `vincenty_geodesy` trait (listed in
|
|
5
|
+
* pluginMeta.traits with NO handler — the exact analog of energy-grid's
|
|
6
|
+
* power_flow) into a behavioral TraitHandler that runs the REAL WGS-84
|
|
7
|
+
* Vincenty inverse solver through the runtime, via the shared P1 registrar.
|
|
8
|
+
*
|
|
9
|
+
* Second plugin of the domain-plugin rollout (task_1780878631657_j63f PATH-3):
|
|
10
|
+
* a deliberately DIFFERENT domain (geodesy, not power-flow) to prove the shared
|
|
11
|
+
* registration pattern generalizes. Also addresses the geolocation OVERCLAIM
|
|
12
|
+
* (PATH-5 / deep-ratchet 2026-06-07): the plugin's real geodesy now drives a
|
|
13
|
+
* live trait instead of sitting unused behind thin map/route/geocode stubs.
|
|
14
|
+
*/
|
|
15
|
+
import { registerPluginTraits } from '@holoscript/core/runtime';
|
|
16
|
+
import { vincentyInverse, type VincentyResult } from './geodesy';
|
|
17
|
+
|
|
18
|
+
export const GEOLOCATION_GIS_PLUGIN_ID = 'geolocation-gis' as const;
|
|
19
|
+
|
|
20
|
+
/** A WGS-84 surface point in decimal degrees. */
|
|
21
|
+
export interface LatLon {
|
|
22
|
+
latDeg: number;
|
|
23
|
+
lonDeg: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Config carried by an orb's `@vincenty_geodesy` trait directive. */
|
|
27
|
+
export interface VincentyGeodesyTraitConfig {
|
|
28
|
+
from?: LatLon;
|
|
29
|
+
to?: LatLon;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Summary payload emitted on `vincenty_geodesy_solved`. */
|
|
33
|
+
export interface VincentyGeodesySolvedEvent {
|
|
34
|
+
nodeId: string;
|
|
35
|
+
distanceM: number;
|
|
36
|
+
forwardAzimuthDeg: number;
|
|
37
|
+
backAzimuthDeg: number;
|
|
38
|
+
antipodal: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TraitDispatchContext {
|
|
42
|
+
emit: (event: string, payload?: unknown) => void;
|
|
43
|
+
setState?: (updates: Record<string, unknown>) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RuntimeTraitHandler {
|
|
47
|
+
name: string;
|
|
48
|
+
onAttach?: (
|
|
49
|
+
node: unknown,
|
|
50
|
+
config: VincentyGeodesyTraitConfig,
|
|
51
|
+
context: TraitDispatchContext,
|
|
52
|
+
) => void;
|
|
53
|
+
onUpdate?: (
|
|
54
|
+
node: unknown,
|
|
55
|
+
config: VincentyGeodesyTraitConfig,
|
|
56
|
+
context: TraitDispatchContext,
|
|
57
|
+
delta: number,
|
|
58
|
+
) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface VincentyNode {
|
|
62
|
+
id?: string;
|
|
63
|
+
name?: string;
|
|
64
|
+
properties?: Record<string, unknown>;
|
|
65
|
+
__vincentyResult?: VincentyResult;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run the Vincenty inverse for `config.{from,to}`, write onto the node, emit. */
|
|
69
|
+
function solveOntoNode(
|
|
70
|
+
node: unknown,
|
|
71
|
+
config: VincentyGeodesyTraitConfig | undefined,
|
|
72
|
+
context: TraitDispatchContext,
|
|
73
|
+
): void {
|
|
74
|
+
const carrier = node as VincentyNode;
|
|
75
|
+
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
76
|
+
const from = config?.from;
|
|
77
|
+
const to = config?.to;
|
|
78
|
+
|
|
79
|
+
if (!from || !to) {
|
|
80
|
+
context.emit('vincenty_geodesy_error', {
|
|
81
|
+
nodeId,
|
|
82
|
+
error: 'vincenty_geodesy trait requires config.from and config.to ({ latDeg, lonDeg })',
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = vincentyInverse(from, to);
|
|
89
|
+
carrier.__vincentyResult = result;
|
|
90
|
+
carrier.properties = {
|
|
91
|
+
...(carrier.properties ?? {}),
|
|
92
|
+
distanceM: result.distanceM,
|
|
93
|
+
antipodal: result.antipodal,
|
|
94
|
+
};
|
|
95
|
+
const summary: VincentyGeodesySolvedEvent = {
|
|
96
|
+
nodeId,
|
|
97
|
+
distanceM: result.distanceM,
|
|
98
|
+
forwardAzimuthDeg: result.forwardAzimuthDeg,
|
|
99
|
+
backAzimuthDeg: result.backAzimuthDeg,
|
|
100
|
+
antipodal: result.antipodal,
|
|
101
|
+
};
|
|
102
|
+
context.setState?.({ [`vincenty_geodesy:${nodeId}`]: summary });
|
|
103
|
+
context.emit('vincenty_geodesy_solved', summary);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
context.emit('vincenty_geodesy_error', {
|
|
106
|
+
nodeId,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Behavioral handler for the `vincenty_geodesy` trait — real WGS-84 geodesics. */
|
|
113
|
+
export const vincentyGeodesyHandler: RuntimeTraitHandler = {
|
|
114
|
+
name: 'vincenty_geodesy',
|
|
115
|
+
onAttach: (node, config, context) => solveOntoNode(node, config, context),
|
|
116
|
+
onUpdate: (node, config, context) => solveOntoNode(node, config, context),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** A runtime that can register behavioral trait handlers. */
|
|
120
|
+
export interface TraitRegistrar {
|
|
121
|
+
registerTrait(name: string, handler: unknown): void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register geolocation behavioral trait handlers into a runtime (opt-in).
|
|
126
|
+
* Uses the shared P1 registrar so this plugin registers identically to every
|
|
127
|
+
* other domain plugin (owner-tagged, collision-guarded).
|
|
128
|
+
*/
|
|
129
|
+
export function registerGeolocationTraitHandlers(registrar: TraitRegistrar): void {
|
|
130
|
+
registerPluginTraits(registrar, GEOLOCATION_GIS_PLUGIN_ID, [vincentyGeodesyHandler]);
|
|
131
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @geocode Trait — Address to coordinate conversion. @trait geocode */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface GeocodeConfig { address: string; provider: 'nominatim' | 'google' | 'mapbox'; language: string; }
|
|
5
|
+
const defaultConfig: GeocodeConfig = { address: '', provider: 'nominatim', language: 'en' };
|
|
6
|
+
|
|
7
|
+
export function createGeocodeHandler(): TraitHandler<GeocodeConfig> {
|
|
8
|
+
return { name: 'geocode', defaultConfig,
|
|
9
|
+
onAttach(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext) { n.__geocodeState = { result: null, isGeocoding: false }; ctx.emit?.('geocode:ready'); },
|
|
10
|
+
onDetach(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext) { delete n.__geocodeState; ctx.emit?.('geocode:detached'); },
|
|
11
|
+
onUpdate() {},
|
|
12
|
+
onEvent(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
+
const s = n.__geocodeState as Record<string, unknown> | undefined; if (!s) return;
|
|
14
|
+
if (e.type === 'geocode:lookup') { s.isGeocoding = true; ctx.emit?.('geocode:searching', { address: e.payload?.address }); }
|
|
15
|
+
if (e.type === 'geocode:result') { s.result = e.payload; s.isGeocoding = false; ctx.emit?.('geocode:found', e.payload); }
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** @geofence Trait — Geographic boundary monitoring. @trait geofence */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface GeofenceConfig { center: { lat: number; lng: number }; radiusM: number; shape: 'circle' | 'polygon'; polygonPoints?: Array<{ lat: number; lng: number }>; triggerOnEnter: boolean; triggerOnExit: boolean; }
|
|
5
|
+
const defaultConfig: GeofenceConfig = { center: { lat: 0, lng: 0 }, radiusM: 100, shape: 'circle', triggerOnEnter: true, triggerOnExit: true };
|
|
6
|
+
|
|
7
|
+
export function createGeofenceHandler(): TraitHandler<GeofenceConfig> {
|
|
8
|
+
return { name: 'geofence', defaultConfig,
|
|
9
|
+
onAttach(n: HSPlusNode, c: GeofenceConfig, ctx: TraitContext) { n.__geofenceState = { isInside: false, lastCheck: null }; ctx.emit?.('geofence:created', { radius: c.radiusM, shape: c.shape }); },
|
|
10
|
+
onDetach(n: HSPlusNode, _c: GeofenceConfig, ctx: TraitContext) { delete n.__geofenceState; ctx.emit?.('geofence:removed'); },
|
|
11
|
+
onUpdate() {},
|
|
12
|
+
onEvent(n: HSPlusNode, c: GeofenceConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
+
const s = n.__geofenceState as Record<string, unknown> | undefined; if (!s) return;
|
|
14
|
+
if (e.type === 'geofence:check') {
|
|
15
|
+
const pos = e.payload as unknown as { lat: number; lng: number };
|
|
16
|
+
const dist = Math.sqrt(Math.pow(pos.lat - c.center.lat, 2) + Math.pow(pos.lng - c.center.lng, 2)) * 111320;
|
|
17
|
+
const inside = dist <= c.radiusM;
|
|
18
|
+
const wasInside = s.isInside as boolean;
|
|
19
|
+
s.isInside = inside; s.lastCheck = Date.now();
|
|
20
|
+
if (inside && !wasInside && c.triggerOnEnter) ctx.emit?.('geofence:entered');
|
|
21
|
+
if (!inside && wasInside && c.triggerOnExit) ctx.emit?.('geofence:exited');
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @map_view Trait — Interactive map display. @trait map_view */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface MapViewConfig { center: { lat: number; lng: number }; zoom: number; tileProvider: 'osm' | 'mapbox' | 'google' | 'custom'; style: 'streets' | 'satellite' | 'terrain' | 'dark'; maxZoom: number; minZoom: number; }
|
|
5
|
+
const defaultConfig: MapViewConfig = { center: { lat: 0, lng: 0 }, zoom: 10, tileProvider: 'osm', style: 'streets', maxZoom: 18, minZoom: 1 };
|
|
6
|
+
|
|
7
|
+
export function createMapViewHandler(): TraitHandler<MapViewConfig> {
|
|
8
|
+
return { name: 'map_view', defaultConfig,
|
|
9
|
+
onAttach(n: HSPlusNode, c: MapViewConfig, ctx: TraitContext) { n.__mapState = { center: c.center, zoom: c.zoom, bounds: null }; ctx.emit?.('map:ready', { center: c.center }); },
|
|
10
|
+
onDetach(n: HSPlusNode, _c: MapViewConfig, ctx: TraitContext) { delete n.__mapState; ctx.emit?.('map:destroyed'); },
|
|
11
|
+
onUpdate() {},
|
|
12
|
+
onEvent(n: HSPlusNode, _c: MapViewConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
+
const s = n.__mapState as Record<string, unknown> | undefined; if (!s) return;
|
|
14
|
+
if (e.type === 'map:pan') { s.center = e.payload?.center; ctx.emit?.('map:moved', { center: s.center }); }
|
|
15
|
+
if (e.type === 'map:zoom') { s.zoom = e.payload?.zoom; ctx.emit?.('map:zoomed', { zoom: s.zoom }); }
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @poi Trait — Point of Interest. @trait poi */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface POIConfig { name: string; lat: number; lng: number; category: string; icon?: string; description?: string; rating?: number; }
|
|
5
|
+
const defaultConfig: POIConfig = { name: '', lat: 0, lng: 0, category: 'general' };
|
|
6
|
+
|
|
7
|
+
export function createPOIHandler(): TraitHandler<POIConfig> {
|
|
8
|
+
return { name: 'poi', defaultConfig,
|
|
9
|
+
onAttach(n: HSPlusNode, c: POIConfig, ctx: TraitContext) { n.__poiState = { visible: true, selected: false }; ctx.emit?.('poi:added', { name: c.name, category: c.category }); },
|
|
10
|
+
onDetach(n: HSPlusNode, _c: POIConfig, ctx: TraitContext) { delete n.__poiState; ctx.emit?.('poi:removed'); },
|
|
11
|
+
onUpdate() {},
|
|
12
|
+
onEvent(n: HSPlusNode, c: POIConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
+
const s = n.__poiState as Record<string, unknown> | undefined; if (!s) return;
|
|
14
|
+
if (e.type === 'poi:select') { s.selected = true; ctx.emit?.('poi:selected', { name: c.name }); }
|
|
15
|
+
if (e.type === 'poi:deselect') { s.selected = false; ctx.emit?.('poi:deselected'); }
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @route Trait — Route planning and navigation. @trait route */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface Waypoint { lat: number; lng: number; label?: string; }
|
|
5
|
+
export interface RouteConfig { origin: Waypoint; destination: Waypoint; waypoints: Waypoint[]; mode: 'driving' | 'walking' | 'cycling' | 'transit'; avoidTolls: boolean; avoidHighways: boolean; }
|
|
6
|
+
const defaultConfig: RouteConfig = { origin: { lat: 0, lng: 0 }, destination: { lat: 0, lng: 0 }, waypoints: [], mode: 'driving', avoidTolls: false, avoidHighways: false };
|
|
7
|
+
|
|
8
|
+
export function createRouteHandler(): TraitHandler<RouteConfig> {
|
|
9
|
+
return { name: 'route', defaultConfig,
|
|
10
|
+
onAttach(n: HSPlusNode, c: RouteConfig, ctx: TraitContext) { n.__routeState = { distanceKm: 0, durationMin: 0, isCalculating: false }; ctx.emit?.('route:created', { mode: c.mode }); },
|
|
11
|
+
onDetach(n: HSPlusNode, _c: RouteConfig, ctx: TraitContext) { delete n.__routeState; ctx.emit?.('route:cleared'); },
|
|
12
|
+
onUpdate() {},
|
|
13
|
+
onEvent(n: HSPlusNode, _c: RouteConfig, ctx: TraitContext, e: TraitEvent) {
|
|
14
|
+
if (e.type === 'route:calculate') { const s = n.__routeState as Record<string, unknown>; if (s) { s.isCalculating = true; ctx.emit?.('route:calculating'); } }
|
|
15
|
+
if (e.type === 'route:result') { const s = n.__routeState as Record<string, unknown>; if (s) { s.distanceKm = e.payload?.distanceKm; s.durationMin = e.payload?.durationMin; s.isCalculating = false; ctx.emit?.('route:calculated', e.payload); } }
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
|
|
2
|
+
export interface TraitContext { emit?: (event: string, payload?: unknown) => void; [key: string]: unknown; }
|
|
3
|
+
export interface TraitEvent { type: string; payload?: Record<string, unknown>; [key: string]: unknown; }
|
|
4
|
+
export interface TraitHandler<T = unknown> { name: string; defaultConfig: T; onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void; onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void; onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void; onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void; }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true }, "include": ["src"] }
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
// Resolve workspace peers to SOURCE so tests run against current core/engine
|
|
6
|
+
// (matches the sibling plugin configs, e.g. energy-grid). Without this the
|
|
7
|
+
// `@holoscript/core/runtime` import resolves to stale built dist.
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: {
|
|
10
|
+
'@holoscript/engine': resolve(__dirname, '../../engine/src'),
|
|
11
|
+
'@holoscript/core': resolve(__dirname, '../../core/src'),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
test: {
|
|
15
|
+
globals: true,
|
|
16
|
+
environment: 'node',
|
|
17
|
+
include: ['src/**/*.test.ts'],
|
|
18
|
+
passWithNoTests: true,
|
|
19
|
+
},
|
|
20
|
+
});
|