@tak-ps/node-cot 13.6.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import Err from '@openaddresses/batch-error';
2
+ import { v4 as randomUUID } from 'uuid';
3
+ import type { Static } from '@sinclair/typebox';
4
+ import { coordEach } from "@turf/meta";
5
+ import TypeValidator from '../type.js';
6
+ import { Feature } from '../types/feature.js';
7
+ import PointOnFeature from '@turf/point-on-feature';
8
+ import { GeoJSONFeature } from '../types/geojson.js';
9
+
10
+ /**
11
+ * Given a generic GeoJSON Feature, convert it to a CoT Featurt
12
+ *
13
+ * @param {Object} feature GeoJSON Feature
14
+ *
15
+ * @return {CoT}
16
+ */
17
+ export async function normalize_geojson(
18
+ feature: Static<typeof GeoJSONFeature>,
19
+ ): Promise<Static<typeof Feature>> {
20
+ try {
21
+ feature = await TypeValidator.type(GeoJSONFeature, feature);
22
+ } catch (err) {
23
+ throw new Err(400, null, `Validation Error: ${err}`);
24
+ }
25
+
26
+ if (!feature.id) {
27
+ feature.id = randomUUID();
28
+ }
29
+
30
+ const props = feature.properties;
31
+
32
+ feature.properties = {
33
+ metadata: props || {}
34
+ }
35
+
36
+ if (feature.geometry.type === 'Point') {
37
+ feature.properties.type = 'u-d-p';
38
+ } else if (feature.geometry.type === 'LineString') {
39
+ feature.properties.type = 'u-d-f';
40
+ } else if (feature.geometry.type === 'Polygon') {
41
+ feature.properties.type = 'u-d-f';
42
+ } else {
43
+ throw new Err(400, null, `Unsupported Geometry Type`);
44
+ }
45
+
46
+ for (const color of ['marker-color', 'stroke', 'fill']) {
47
+ if (
48
+ props[color]
49
+ && typeof props[color] === 'string'
50
+ ) {
51
+ feature.properties[color] = props[color];
52
+ }
53
+ }
54
+
55
+ for (const number of ['marker-opacity', 'stroke-opacity', 'stroke-width', 'fill-opacity']) {
56
+ if (
57
+ props[number]
58
+ && typeof props[number] === 'number'
59
+ ) {
60
+ feature.properties[number] = props[number];
61
+ }
62
+ }
63
+
64
+ // Callsign Options
65
+ for (const callsign of ['callsign', 'title', 'name']) {
66
+ if (props[callsign] && typeof props[callsign] === 'string') {
67
+ feature.properties.callsign = props[callsign];
68
+ break;
69
+ }
70
+ }
71
+
72
+ if (!feature.properties.callsign) {
73
+ feature.properties.callsign = 'New Feature';
74
+ }
75
+
76
+ // Remarks Options
77
+ for (const remarks of ['remarks', 'description']) {
78
+ if (props[remarks] && typeof props[remarks] === 'string') {
79
+ feature.properties.remarks = props[remarks];
80
+ break;
81
+ }
82
+ }
83
+
84
+ if (!feature.properties.remarks) {
85
+ feature.properties.remarks = '';
86
+ }
87
+
88
+ feature.properties.time = new Date().toISOString();
89
+ feature.properties.start = new Date().toISOString();
90
+
91
+ const stale = new Date();
92
+ stale.setHours(stale.getHours() + 1);
93
+ feature.properties.stale = stale;
94
+
95
+ feature.properties.center = PointOnFeature(feature).geometry.coordinates;
96
+
97
+ feature.properties.archived = feature.properties.archived || false;
98
+
99
+ coordEach(feature.geometry, (coord) => {
100
+ return coord.slice(0, 3);
101
+ });
102
+
103
+ return feature as Static<typeof Feature>;
104
+ }
@@ -0,0 +1,395 @@
1
+ import Err from '@openaddresses/batch-error';
2
+ import type { Static } from '@sinclair/typebox';
3
+ import type {
4
+ Feature,
5
+ Polygon,
6
+ FeaturePropertyMission,
7
+ FeaturePropertyMissionLayer,
8
+ } from '../types/feature.js';
9
+ import type {
10
+ MartiDest,
11
+ MartiDestAttributes,
12
+ Link,
13
+ LinkAttributes,
14
+ ColorAttributes,
15
+ } from '../types/types.js'
16
+ import Ellipse from '@turf/ellipse';
17
+ import Truncate from '@turf/truncate';
18
+ import { destination } from '@turf/destination';
19
+ import Color from '../utils/color.js';
20
+ import JSONCoT from '../types/types.js'
21
+ import CoT from '../cot.js';
22
+
23
+ // GeoJSON Geospatial ops will truncate to the below
24
+ const COORDINATE_PRECISION = 6;
25
+
26
+ /**
27
+ * Return a GeoJSON Feature from an XML CoT message
28
+ */
29
+ export async function to_geojson(cot: CoT): Promise<Static<typeof Feature>> {
30
+ const raw: Static<typeof JSONCoT> = JSON.parse(JSON.stringify(cot.raw));
31
+ if (!raw.event.detail) raw.event.detail = {};
32
+ if (!raw.event.detail.contact) raw.event.detail.contact = { _attributes: { callsign: 'UNKNOWN' } };
33
+ if (!raw.event.detail.contact._attributes) raw.event.detail.contact._attributes = { callsign: 'UNKNOWN' };
34
+
35
+ const feat: Static<typeof Feature> = {
36
+ id: raw.event._attributes.uid,
37
+ type: 'Feature',
38
+ properties: {
39
+ callsign: raw.event.detail.contact._attributes.callsign || 'UNKNOWN',
40
+ center: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ],
41
+ type: raw.event._attributes.type,
42
+ how: raw.event._attributes.how || '',
43
+ time: raw.event._attributes.time,
44
+ start: raw.event._attributes.start,
45
+ stale: raw.event._attributes.stale,
46
+ },
47
+ geometry: {
48
+ type: 'Point',
49
+ coordinates: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ]
50
+ }
51
+ };
52
+
53
+ const contact = JSON.parse(JSON.stringify(raw.event.detail.contact._attributes));
54
+ delete contact.callsign;
55
+ if (Object.keys(contact).length) {
56
+ feat.properties.contact = contact;
57
+ }
58
+
59
+ if (cot.creator()) {
60
+ feat.properties.creator = cot.creator();
61
+ }
62
+
63
+ if (raw.event.detail.remarks && raw.event.detail.remarks._text) {
64
+ feat.properties.remarks = raw.event.detail.remarks._text;
65
+ }
66
+
67
+ if (raw.event.detail.fileshare) {
68
+ feat.properties.fileshare = raw.event.detail.fileshare._attributes;
69
+ if (feat.properties.fileshare && typeof feat.properties.fileshare.sizeInBytes === 'string') {
70
+ feat.properties.fileshare.sizeInBytes = parseInt(feat.properties.fileshare.sizeInBytes)
71
+ }
72
+ }
73
+
74
+ if (raw.event.detail.__milsym) {
75
+ feat.properties.milsym = {
76
+ id: raw.event.detail.__milsym._attributes.id
77
+ }
78
+ }
79
+
80
+ if (raw.event.detail.sensor) {
81
+ feat.properties.sensor = raw.event.detail.sensor._attributes;
82
+ }
83
+
84
+ if (raw.event.detail.range) {
85
+ feat.properties.range = raw.event.detail.range._attributes.value;
86
+ }
87
+
88
+ if (raw.event.detail.bearing) {
89
+ feat.properties.bearing = raw.event.detail.bearing._attributes.value;
90
+ }
91
+
92
+ if (raw.event.detail.labels_on && raw.event.detail.labels_on._attributes && raw.event.detail.labels_on._attributes.value !== undefined) {
93
+ feat.properties.labels = raw.event.detail.labels_on._attributes.value;
94
+ }
95
+
96
+ if (raw.event.detail.__video && raw.event.detail.__video._attributes) {
97
+ feat.properties.video = raw.event.detail.__video._attributes;
98
+
99
+ if (raw.event.detail.__video.ConnectionEntry) {
100
+ feat.properties.video.connection = raw.event.detail.__video.ConnectionEntry._attributes;
101
+ }
102
+ }
103
+
104
+ if (raw.event.detail.__geofence) {
105
+ feat.properties.geofence = raw.event.detail.__geofence._attributes;
106
+ }
107
+
108
+ if (raw.event.detail.ackrequest) {
109
+ feat.properties.ackrequest = raw.event.detail.ackrequest._attributes;
110
+ }
111
+
112
+ if (raw.event.detail.attachment_list) {
113
+ feat.properties.attachments = JSON.parse(raw.event.detail.attachment_list._attributes.hashes);
114
+ }
115
+
116
+ if (raw.event.detail.link) {
117
+ if (!Array.isArray(raw.event.detail.link)) raw.event.detail.link = [raw.event.detail.link];
118
+
119
+ feat.properties.links = raw.event.detail.link.filter((link: Static<typeof Link>) => {
120
+ return !!link._attributes.url
121
+ }).map((link: Static<typeof Link>): Static<typeof LinkAttributes> => {
122
+ return link._attributes;
123
+ });
124
+
125
+ if (!feat.properties.links || !feat.properties.links.length) delete feat.properties.links;
126
+ }
127
+
128
+ if (raw.event.detail.archive) {
129
+ feat.properties.archived = true;
130
+ }
131
+
132
+ if (raw.event.detail.__chat) {
133
+ feat.properties.chat = {
134
+ ...raw.event.detail.__chat._attributes,
135
+ chatgrp: raw.event.detail.__chat.chatgrp
136
+ }
137
+ }
138
+
139
+ if (raw.event.detail.track && raw.event.detail.track._attributes) {
140
+ if (raw.event.detail.track._attributes.course) feat.properties.course = Number(raw.event.detail.track._attributes.course);
141
+ if (raw.event.detail.track._attributes.slope) feat.properties.slope = Number(raw.event.detail.track._attributes.slope);
142
+ if (raw.event.detail.track._attributes.course) feat.properties.speed = Number(raw.event.detail.track._attributes.speed);
143
+ }
144
+
145
+ if (raw.event.detail.marti && raw.event.detail.marti.dest) {
146
+ if (!Array.isArray(raw.event.detail.marti.dest)) raw.event.detail.marti.dest = [raw.event.detail.marti.dest];
147
+
148
+ const dest: Array<Static<typeof MartiDestAttributes>> = raw.event.detail.marti.dest.map((d: Static<typeof MartiDest>) => {
149
+ return { ...d._attributes };
150
+ });
151
+
152
+ feat.properties.dest = dest.length === 1 ? dest[0] : dest
153
+ }
154
+
155
+ if (raw.event.detail.usericon && raw.event.detail.usericon._attributes && raw.event.detail.usericon._attributes.iconsetpath) {
156
+ feat.properties.icon = raw.event.detail.usericon._attributes.iconsetpath;
157
+ }
158
+
159
+
160
+ if (raw.event.detail.uid && raw.event.detail.uid._attributes && raw.event.detail.uid._attributes.Droid) {
161
+ feat.properties.droid = raw.event.detail.uid._attributes.Droid;
162
+ }
163
+
164
+ if (raw.event.detail.takv && raw.event.detail.takv._attributes) {
165
+ feat.properties.takv = raw.event.detail.takv._attributes;
166
+ }
167
+
168
+ if (raw.event.detail.__group && raw.event.detail.__group._attributes) {
169
+ feat.properties.group = raw.event.detail.__group._attributes;
170
+ }
171
+
172
+ if (raw.event.detail['_flow-tags_'] && raw.event.detail['_flow-tags_']._attributes) {
173
+ feat.properties.flow = raw.event.detail['_flow-tags_']._attributes;
174
+ }
175
+
176
+ if (raw.event.detail.status && raw.event.detail.status._attributes) {
177
+ feat.properties.status = raw.event.detail.status._attributes;
178
+ }
179
+
180
+ if (raw.event.detail.mission && raw.event.detail.mission._attributes) {
181
+ const mission: Static<typeof FeaturePropertyMission> = {
182
+ ...raw.event.detail.mission._attributes
183
+ };
184
+
185
+ if (raw.event.detail.mission && raw.event.detail.mission.MissionChanges) {
186
+ const changes =
187
+ Array.isArray(raw.event.detail.mission.MissionChanges)
188
+ ? raw.event.detail.mission.MissionChanges
189
+ : [ raw.event.detail.mission.MissionChanges ]
190
+
191
+ mission.missionChanges = []
192
+ for (const change of changes) {
193
+ mission.missionChanges.push({
194
+ contentUid: change.MissionChange.contentUid._text,
195
+ creatorUid: change.MissionChange.creatorUid._text,
196
+ isFederatedChange: change.MissionChange.isFederatedChange._text,
197
+ missionName: change.MissionChange.missionName._text,
198
+ timestamp: change.MissionChange.timestamp._text,
199
+ type: change.MissionChange.type._text,
200
+ details: {
201
+ ...change.MissionChange.details._attributes,
202
+ ...change.MissionChange.details.location
203
+ ? change.MissionChange.details.location._attributes
204
+ : {}
205
+ }
206
+ })
207
+ }
208
+ }
209
+
210
+
211
+ if (raw.event.detail.mission && raw.event.detail.mission.missionLayer) {
212
+ const missionLayer: Static<typeof FeaturePropertyMissionLayer> = {};
213
+
214
+ if (raw.event.detail.mission.missionLayer.name && raw.event.detail.mission.missionLayer.name._text) {
215
+ missionLayer.name = raw.event.detail.mission.missionLayer.name._text;
216
+ }
217
+ if (raw.event.detail.mission.missionLayer.parentUid && raw.event.detail.mission.missionLayer.parentUid._text) {
218
+ missionLayer.parentUid = raw.event.detail.mission.missionLayer.parentUid._text;
219
+ }
220
+ if (raw.event.detail.mission.missionLayer.type && raw.event.detail.mission.missionLayer.type._text) {
221
+ missionLayer.type = raw.event.detail.mission.missionLayer.type._text;
222
+ }
223
+ if (raw.event.detail.mission.missionLayer.uid && raw.event.detail.mission.missionLayer.uid._text) {
224
+ missionLayer.uid = raw.event.detail.mission.missionLayer.uid._text;
225
+ }
226
+
227
+ mission.missionLayer = missionLayer;
228
+ }
229
+
230
+ feat.properties.mission = mission;
231
+ }
232
+
233
+ if (raw.event.detail.precisionlocation && raw.event.detail.precisionlocation._attributes) {
234
+ feat.properties.precisionlocation = raw.event.detail.precisionlocation._attributes;
235
+ }
236
+
237
+ if (raw.event.detail.strokeColor && raw.event.detail.strokeColor._attributes && raw.event.detail.strokeColor._attributes.value) {
238
+ const stroke = new Color(Number(raw.event.detail.strokeColor._attributes.value));
239
+ feat.properties.stroke = stroke.as_hex();
240
+ feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
241
+ }
242
+
243
+ if (raw.event.detail.strokeWeight && raw.event.detail.strokeWeight._attributes && raw.event.detail.strokeWeight._attributes.value) {
244
+ feat.properties['stroke-width'] = Number(raw.event.detail.strokeWeight._attributes.value);
245
+ }
246
+
247
+ if (raw.event.detail.strokeStyle && raw.event.detail.strokeStyle._attributes && raw.event.detail.strokeStyle._attributes.value) {
248
+ feat.properties['stroke-style'] = raw.event.detail.strokeStyle._attributes.value;
249
+ }
250
+
251
+ if (raw.event.detail.color) {
252
+ let color: Static<typeof ColorAttributes> | null = null;
253
+
254
+ if (Array.isArray(raw.event.detail.color) && raw.event.detail.color.length > 1) {
255
+ color = raw.event.detail.color[0];
256
+ if (!color._attributes) color._attributes = {};
257
+
258
+ for (let i = raw.event.detail.color.length - 1; i >= 1; i--) {
259
+ if (raw.event.detail.color[i]._attributes) {
260
+ Object.assign(color._attributes, raw.event.detail.color[i]._attributes);
261
+ }
262
+ }
263
+ } else if (Array.isArray(raw.event.detail.color) && raw.event.detail.color.length === 1) {
264
+ color = raw.event.detail.color[0];
265
+ } else if (!Array.isArray(raw.event.detail.color)) {
266
+ color = raw.event.detail.color;
267
+ }
268
+
269
+ if (color && color._attributes && color._attributes.argb) {
270
+ const parsedColor = new Color(Number(color._attributes.argb));
271
+ feat.properties['marker-color'] = parsedColor.as_hex();
272
+ feat.properties['marker-opacity'] = parsedColor.as_opacity() / 255;
273
+ }
274
+ }
275
+
276
+ // Line, Polygon style types
277
+ if (['u-d-f', 'u-d-r', 'b-m-r', 'u-rb-a'].includes(raw.event._attributes.type) && Array.isArray(raw.event.detail.link)) {
278
+ const coordinates = [];
279
+
280
+ for (const l of raw.event.detail.link) {
281
+ if (!l._attributes.point) continue;
282
+ coordinates.push(l._attributes.point.split(',').map((p: string) => { return Number(p.trim()) }).splice(0, 2).reverse());
283
+ }
284
+
285
+ // Range & Bearing Line
286
+ if (raw.event._attributes.type === 'u-rb-a') {
287
+ const detail = cot.detail();
288
+
289
+ if (!detail.range) throw new Error('Range value not provided')
290
+ if (!detail.bearing) throw new Error('Bearing value not provided')
291
+
292
+ // TODO Support inclination
293
+ const dest = destination(
294
+ cot.position(),
295
+ detail.range._attributes.value / 1000,
296
+ detail.bearing._attributes.value
297
+ ).geometry.coordinates;
298
+
299
+ feat.geometry = {
300
+ type: 'LineString',
301
+ coordinates: [cot.position(), dest]
302
+ };
303
+ } else if (raw.event._attributes.type === 'u-d-r' || (coordinates[0][0] === coordinates[coordinates.length -1][0] && coordinates[0][1] === coordinates[coordinates.length -1][1])) {
304
+ if (raw.event._attributes.type === 'u-d-r') {
305
+ // CoT rectangles are only 4 points - GeoJSON needs to be closed
306
+ coordinates.push(coordinates[0])
307
+ }
308
+
309
+ feat.geometry = {
310
+ type: 'Polygon',
311
+ coordinates: [coordinates]
312
+ }
313
+
314
+ if (raw.event.detail.fillColor && raw.event.detail.fillColor._attributes && raw.event.detail.fillColor._attributes.value) {
315
+ const fill = new Color(Number(raw.event.detail.fillColor._attributes.value));
316
+ feat.properties['fill-opacity'] = fill.as_opacity() / 255;
317
+ feat.properties['fill'] = fill.as_hex();
318
+ }
319
+ } else {
320
+ feat.geometry = {
321
+ type: 'LineString',
322
+ coordinates
323
+ }
324
+ }
325
+ } else if (raw.event._attributes.type.startsWith('u-d-c-c') || raw.event._attributes.type.startsWith('u-r-b-c-c')) {
326
+ if (!raw.event.detail.shape) throw new Err(400, null, `${raw.event._attributes.type} (Circle) must define shape value`)
327
+ if (
328
+ !raw.event.detail.shape.ellipse
329
+ || !raw.event.detail.shape.ellipse._attributes
330
+ ) throw new Err(400, null, `${raw.event._attributes.type} (Circle) must define ellipse shape value`)
331
+
332
+ const ellipse = {
333
+ major: Number(raw.event.detail.shape.ellipse._attributes.major),
334
+ minor: Number(raw.event.detail.shape.ellipse._attributes.minor),
335
+ angle: Number(raw.event.detail.shape.ellipse._attributes.angle)
336
+ }
337
+
338
+ feat.geometry = Truncate(Ellipse(
339
+ feat.geometry.coordinates as number[],
340
+ Number(ellipse.major) / 1000,
341
+ Number(ellipse.minor) / 1000,
342
+ {
343
+ angle: ellipse.angle
344
+ }
345
+ ), {
346
+ precision: COORDINATE_PRECISION,
347
+ mutate: true
348
+ }).geometry as Static<typeof Polygon>;
349
+
350
+ feat.properties.shape = {};
351
+ feat.properties.shape.ellipse = ellipse;
352
+ } else if (raw.event._attributes.type.startsWith('b-m-p-s-p-i')) {
353
+ // TODO: Currently the "shape" tag is only parsed here - asking ARA for clarification if it is a general use tag
354
+ if (raw.event.detail.shape && raw.event.detail.shape.polyline && raw.event.detail.shape.polyline.vertex) {
355
+ const coordinates = [];
356
+
357
+ const vertices = Array.isArray(raw.event.detail.shape.polyline.vertex) ? raw.event.detail.shape.polyline.vertex : [raw.event.detail.shape.polyline.vertex];
358
+ for (const v of vertices) {
359
+ coordinates.push([Number(v._attributes.lon), Number(v._attributes.lat)]);
360
+ }
361
+
362
+ if (coordinates.length === 1) {
363
+ feat.geometry = { type: 'Point', coordinates: coordinates[0] }
364
+ } else if (raw.event.detail.shape.polyline._attributes && raw.event.detail.shape.polyline._attributes.closed === true) {
365
+ coordinates.push(coordinates[0]);
366
+ feat.geometry = { type: 'Polygon', coordinates: [coordinates] }
367
+ } else {
368
+ feat.geometry = { type: 'LineString', coordinates }
369
+ }
370
+ }
371
+
372
+ if (
373
+ raw.event.detail.shape
374
+ && raw.event.detail.shape.polyline
375
+ && raw.event.detail.shape.polyline._attributes
376
+ ) {
377
+ if (raw.event.detail.shape.polyline._attributes.fillColor) {
378
+ const fill = new Color(Number(raw.event.detail.shape.polyline._attributes.fillColor));
379
+ feat.properties['fill-opacity'] = fill.as_opacity() / 255;
380
+ feat.properties['fill'] = fill.as_hex();
381
+ }
382
+
383
+ if (raw.event.detail.shape.polyline._attributes.color) {
384
+ const stroke = new Color(Number(raw.event.detail.shape.polyline._attributes.color));
385
+ feat.properties.stroke = stroke.as_hex();
386
+ feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
387
+ }
388
+ }
389
+ }
390
+
391
+ feat.properties.metadata = cot.metadata;
392
+ feat.path = cot.path;
393
+
394
+ return feat;
395
+ }