@holoscript/plugin-geolocation-gis 2.0.1 → 2.0.2
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 +3 -3
- package/src/__tests__/geodesy.test.ts +34 -23
- package/src/geodesy.ts +113 -98
- package/src/index.ts +12 -2
- package/src/runtime.ts +3 -3
- package/src/traits/GeocodeTrait.ts +27 -7
- package/src/traits/GeofenceTrait.ts +33 -8
- package/src/traits/MapViewTrait.ts +37 -8
- package/src/traits/POITrait.ts +30 -7
- package/src/traits/RouteTrait.ts +48 -8
- package/src/traits/types.ts +22 -4
- package/tsconfig.json +5 -1
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holoscript/plugin-geolocation-gis",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"peerDependencies": {
|
|
6
|
-
"@holoscript/core": "8.0.
|
|
6
|
+
"@holoscript/core": ">=8.0.0"
|
|
7
7
|
},
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "vitest run --passWithNoTests",
|
|
11
11
|
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
12
|
}
|
|
13
|
-
}
|
|
13
|
+
}
|
|
@@ -39,8 +39,8 @@ describe('vincentyInverse', () => {
|
|
|
39
39
|
it('London → New York distance is in the 5550–5620 km range', () => {
|
|
40
40
|
// Exact value depends on precise coordinates; WGS-84 ellipsoidal geodesic ≈ 5585 km.
|
|
41
41
|
// Published spherical estimates vary 5570–5590 km depending on source.
|
|
42
|
-
const london
|
|
43
|
-
const newYork
|
|
42
|
+
const london = { latDeg: 51.5074, lonDeg: -0.1278 };
|
|
43
|
+
const newYork = { latDeg: 40.7128, lonDeg: -74.006 };
|
|
44
44
|
const r = vincentyInverse(london, newYork);
|
|
45
45
|
expect(r.distanceM / 1000).toBeGreaterThan(5550);
|
|
46
46
|
expect(r.distanceM / 1000).toBeLessThan(5620);
|
|
@@ -49,7 +49,7 @@ describe('vincentyInverse', () => {
|
|
|
49
49
|
it('London → New York forward azimuth is roughly NW (~288°)', () => {
|
|
50
50
|
const r = vincentyInverse(
|
|
51
51
|
{ latDeg: 51.5074, lonDeg: -0.1278 },
|
|
52
|
-
{ latDeg: 40.7128, lonDeg: -74.
|
|
52
|
+
{ latDeg: 40.7128, lonDeg: -74.006 }
|
|
53
53
|
);
|
|
54
54
|
// Initial bearing London → NY is roughly 288° (NW)
|
|
55
55
|
expect(r.forwardAzimuthDeg).toBeGreaterThan(270);
|
|
@@ -111,18 +111,18 @@ describe('llaToECEF and ecefToLLA', () => {
|
|
|
111
111
|
|
|
112
112
|
it('ECEF roundtrip preserves LLA within 1 mm altitude and 1e-9 deg lat/lon', () => {
|
|
113
113
|
const original: { latDeg: number; lonDeg: number; altM: number }[] = [
|
|
114
|
-
{ latDeg:
|
|
115
|
-
{ latDeg:
|
|
116
|
-
{ latDeg: -33.8688, lonDeg:
|
|
117
|
-
{ latDeg:
|
|
118
|
-
{ latDeg:
|
|
114
|
+
{ latDeg: 51.5074, lonDeg: -0.1278, altM: 10 },
|
|
115
|
+
{ latDeg: 40.7128, lonDeg: -74.006, altM: 50 },
|
|
116
|
+
{ latDeg: -33.8688, lonDeg: 151.2093, altM: 40 },
|
|
117
|
+
{ latDeg: 35.6762, lonDeg: 139.6503, altM: 40 },
|
|
118
|
+
{ latDeg: -0.1, lonDeg: -78.4678, altM: 2_850 }, // Quito
|
|
119
119
|
];
|
|
120
120
|
for (const lla of original) {
|
|
121
|
-
const ecef
|
|
122
|
-
const back
|
|
121
|
+
const ecef = llaToECEF(lla);
|
|
122
|
+
const back = ecefToLLA(ecef);
|
|
123
123
|
expect(Math.abs(back.latDeg - lla.latDeg)).toBeLessThan(1e-9);
|
|
124
124
|
expect(Math.abs(back.lonDeg - lla.lonDeg)).toBeLessThan(1e-9);
|
|
125
|
-
expect(Math.abs(back.altM
|
|
125
|
+
expect(Math.abs(back.altM - lla.altM)).toBeLessThan(0.001); // 1 mm
|
|
126
126
|
}
|
|
127
127
|
});
|
|
128
128
|
|
|
@@ -138,8 +138,8 @@ describe('llaToECEF and ecefToLLA', () => {
|
|
|
138
138
|
// ─── Great-circle waypoints ───────────────────────────────────────────────────
|
|
139
139
|
|
|
140
140
|
describe('greatCircleWaypoints', () => {
|
|
141
|
-
const london
|
|
142
|
-
const newYork = { latDeg: 40.7128, lonDeg: -74.
|
|
141
|
+
const london = { latDeg: 51.5074, lonDeg: -0.1278 };
|
|
142
|
+
const newYork = { latDeg: 40.7128, lonDeg: -74.006 };
|
|
143
143
|
|
|
144
144
|
it('returns numIntermediate+2 waypoints (endpoints included)', () => {
|
|
145
145
|
const route = greatCircleWaypoints(london, newYork, 8);
|
|
@@ -149,7 +149,7 @@ describe('greatCircleWaypoints', () => {
|
|
|
149
149
|
it('first waypoint matches origin, last matches destination', () => {
|
|
150
150
|
const route = greatCircleWaypoints(london, newYork, 4);
|
|
151
151
|
const first = route.waypoints[0];
|
|
152
|
-
const last
|
|
152
|
+
const last = route.waypoints[route.waypoints.length - 1];
|
|
153
153
|
expect(first.latDeg).toBeCloseTo(london.latDeg, 4);
|
|
154
154
|
expect(first.lonDeg).toBeCloseTo(london.lonDeg, 4);
|
|
155
155
|
expect(last.latDeg).toBeCloseTo(newYork.latDeg, 4);
|
|
@@ -165,7 +165,7 @@ describe('greatCircleWaypoints', () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('totalDistanceM matches vincentyInverse result', () => {
|
|
168
|
-
const route
|
|
168
|
+
const route = greatCircleWaypoints(london, newYork, 4);
|
|
169
169
|
const vincenty = vincentyInverse(london, newYork);
|
|
170
170
|
expect(route.totalDistanceM).toBeCloseTo(vincenty.distanceM, 0);
|
|
171
171
|
});
|
|
@@ -187,10 +187,10 @@ describe('pointInGeofence', () => {
|
|
|
187
187
|
const parisFence = {
|
|
188
188
|
id: 'paris-box',
|
|
189
189
|
vertices: [
|
|
190
|
-
{ latDeg: 48.
|
|
190
|
+
{ latDeg: 48.8, lonDeg: 2.25 },
|
|
191
191
|
{ latDeg: 48.92, lonDeg: 2.25 },
|
|
192
192
|
{ latDeg: 48.92, lonDeg: 2.42 },
|
|
193
|
-
{ latDeg: 48.
|
|
193
|
+
{ latDeg: 48.8, lonDeg: 2.42 },
|
|
194
194
|
],
|
|
195
195
|
};
|
|
196
196
|
|
|
@@ -231,7 +231,9 @@ describe('pointInGeofence', () => {
|
|
|
231
231
|
});
|
|
232
232
|
|
|
233
233
|
it('degenerate polygon (< 3 vertices) returns false', () => {
|
|
234
|
-
expect(
|
|
234
|
+
expect(
|
|
235
|
+
pointInGeofence({ latDeg: 0, lonDeg: 0 }, { id: 'bad', vertices: [{ latDeg: 0, lonDeg: 0 }] })
|
|
236
|
+
).toBe(false);
|
|
235
237
|
});
|
|
236
238
|
});
|
|
237
239
|
|
|
@@ -239,7 +241,10 @@ describe('pointInGeofence', () => {
|
|
|
239
241
|
|
|
240
242
|
describe('analyzeGeodesy', () => {
|
|
241
243
|
it('returns converged=true for non-antipodal points', () => {
|
|
242
|
-
const r = analyzeGeodesy(
|
|
244
|
+
const r = analyzeGeodesy(
|
|
245
|
+
{ latDeg: 48.8566, lonDeg: 2.3522 },
|
|
246
|
+
{ latDeg: 35.6762, lonDeg: 139.6503 }
|
|
247
|
+
);
|
|
243
248
|
expect(r.converged).toBe(true);
|
|
244
249
|
});
|
|
245
250
|
|
|
@@ -252,7 +257,10 @@ describe('analyzeGeodesy', () => {
|
|
|
252
257
|
|
|
253
258
|
describe('buildGeodesyReceipt', () => {
|
|
254
259
|
it('produces receipt with plugin=geolocation-gis and CAEL event', () => {
|
|
255
|
-
const result
|
|
260
|
+
const result = analyzeGeodesy(
|
|
261
|
+
{ latDeg: 51.5074, lonDeg: -0.1278 },
|
|
262
|
+
{ latDeg: 40.7128, lonDeg: -74.006 }
|
|
263
|
+
);
|
|
256
264
|
const receipt = buildGeodesyReceipt(result);
|
|
257
265
|
expect(receipt.plugin).toBe('geolocation-gis');
|
|
258
266
|
expect(receipt.cael.event).toBe('geolocation_gis.geodesy');
|
|
@@ -260,21 +268,24 @@ describe('buildGeodesyReceipt', () => {
|
|
|
260
268
|
});
|
|
261
269
|
|
|
262
270
|
it('accepted=true for convergent analysis', () => {
|
|
263
|
-
const result
|
|
271
|
+
const result = analyzeGeodesy({ latDeg: 0, lonDeg: 0 }, { latDeg: 1, lonDeg: 1 });
|
|
264
272
|
const receipt = buildGeodesyReceipt(result);
|
|
265
273
|
expect(receipt.acceptance.accepted).toBe(true);
|
|
266
274
|
expect(receipt.acceptance.violations).toHaveLength(0);
|
|
267
275
|
});
|
|
268
276
|
|
|
269
277
|
it('resultSummary.distanceKm matches vincenty distance', () => {
|
|
270
|
-
const result
|
|
278
|
+
const result = analyzeGeodesy(
|
|
279
|
+
{ latDeg: 51.5074, lonDeg: -0.1278 },
|
|
280
|
+
{ latDeg: 40.7128, lonDeg: -74.006 }
|
|
281
|
+
);
|
|
271
282
|
const receipt = buildGeodesyReceipt(result);
|
|
272
283
|
// Receipt distance should match the Vincenty result (within floating-point rounding to 4 dp)
|
|
273
284
|
expect(receipt.resultSummary.distanceKm).toBeCloseTo(result.vincenty.distanceM / 1000, 1);
|
|
274
285
|
});
|
|
275
286
|
|
|
276
287
|
it('uses provided runId', () => {
|
|
277
|
-
const result
|
|
288
|
+
const result = analyzeGeodesy({ latDeg: 0, lonDeg: 0 }, { latDeg: 10, lonDeg: 10 });
|
|
278
289
|
const receipt = buildGeodesyReceipt(result, { runId: 'geo-run-1' });
|
|
279
290
|
expect(receipt.runId).toBe('geo-run-1');
|
|
280
291
|
});
|
package/src/geodesy.ts
CHANGED
|
@@ -14,15 +14,12 @@
|
|
|
14
14
|
* WGS-84: NIMA TR8350.2 (3rd ed., 2000)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
DOMAIN_SIMULATION_RECEIPT_SCHEMA,
|
|
19
|
-
buildDomainSimulationReceipt,
|
|
20
|
-
} from '@holoscript/core';
|
|
17
|
+
import { DOMAIN_SIMULATION_RECEIPT_SCHEMA, buildDomainSimulationReceipt } from '@holoscript/core';
|
|
21
18
|
|
|
22
19
|
// ─── WGS-84 constants ─────────────────────────────────────────────────────────
|
|
23
|
-
const WGS84_A
|
|
24
|
-
const WGS84_F
|
|
25
|
-
const WGS84_B
|
|
20
|
+
const WGS84_A = 6_378_137.0; // semi-major axis (m)
|
|
21
|
+
const WGS84_F = 1 / 298.257_223_563; // flattening
|
|
22
|
+
const WGS84_B = WGS84_A * (1 - WGS84_F); // semi-minor axis (m)
|
|
26
23
|
const WGS84_E2 = 2 * WGS84_F - WGS84_F ** 2; // first eccentricity squared
|
|
27
24
|
// second eccentricity squared
|
|
28
25
|
const WGS84_EP2 = WGS84_E2 / (1 - WGS84_E2);
|
|
@@ -31,9 +28,9 @@ const WGS84_EP2 = WGS84_E2 / (1 - WGS84_E2);
|
|
|
31
28
|
|
|
32
29
|
/** Geodetic coordinates (WGS-84). Lat/lon in decimal degrees, altitude in metres. */
|
|
33
30
|
export interface LatLonAlt {
|
|
34
|
-
latDeg:
|
|
35
|
-
lonDeg:
|
|
36
|
-
altM:
|
|
31
|
+
latDeg: number; // −90 … +90
|
|
32
|
+
lonDeg: number; // −180 … +180
|
|
33
|
+
altM: number; // metres above WGS-84 ellipsoid
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
/** Earth-Centered Earth-Fixed Cartesian coordinates (metres). */
|
|
@@ -45,47 +42,47 @@ export interface ECEF {
|
|
|
45
42
|
|
|
46
43
|
/** Geofence defined by an ordered list of vertices forming a closed polygon. */
|
|
47
44
|
export interface GeofencePolygon {
|
|
48
|
-
id:
|
|
45
|
+
id: string;
|
|
49
46
|
vertices: { latDeg: number; lonDeg: number }[];
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
/** Result of Vincenty inverse computation. */
|
|
53
50
|
export interface VincentyResult {
|
|
54
|
-
distanceM:
|
|
51
|
+
distanceM: number; // geodesic distance (m)
|
|
55
52
|
forwardAzimuthDeg: number; // bearing from p1 → p2 (deg, 0=N, 90=E)
|
|
56
|
-
backAzimuthDeg:
|
|
53
|
+
backAzimuthDeg: number; // bearing from p2 → p1 (deg)
|
|
57
54
|
/** true if points are antipodal (distance undefined within machine epsilon) */
|
|
58
|
-
antipodal:
|
|
55
|
+
antipodal: boolean;
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
/** A sequence of waypoints along a great-circle path. */
|
|
62
59
|
export interface GreatCircleRoute {
|
|
63
|
-
origin:
|
|
60
|
+
origin: { latDeg: number; lonDeg: number };
|
|
64
61
|
destination: { latDeg: number; lonDeg: number };
|
|
65
|
-
waypoints:
|
|
62
|
+
waypoints: { latDeg: number; lonDeg: number; fractionOfTotal: number }[];
|
|
66
63
|
totalDistanceM: number;
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
/** Combined result from a full geodesy analysis session. */
|
|
70
67
|
export interface GeodesyAnalysisResult {
|
|
71
|
-
vincenty:
|
|
72
|
-
route:
|
|
68
|
+
vincenty: VincentyResult;
|
|
69
|
+
route: GreatCircleRoute;
|
|
73
70
|
ecefOrigin: ECEF;
|
|
74
|
-
ecefDest:
|
|
75
|
-
converged:
|
|
71
|
+
ecefDest: ECEF;
|
|
72
|
+
converged: boolean;
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
export interface GeodesyReceipt {
|
|
79
|
-
plugin:
|
|
80
|
-
runId:
|
|
81
|
-
payloadHash:
|
|
76
|
+
plugin: string;
|
|
77
|
+
runId: string;
|
|
78
|
+
payloadHash: string;
|
|
82
79
|
hashAlgorithm: string;
|
|
83
|
-
cael:
|
|
84
|
-
acceptance:
|
|
80
|
+
cael: { event: string; schemaVersion: string; ts: string };
|
|
81
|
+
acceptance: { accepted: boolean; violations: string[] };
|
|
85
82
|
resultSummary: {
|
|
86
|
-
distanceKm:
|
|
87
|
-
forwardAzimuthDeg:
|
|
88
|
-
waypointCount:
|
|
83
|
+
distanceKm: number;
|
|
84
|
+
forwardAzimuthDeg: number;
|
|
85
|
+
waypointCount: number;
|
|
89
86
|
};
|
|
90
87
|
}
|
|
91
88
|
|
|
@@ -94,9 +91,15 @@ export interface GeodesyReceipt {
|
|
|
94
91
|
const DEG = Math.PI / 180;
|
|
95
92
|
const RAD = 180 / Math.PI;
|
|
96
93
|
|
|
97
|
-
function toRad(deg: number): number {
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
function toRad(deg: number): number {
|
|
95
|
+
return deg * DEG;
|
|
96
|
+
}
|
|
97
|
+
function toDeg(rad: number): number {
|
|
98
|
+
return rad * RAD;
|
|
99
|
+
}
|
|
100
|
+
function normalizeDeg(d: number): number {
|
|
101
|
+
return ((d % 360) + 360) % 360;
|
|
102
|
+
}
|
|
100
103
|
|
|
101
104
|
/** Prime vertical radius of curvature N(φ) */
|
|
102
105
|
function primeVerticalRadius(sinPhi: number): number {
|
|
@@ -112,17 +115,19 @@ function primeVerticalRadius(sinPhi: number): number {
|
|
|
112
115
|
*/
|
|
113
116
|
export function vincentyInverse(
|
|
114
117
|
p1: { latDeg: number; lonDeg: number },
|
|
115
|
-
p2: { latDeg: number; lonDeg: number }
|
|
118
|
+
p2: { latDeg: number; lonDeg: number }
|
|
116
119
|
): VincentyResult {
|
|
117
120
|
const phi1 = toRad(p1.latDeg);
|
|
118
121
|
const phi2 = toRad(p2.latDeg);
|
|
119
|
-
const L
|
|
122
|
+
const L = toRad(p2.lonDeg - p1.lonDeg);
|
|
120
123
|
|
|
121
124
|
const U1 = Math.atan((1 - WGS84_F) * Math.tan(phi1));
|
|
122
125
|
const U2 = Math.atan((1 - WGS84_F) * Math.tan(phi2));
|
|
123
126
|
|
|
124
|
-
const sinU1 = Math.sin(U1),
|
|
125
|
-
|
|
127
|
+
const sinU1 = Math.sin(U1),
|
|
128
|
+
cosU1 = Math.cos(U1);
|
|
129
|
+
const sinU2 = Math.sin(U2),
|
|
130
|
+
cosU2 = Math.cos(U2);
|
|
126
131
|
|
|
127
132
|
let lambda = L;
|
|
128
133
|
let lambdaP: number;
|
|
@@ -137,8 +142,7 @@ export function vincentyInverse(
|
|
|
137
142
|
cosLambda = Math.cos(lambda);
|
|
138
143
|
|
|
139
144
|
sinSigma = Math.sqrt(
|
|
140
|
-
(cosU2 * sinLambda) ** 2 +
|
|
141
|
-
(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) ** 2,
|
|
145
|
+
(cosU2 * sinLambda) ** 2 + (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) ** 2
|
|
142
146
|
);
|
|
143
147
|
|
|
144
148
|
if (sinSigma === 0) {
|
|
@@ -146,35 +150,37 @@ export function vincentyInverse(
|
|
|
146
150
|
return { distanceM: 0, forwardAzimuthDeg: 0, backAzimuthDeg: 0, antipodal: false };
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
cosSigma
|
|
150
|
-
sigma
|
|
151
|
-
sinAlpha
|
|
153
|
+
cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
|
|
154
|
+
sigma = Math.atan2(sinSigma, cosSigma);
|
|
155
|
+
sinAlpha = (cosU1 * cosU2 * sinLambda) / sinSigma;
|
|
152
156
|
cos2Alpha = 1 - sinAlpha * sinAlpha;
|
|
153
157
|
|
|
154
|
-
cos2SigmaM = cos2Alpha !== 0
|
|
155
|
-
? cosSigma - (2 * sinU1 * sinU2) / cos2Alpha
|
|
156
|
-
: 0; // equatorial line
|
|
158
|
+
cos2SigmaM = cos2Alpha !== 0 ? cosSigma - (2 * sinU1 * sinU2) / cos2Alpha : 0; // equatorial line
|
|
157
159
|
|
|
158
160
|
C = (WGS84_F / 16) * cos2Alpha * (4 + WGS84_F * (4 - 3 * cos2Alpha));
|
|
159
161
|
|
|
160
162
|
lambdaP = lambda;
|
|
161
|
-
lambda
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
lambda =
|
|
164
|
+
L +
|
|
165
|
+
(1 - C) *
|
|
166
|
+
WGS84_F *
|
|
167
|
+
sinAlpha *
|
|
168
|
+
(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM ** 2)));
|
|
164
169
|
} while (Math.abs(lambda - lambdaP) > 1e-12 && ++iterations < 200);
|
|
165
170
|
|
|
166
171
|
const antipodal = iterations >= 200;
|
|
167
172
|
|
|
168
|
-
const uSq
|
|
173
|
+
const uSq = cos2Alpha * WGS84_EP2;
|
|
169
174
|
const A_vc = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
|
|
170
175
|
const B_vc = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
|
|
171
176
|
|
|
172
|
-
const deltaSigma =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
const deltaSigma =
|
|
178
|
+
B_vc *
|
|
179
|
+
sinSigma *
|
|
180
|
+
(cos2SigmaM +
|
|
181
|
+
(B_vc / 4) *
|
|
182
|
+
(cosSigma * (-1 + 2 * cos2SigmaM ** 2) -
|
|
183
|
+
(B_vc / 6) * cos2SigmaM * (-3 + 4 * sinSigma ** 2) * (-3 + 4 * cos2SigmaM ** 2)));
|
|
178
184
|
|
|
179
185
|
const distanceM = WGS84_B * A_vc * (sigma - deltaSigma);
|
|
180
186
|
|
|
@@ -184,7 +190,7 @@ export function vincentyInverse(
|
|
|
184
190
|
return {
|
|
185
191
|
distanceM,
|
|
186
192
|
forwardAzimuthDeg: normalizeDeg(toDeg(fwdAz)),
|
|
187
|
-
backAzimuthDeg:
|
|
193
|
+
backAzimuthDeg: normalizeDeg(toDeg(bakAz)),
|
|
188
194
|
antipodal,
|
|
189
195
|
};
|
|
190
196
|
}
|
|
@@ -193,13 +199,13 @@ export function vincentyInverse(
|
|
|
193
199
|
|
|
194
200
|
/** Convert WGS-84 geodetic coordinates to ECEF Cartesian. */
|
|
195
201
|
export function llaToECEF(lla: LatLonAlt): ECEF {
|
|
196
|
-
const phi
|
|
202
|
+
const phi = toRad(lla.latDeg);
|
|
197
203
|
const lambda = toRad(lla.lonDeg);
|
|
198
|
-
const h
|
|
204
|
+
const h = lla.altM;
|
|
199
205
|
|
|
200
206
|
const sinPhi = Math.sin(phi);
|
|
201
207
|
const cosPhi = Math.cos(phi);
|
|
202
|
-
const N
|
|
208
|
+
const N = primeVerticalRadius(sinPhi);
|
|
203
209
|
|
|
204
210
|
return {
|
|
205
211
|
xM: (N + h) * cosPhi * Math.cos(lambda),
|
|
@@ -214,7 +220,7 @@ export function llaToECEF(lla: LatLonAlt): ECEF {
|
|
|
214
220
|
*/
|
|
215
221
|
export function ecefToLLA(ecef: ECEF): LatLonAlt {
|
|
216
222
|
const { xM, yM, zM } = ecef;
|
|
217
|
-
const p
|
|
223
|
+
const p = Math.sqrt(xM * xM + yM * yM);
|
|
218
224
|
const lambda = Math.atan2(yM, xM);
|
|
219
225
|
|
|
220
226
|
// Initial estimate (Bowring)
|
|
@@ -222,20 +228,23 @@ export function ecefToLLA(ecef: ECEF): LatLonAlt {
|
|
|
222
228
|
|
|
223
229
|
for (let i = 0; i < 10; i++) {
|
|
224
230
|
const sinPhi = Math.sin(phi);
|
|
225
|
-
const N
|
|
231
|
+
const N = primeVerticalRadius(sinPhi);
|
|
226
232
|
const phiNew = Math.atan2(zM + WGS84_E2 * N * sinPhi, p);
|
|
227
|
-
if (Math.abs(phiNew - phi) < 1e-12) {
|
|
233
|
+
if (Math.abs(phiNew - phi) < 1e-12) {
|
|
234
|
+
phi = phiNew;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
228
237
|
phi = phiNew;
|
|
229
238
|
}
|
|
230
239
|
|
|
231
240
|
const sinPhi = Math.sin(phi);
|
|
232
|
-
const N
|
|
233
|
-
const h
|
|
241
|
+
const N = primeVerticalRadius(sinPhi);
|
|
242
|
+
const h = p / Math.cos(phi) - N;
|
|
234
243
|
|
|
235
244
|
return {
|
|
236
245
|
latDeg: toDeg(phi),
|
|
237
246
|
lonDeg: toDeg(lambda),
|
|
238
|
-
altM:
|
|
247
|
+
altM: h,
|
|
239
248
|
};
|
|
240
249
|
}
|
|
241
250
|
|
|
@@ -249,21 +258,22 @@ export function ecefToLLA(ecef: ECEF): LatLonAlt {
|
|
|
249
258
|
export function greatCircleWaypoints(
|
|
250
259
|
p1: { latDeg: number; lonDeg: number },
|
|
251
260
|
p2: { latDeg: number; lonDeg: number },
|
|
252
|
-
numIntermediate = 8
|
|
261
|
+
numIntermediate = 8
|
|
253
262
|
): GreatCircleRoute {
|
|
254
263
|
const vincenty = vincentyInverse(p1, p2);
|
|
255
264
|
|
|
256
265
|
// Convert to ECEF unit vectors (ignoring altitude — surface only)
|
|
257
266
|
const toUnitVec = (p: { latDeg: number; lonDeg: number }) => {
|
|
258
|
-
const phi = toRad(p.latDeg),
|
|
259
|
-
|
|
267
|
+
const phi = toRad(p.latDeg),
|
|
268
|
+
lam = toRad(p.lonDeg);
|
|
269
|
+
const cp = Math.cos(phi);
|
|
260
270
|
return { x: cp * Math.cos(lam), y: cp * Math.sin(lam), z: Math.sin(phi) };
|
|
261
271
|
};
|
|
262
272
|
|
|
263
273
|
const v1 = toUnitVec(p1);
|
|
264
274
|
const v2 = toUnitVec(p2);
|
|
265
275
|
|
|
266
|
-
const dot
|
|
276
|
+
const dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
|
|
267
277
|
const omega = Math.acos(Math.max(-1, Math.min(1, dot))); // angular separation
|
|
268
278
|
|
|
269
279
|
const total = numIntermediate + 2;
|
|
@@ -271,7 +281,9 @@ export function greatCircleWaypoints(
|
|
|
271
281
|
const t = k / (total - 1);
|
|
272
282
|
let vx: number, vy: number, vz: number;
|
|
273
283
|
if (omega < 1e-10) {
|
|
274
|
-
vx = v1.x;
|
|
284
|
+
vx = v1.x;
|
|
285
|
+
vy = v1.y;
|
|
286
|
+
vz = v1.z;
|
|
275
287
|
} else {
|
|
276
288
|
const s = Math.sin(omega);
|
|
277
289
|
const a = Math.sin((1 - t) * omega) / s;
|
|
@@ -287,8 +299,8 @@ export function greatCircleWaypoints(
|
|
|
287
299
|
});
|
|
288
300
|
|
|
289
301
|
return {
|
|
290
|
-
origin:
|
|
291
|
-
destination:
|
|
302
|
+
origin: { latDeg: p1.latDeg, lonDeg: p1.lonDeg },
|
|
303
|
+
destination: { latDeg: p2.latDeg, lonDeg: p2.lonDeg },
|
|
292
304
|
waypoints,
|
|
293
305
|
totalDistanceM: vincenty.distanceM,
|
|
294
306
|
};
|
|
@@ -303,36 +315,36 @@ export function greatCircleWaypoints(
|
|
|
303
315
|
* Returns true if the point is inside or on the boundary.
|
|
304
316
|
*/
|
|
305
317
|
export function pointInGeofence(
|
|
306
|
-
point:
|
|
307
|
-
polygon: GeofencePolygon
|
|
318
|
+
point: { latDeg: number; lonDeg: number },
|
|
319
|
+
polygon: GeofencePolygon
|
|
308
320
|
): boolean {
|
|
309
321
|
const verts = polygon.vertices;
|
|
310
322
|
if (verts.length < 3) return false;
|
|
311
323
|
|
|
312
324
|
// Project all vertices and test point onto flat plane using gnomonic projection
|
|
313
325
|
// centred on the first vertex (good for polygons up to ~500 km wide).
|
|
314
|
-
const phi0
|
|
315
|
-
const lam0
|
|
326
|
+
const phi0 = toRad(verts[0].latDeg);
|
|
327
|
+
const lam0 = toRad(verts[0].lonDeg);
|
|
316
328
|
const cosPhi0 = Math.cos(phi0);
|
|
317
329
|
const sinPhi0 = Math.sin(phi0);
|
|
318
330
|
|
|
319
331
|
const project = (lat: number, lon: number): [number, number] => {
|
|
320
|
-
const phi = toRad(lat),
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const
|
|
332
|
+
const phi = toRad(lat),
|
|
333
|
+
lam = toRad(lon);
|
|
334
|
+
const c = sinPhi0 * Math.sin(phi) + cosPhi0 * Math.cos(phi) * Math.cos(lam - lam0);
|
|
335
|
+
const x = (Math.cos(phi) * Math.sin(lam - lam0)) / c;
|
|
336
|
+
const y = (cosPhi0 * Math.sin(phi) - sinPhi0 * Math.cos(phi) * Math.cos(lam - lam0)) / c;
|
|
324
337
|
return [x, y];
|
|
325
338
|
};
|
|
326
339
|
|
|
327
340
|
const [px, py] = project(point.latDeg, point.lonDeg);
|
|
328
|
-
const n
|
|
329
|
-
let inside
|
|
341
|
+
const n = verts.length;
|
|
342
|
+
let inside = false;
|
|
330
343
|
|
|
331
344
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
332
345
|
const [xi, yi] = project(verts[i].latDeg, verts[i].lonDeg);
|
|
333
346
|
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;
|
|
347
|
+
const intersect = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
|
|
336
348
|
if (intersect) inside = !inside;
|
|
337
349
|
}
|
|
338
350
|
|
|
@@ -347,17 +359,17 @@ export function pointInGeofence(
|
|
|
347
359
|
export function analyzeGeodesy(
|
|
348
360
|
origin: { latDeg: number; lonDeg: number; altM?: number },
|
|
349
361
|
destination: { latDeg: number; lonDeg: number; altM?: number },
|
|
350
|
-
numWaypoints = 8
|
|
362
|
+
numWaypoints = 8
|
|
351
363
|
): GeodesyAnalysisResult {
|
|
352
|
-
const p1
|
|
353
|
-
const p2
|
|
364
|
+
const p1 = { latDeg: origin.latDeg, lonDeg: origin.lonDeg };
|
|
365
|
+
const p2 = { latDeg: destination.latDeg, lonDeg: destination.lonDeg };
|
|
354
366
|
const lla1: LatLonAlt = { ...p1, altM: origin.altM ?? 0 };
|
|
355
367
|
const lla2: LatLonAlt = { ...p2, altM: destination.altM ?? 0 };
|
|
356
368
|
|
|
357
|
-
const vincenty
|
|
358
|
-
const route
|
|
369
|
+
const vincenty = vincentyInverse(p1, p2);
|
|
370
|
+
const route = greatCircleWaypoints(p1, p2, numWaypoints);
|
|
359
371
|
const ecefOrigin = llaToECEF(lla1);
|
|
360
|
-
const ecefDest
|
|
372
|
+
const ecefDest = llaToECEF(lla2);
|
|
361
373
|
|
|
362
374
|
return { vincenty, route, ecefOrigin, ecefDest, converged: !vincenty.antipodal };
|
|
363
375
|
}
|
|
@@ -366,31 +378,34 @@ export function analyzeGeodesy(
|
|
|
366
378
|
|
|
367
379
|
export function buildGeodesyReceipt(
|
|
368
380
|
result: GeodesyAnalysisResult,
|
|
369
|
-
options?: { runId?: string }
|
|
381
|
+
options?: { runId?: string }
|
|
370
382
|
): GeodesyReceipt {
|
|
371
383
|
const violations: Array<{ criterion: string; message: string }> = [];
|
|
372
384
|
if (result.vincenty.antipodal)
|
|
373
|
-
violations.push({
|
|
385
|
+
violations.push({
|
|
386
|
+
criterion: 'convergence',
|
|
387
|
+
message: 'points are antipodal — geodesic undefined',
|
|
388
|
+
});
|
|
374
389
|
if (!result.converged)
|
|
375
390
|
violations.push({ criterion: 'convergence', message: 'Vincenty iteration did not converge' });
|
|
376
391
|
|
|
377
392
|
const raw = buildDomainSimulationReceipt({
|
|
378
|
-
plugin:
|
|
393
|
+
plugin: 'geolocation-gis',
|
|
379
394
|
pluginVersion: '1.0.0',
|
|
380
|
-
runId:
|
|
381
|
-
modelId:
|
|
395
|
+
runId: options?.runId ?? `geo-${Date.now().toString(36)}`,
|
|
396
|
+
modelId: 'geodesy-analysis',
|
|
382
397
|
solverConfig: {
|
|
383
398
|
solverType: 'vincenty-wgs84',
|
|
384
|
-
scale:
|
|
399
|
+
scale: 'global',
|
|
385
400
|
},
|
|
386
401
|
resultSummary: {
|
|
387
|
-
distanceKm:
|
|
402
|
+
distanceKm: +(result.vincenty.distanceM / 1000).toFixed(4),
|
|
388
403
|
forwardAzimuthDeg: +result.vincenty.forwardAzimuthDeg.toFixed(4),
|
|
389
|
-
waypointCount:
|
|
404
|
+
waypointCount: result.route.waypoints.length,
|
|
390
405
|
},
|
|
391
406
|
cael: {
|
|
392
|
-
version:
|
|
393
|
-
event:
|
|
407
|
+
version: 'cael.v1',
|
|
408
|
+
event: 'geolocation_gis.geodesy',
|
|
394
409
|
solverType: 'geolocation-gis.vincenty-wgs84',
|
|
395
410
|
},
|
|
396
411
|
acceptance: { accepted: violations.length === 0, violations },
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,18 @@ import { createGeofenceHandler } from './traits/GeofenceTrait';
|
|
|
13
13
|
|
|
14
14
|
export * from './geodesy';
|
|
15
15
|
|
|
16
|
-
export const pluginMeta = {
|
|
17
|
-
|
|
16
|
+
export const pluginMeta = {
|
|
17
|
+
name: '@holoscript/plugin-geolocation-gis',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
traits: ['map_view', 'route', 'poi', 'geocode', 'geofence', 'vincenty_geodesy'],
|
|
20
|
+
};
|
|
21
|
+
export const traitHandlers = [
|
|
22
|
+
createMapViewHandler(),
|
|
23
|
+
createRouteHandler(),
|
|
24
|
+
createPOIHandler(),
|
|
25
|
+
createGeocodeHandler(),
|
|
26
|
+
createGeofenceHandler(),
|
|
27
|
+
];
|
|
18
28
|
|
|
19
29
|
// Runtime integration — behavioral `vincenty_geodesy` handler + opt-in registrar
|
|
20
30
|
// that wire the real Vincenty solver into HoloScriptRuntime via the shared P1
|
package/src/runtime.ts
CHANGED
|
@@ -48,13 +48,13 @@ export interface RuntimeTraitHandler {
|
|
|
48
48
|
onAttach?: (
|
|
49
49
|
node: unknown,
|
|
50
50
|
config: VincentyGeodesyTraitConfig,
|
|
51
|
-
context: TraitDispatchContext
|
|
51
|
+
context: TraitDispatchContext
|
|
52
52
|
) => void;
|
|
53
53
|
onUpdate?: (
|
|
54
54
|
node: unknown,
|
|
55
55
|
config: VincentyGeodesyTraitConfig,
|
|
56
56
|
context: TraitDispatchContext,
|
|
57
|
-
delta: number
|
|
57
|
+
delta: number
|
|
58
58
|
) => void;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -69,7 +69,7 @@ interface VincentyNode {
|
|
|
69
69
|
function solveOntoNode(
|
|
70
70
|
node: unknown,
|
|
71
71
|
config: VincentyGeodesyTraitConfig | undefined,
|
|
72
|
-
context: TraitDispatchContext
|
|
72
|
+
context: TraitDispatchContext
|
|
73
73
|
): void {
|
|
74
74
|
const carrier = node as VincentyNode;
|
|
75
75
|
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
/** @geocode Trait — Address to coordinate conversion. @trait geocode */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface GeocodeConfig {
|
|
4
|
+
export interface GeocodeConfig {
|
|
5
|
+
address: string;
|
|
6
|
+
provider: 'nominatim' | 'google' | 'mapbox';
|
|
7
|
+
language: string;
|
|
8
|
+
}
|
|
5
9
|
const defaultConfig: GeocodeConfig = { address: '', provider: 'nominatim', language: 'en' };
|
|
6
10
|
|
|
7
11
|
export function createGeocodeHandler(): TraitHandler<GeocodeConfig> {
|
|
8
|
-
return {
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
return {
|
|
13
|
+
name: 'geocode',
|
|
14
|
+
defaultConfig,
|
|
15
|
+
onAttach(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext) {
|
|
16
|
+
n.__geocodeState = { result: null, isGeocoding: false };
|
|
17
|
+
ctx.emit?.('geocode:ready');
|
|
18
|
+
},
|
|
19
|
+
onDetach(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext) {
|
|
20
|
+
delete n.__geocodeState;
|
|
21
|
+
ctx.emit?.('geocode:detached');
|
|
22
|
+
},
|
|
11
23
|
onUpdate() {},
|
|
12
24
|
onEvent(n: HSPlusNode, _c: GeocodeConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
-
const s = n.__geocodeState as Record<string, unknown> | undefined;
|
|
14
|
-
if (
|
|
15
|
-
if (e.type === 'geocode:
|
|
25
|
+
const s = n.__geocodeState as Record<string, unknown> | undefined;
|
|
26
|
+
if (!s) return;
|
|
27
|
+
if (e.type === 'geocode:lookup') {
|
|
28
|
+
s.isGeocoding = true;
|
|
29
|
+
ctx.emit?.('geocode:searching', { address: e.payload?.address });
|
|
30
|
+
}
|
|
31
|
+
if (e.type === 'geocode:result') {
|
|
32
|
+
s.result = e.payload;
|
|
33
|
+
s.isGeocoding = false;
|
|
34
|
+
ctx.emit?.('geocode:found', e.payload);
|
|
35
|
+
}
|
|
16
36
|
},
|
|
17
37
|
};
|
|
18
38
|
}
|
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
/** @geofence Trait — Geographic boundary monitoring. @trait geofence */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface GeofenceConfig {
|
|
5
|
-
|
|
4
|
+
export interface GeofenceConfig {
|
|
5
|
+
center: { lat: number; lng: number };
|
|
6
|
+
radiusM: number;
|
|
7
|
+
shape: 'circle' | 'polygon';
|
|
8
|
+
polygonPoints?: Array<{ lat: number; lng: number }>;
|
|
9
|
+
triggerOnEnter: boolean;
|
|
10
|
+
triggerOnExit: boolean;
|
|
11
|
+
}
|
|
12
|
+
const defaultConfig: GeofenceConfig = {
|
|
13
|
+
center: { lat: 0, lng: 0 },
|
|
14
|
+
radiusM: 100,
|
|
15
|
+
shape: 'circle',
|
|
16
|
+
triggerOnEnter: true,
|
|
17
|
+
triggerOnExit: true,
|
|
18
|
+
};
|
|
6
19
|
|
|
7
20
|
export function createGeofenceHandler(): TraitHandler<GeofenceConfig> {
|
|
8
|
-
return {
|
|
9
|
-
|
|
10
|
-
|
|
21
|
+
return {
|
|
22
|
+
name: 'geofence',
|
|
23
|
+
defaultConfig,
|
|
24
|
+
onAttach(n: HSPlusNode, c: GeofenceConfig, ctx: TraitContext) {
|
|
25
|
+
n.__geofenceState = { isInside: false, lastCheck: null };
|
|
26
|
+
ctx.emit?.('geofence:created', { radius: c.radiusM, shape: c.shape });
|
|
27
|
+
},
|
|
28
|
+
onDetach(n: HSPlusNode, _c: GeofenceConfig, ctx: TraitContext) {
|
|
29
|
+
delete n.__geofenceState;
|
|
30
|
+
ctx.emit?.('geofence:removed');
|
|
31
|
+
},
|
|
11
32
|
onUpdate() {},
|
|
12
33
|
onEvent(n: HSPlusNode, c: GeofenceConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
-
const s = n.__geofenceState as Record<string, unknown> | undefined;
|
|
34
|
+
const s = n.__geofenceState as Record<string, unknown> | undefined;
|
|
35
|
+
if (!s) return;
|
|
14
36
|
if (e.type === 'geofence:check') {
|
|
15
37
|
const pos = e.payload as unknown as { lat: number; lng: number };
|
|
16
|
-
const dist =
|
|
38
|
+
const dist =
|
|
39
|
+
Math.sqrt(Math.pow(pos.lat - c.center.lat, 2) + Math.pow(pos.lng - c.center.lng, 2)) *
|
|
40
|
+
111320;
|
|
17
41
|
const inside = dist <= c.radiusM;
|
|
18
42
|
const wasInside = s.isInside as boolean;
|
|
19
|
-
s.isInside = inside;
|
|
43
|
+
s.isInside = inside;
|
|
44
|
+
s.lastCheck = Date.now();
|
|
20
45
|
if (inside && !wasInside && c.triggerOnEnter) ctx.emit?.('geofence:entered');
|
|
21
46
|
if (!inside && wasInside && c.triggerOnExit) ctx.emit?.('geofence:exited');
|
|
22
47
|
}
|
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
/** @map_view Trait — Interactive map display. @trait map_view */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface MapViewConfig {
|
|
5
|
-
|
|
4
|
+
export interface MapViewConfig {
|
|
5
|
+
center: { lat: number; lng: number };
|
|
6
|
+
zoom: number;
|
|
7
|
+
tileProvider: 'osm' | 'mapbox' | 'google' | 'custom';
|
|
8
|
+
style: 'streets' | 'satellite' | 'terrain' | 'dark';
|
|
9
|
+
maxZoom: number;
|
|
10
|
+
minZoom: number;
|
|
11
|
+
}
|
|
12
|
+
const defaultConfig: MapViewConfig = {
|
|
13
|
+
center: { lat: 0, lng: 0 },
|
|
14
|
+
zoom: 10,
|
|
15
|
+
tileProvider: 'osm',
|
|
16
|
+
style: 'streets',
|
|
17
|
+
maxZoom: 18,
|
|
18
|
+
minZoom: 1,
|
|
19
|
+
};
|
|
6
20
|
|
|
7
21
|
export function createMapViewHandler(): TraitHandler<MapViewConfig> {
|
|
8
|
-
return {
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
return {
|
|
23
|
+
name: 'map_view',
|
|
24
|
+
defaultConfig,
|
|
25
|
+
onAttach(n: HSPlusNode, c: MapViewConfig, ctx: TraitContext) {
|
|
26
|
+
n.__mapState = { center: c.center, zoom: c.zoom, bounds: null };
|
|
27
|
+
ctx.emit?.('map:ready', { center: c.center });
|
|
28
|
+
},
|
|
29
|
+
onDetach(n: HSPlusNode, _c: MapViewConfig, ctx: TraitContext) {
|
|
30
|
+
delete n.__mapState;
|
|
31
|
+
ctx.emit?.('map:destroyed');
|
|
32
|
+
},
|
|
11
33
|
onUpdate() {},
|
|
12
34
|
onEvent(n: HSPlusNode, _c: MapViewConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
-
const s = n.__mapState as Record<string, unknown> | undefined;
|
|
14
|
-
if (
|
|
15
|
-
if (e.type === 'map:
|
|
35
|
+
const s = n.__mapState as Record<string, unknown> | undefined;
|
|
36
|
+
if (!s) return;
|
|
37
|
+
if (e.type === 'map:pan') {
|
|
38
|
+
s.center = e.payload?.center;
|
|
39
|
+
ctx.emit?.('map:moved', { center: s.center });
|
|
40
|
+
}
|
|
41
|
+
if (e.type === 'map:zoom') {
|
|
42
|
+
s.zoom = e.payload?.zoom;
|
|
43
|
+
ctx.emit?.('map:zoomed', { zoom: s.zoom });
|
|
44
|
+
}
|
|
16
45
|
},
|
|
17
46
|
};
|
|
18
47
|
}
|
package/src/traits/POITrait.ts
CHANGED
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
/** @poi Trait — Point of Interest. @trait poi */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface POIConfig {
|
|
4
|
+
export interface POIConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
lat: number;
|
|
7
|
+
lng: number;
|
|
8
|
+
category: string;
|
|
9
|
+
icon?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
rating?: number;
|
|
12
|
+
}
|
|
5
13
|
const defaultConfig: POIConfig = { name: '', lat: 0, lng: 0, category: 'general' };
|
|
6
14
|
|
|
7
15
|
export function createPOIHandler(): TraitHandler<POIConfig> {
|
|
8
|
-
return {
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
return {
|
|
17
|
+
name: 'poi',
|
|
18
|
+
defaultConfig,
|
|
19
|
+
onAttach(n: HSPlusNode, c: POIConfig, ctx: TraitContext) {
|
|
20
|
+
n.__poiState = { visible: true, selected: false };
|
|
21
|
+
ctx.emit?.('poi:added', { name: c.name, category: c.category });
|
|
22
|
+
},
|
|
23
|
+
onDetach(n: HSPlusNode, _c: POIConfig, ctx: TraitContext) {
|
|
24
|
+
delete n.__poiState;
|
|
25
|
+
ctx.emit?.('poi:removed');
|
|
26
|
+
},
|
|
11
27
|
onUpdate() {},
|
|
12
28
|
onEvent(n: HSPlusNode, c: POIConfig, ctx: TraitContext, e: TraitEvent) {
|
|
13
|
-
const s = n.__poiState as Record<string, unknown> | undefined;
|
|
14
|
-
if (
|
|
15
|
-
if (e.type === 'poi:
|
|
29
|
+
const s = n.__poiState as Record<string, unknown> | undefined;
|
|
30
|
+
if (!s) return;
|
|
31
|
+
if (e.type === 'poi:select') {
|
|
32
|
+
s.selected = true;
|
|
33
|
+
ctx.emit?.('poi:selected', { name: c.name });
|
|
34
|
+
}
|
|
35
|
+
if (e.type === 'poi:deselect') {
|
|
36
|
+
s.selected = false;
|
|
37
|
+
ctx.emit?.('poi:deselected');
|
|
38
|
+
}
|
|
16
39
|
},
|
|
17
40
|
};
|
|
18
41
|
}
|
package/src/traits/RouteTrait.ts
CHANGED
|
@@ -1,18 +1,58 @@
|
|
|
1
1
|
/** @route Trait — Route planning and navigation. @trait route */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface Waypoint {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface Waypoint {
|
|
5
|
+
lat: number;
|
|
6
|
+
lng: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RouteConfig {
|
|
10
|
+
origin: Waypoint;
|
|
11
|
+
destination: Waypoint;
|
|
12
|
+
waypoints: Waypoint[];
|
|
13
|
+
mode: 'driving' | 'walking' | 'cycling' | 'transit';
|
|
14
|
+
avoidTolls: boolean;
|
|
15
|
+
avoidHighways: boolean;
|
|
16
|
+
}
|
|
17
|
+
const defaultConfig: RouteConfig = {
|
|
18
|
+
origin: { lat: 0, lng: 0 },
|
|
19
|
+
destination: { lat: 0, lng: 0 },
|
|
20
|
+
waypoints: [],
|
|
21
|
+
mode: 'driving',
|
|
22
|
+
avoidTolls: false,
|
|
23
|
+
avoidHighways: false,
|
|
24
|
+
};
|
|
7
25
|
|
|
8
26
|
export function createRouteHandler(): TraitHandler<RouteConfig> {
|
|
9
|
-
return {
|
|
10
|
-
|
|
11
|
-
|
|
27
|
+
return {
|
|
28
|
+
name: 'route',
|
|
29
|
+
defaultConfig,
|
|
30
|
+
onAttach(n: HSPlusNode, c: RouteConfig, ctx: TraitContext) {
|
|
31
|
+
n.__routeState = { distanceKm: 0, durationMin: 0, isCalculating: false };
|
|
32
|
+
ctx.emit?.('route:created', { mode: c.mode });
|
|
33
|
+
},
|
|
34
|
+
onDetach(n: HSPlusNode, _c: RouteConfig, ctx: TraitContext) {
|
|
35
|
+
delete n.__routeState;
|
|
36
|
+
ctx.emit?.('route:cleared');
|
|
37
|
+
},
|
|
12
38
|
onUpdate() {},
|
|
13
39
|
onEvent(n: HSPlusNode, _c: RouteConfig, ctx: TraitContext, e: TraitEvent) {
|
|
14
|
-
if (e.type === 'route:calculate') {
|
|
15
|
-
|
|
40
|
+
if (e.type === 'route:calculate') {
|
|
41
|
+
const s = n.__routeState as Record<string, unknown>;
|
|
42
|
+
if (s) {
|
|
43
|
+
s.isCalculating = true;
|
|
44
|
+
ctx.emit?.('route:calculating');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (e.type === 'route:result') {
|
|
48
|
+
const s = n.__routeState as Record<string, unknown>;
|
|
49
|
+
if (s) {
|
|
50
|
+
s.distanceKm = e.payload?.distanceKm;
|
|
51
|
+
s.durationMin = e.payload?.durationMin;
|
|
52
|
+
s.isCalculating = false;
|
|
53
|
+
ctx.emit?.('route:calculated', e.payload);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
16
56
|
},
|
|
17
57
|
};
|
|
18
58
|
}
|
package/src/traits/types.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
export interface HSPlusNode {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface HSPlusNode {
|
|
2
|
+
id?: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface TraitContext {
|
|
7
|
+
emit?: (event: string, payload?: unknown) => void;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface TraitEvent {
|
|
11
|
+
type: string;
|
|
12
|
+
payload?: Record<string, unknown>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface TraitHandler<T = unknown> {
|
|
16
|
+
name: string;
|
|
17
|
+
defaultConfig: T;
|
|
18
|
+
onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
19
|
+
onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
20
|
+
onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void;
|
|
21
|
+
onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void;
|
|
22
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true },
|
|
4
|
+
"include": ["src"]
|
|
5
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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.
|