@tak-ps/node-cot
Version:
Lightweight JavaScript library for parsing and manipulating TAK messages
396 lines (329 loc) • 17 kB
text/typescript
import Err from '@openaddresses/batch-error';
import type { Static } from '@sinclair/typebox';
import type {
Feature,
Polygon,
FeaturePropertyMission,
FeaturePropertyMissionLayer,
} from '../types/feature.js';
import type {
MartiDest,
MartiDestAttributes,
Link,
LinkAttributes,
ColorAttributes,
} from '../types/types.js'
import Ellipse from '@turf/ellipse';
import Truncate from '@turf/truncate';
import { destination } from '@turf/destination';
import Color from '../utils/color.js';
import JSONCoT from '../types/types.js'
import CoT from '../cot.js';
// GeoJSON Geospatial ops will truncate to the below
const COORDINATE_PRECISION = 6;
/**
* Return a GeoJSON Feature from an XML CoT message
*/
export async function to_geojson(cot: CoT): Promise<Static<typeof Feature>> {
const raw: Static<typeof JSONCoT> = JSON.parse(JSON.stringify(cot.raw));
if (!raw.event.detail) raw.event.detail = {};
if (!raw.event.detail.contact) raw.event.detail.contact = { _attributes: { callsign: 'UNKNOWN' } };
if (!raw.event.detail.contact._attributes) raw.event.detail.contact._attributes = { callsign: 'UNKNOWN' };
const feat: Static<typeof Feature> = {
id: raw.event._attributes.uid,
type: 'Feature',
properties: {
callsign: raw.event.detail.contact._attributes.callsign || 'UNKNOWN',
center: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ],
type: raw.event._attributes.type,
how: raw.event._attributes.how || '',
time: raw.event._attributes.time,
start: raw.event._attributes.start,
stale: raw.event._attributes.stale,
},
geometry: {
type: 'Point',
coordinates: [ Number(raw.event.point._attributes.lon), Number(raw.event.point._attributes.lat), Number(raw.event.point._attributes.hae) ]
}
};
const contact = JSON.parse(JSON.stringify(raw.event.detail.contact._attributes));
delete contact.callsign;
if (Object.keys(contact).length) {
feat.properties.contact = contact;
}
if (cot.creator()) {
feat.properties.creator = cot.creator();
}
if (raw.event.detail.remarks && raw.event.detail.remarks._text) {
feat.properties.remarks = raw.event.detail.remarks._text;
}
if (raw.event.detail.fileshare) {
feat.properties.fileshare = raw.event.detail.fileshare._attributes;
if (feat.properties.fileshare && typeof feat.properties.fileshare.sizeInBytes === 'string') {
feat.properties.fileshare.sizeInBytes = parseInt(feat.properties.fileshare.sizeInBytes)
}
}
if (raw.event.detail.__milsym) {
feat.properties.milsym = {
id: raw.event.detail.__milsym._attributes.id
}
}
if (raw.event.detail.sensor) {
feat.properties.sensor = raw.event.detail.sensor._attributes;
}
if (raw.event.detail.range) {
feat.properties.range = raw.event.detail.range._attributes.value;
}
if (raw.event.detail.bearing) {
feat.properties.bearing = raw.event.detail.bearing._attributes.value;
}
if (raw.event.detail.labels_on && raw.event.detail.labels_on._attributes && raw.event.detail.labels_on._attributes.value !== undefined) {
feat.properties.labels = raw.event.detail.labels_on._attributes.value;
}
if (raw.event.detail.__video && raw.event.detail.__video._attributes) {
feat.properties.video = raw.event.detail.__video._attributes;
if (raw.event.detail.__video.ConnectionEntry) {
feat.properties.video.connection = raw.event.detail.__video.ConnectionEntry._attributes;
}
}
if (raw.event.detail.__geofence) {
feat.properties.geofence = raw.event.detail.__geofence._attributes;
}
if (raw.event.detail.ackrequest) {
feat.properties.ackrequest = raw.event.detail.ackrequest._attributes;
}
if (raw.event.detail.attachment_list) {
feat.properties.attachments = JSON.parse(raw.event.detail.attachment_list._attributes.hashes);
}
if (raw.event.detail.link) {
if (!Array.isArray(raw.event.detail.link)) raw.event.detail.link = [raw.event.detail.link];
feat.properties.links = raw.event.detail.link.filter((link: Static<typeof Link>) => {
return !!link._attributes.url
}).map((link: Static<typeof Link>): Static<typeof LinkAttributes> => {
return link._attributes;
});
if (!feat.properties.links || !feat.properties.links.length) delete feat.properties.links;
}
if (raw.event.detail.archive) {
feat.properties.archived = true;
}
if (raw.event.detail.__chat) {
feat.properties.chat = {
...raw.event.detail.__chat._attributes,
chatgrp: raw.event.detail.__chat.chatgrp
}
}
if (raw.event.detail.track && raw.event.detail.track._attributes) {
if (raw.event.detail.track._attributes.course) feat.properties.course = Number(raw.event.detail.track._attributes.course);
if (raw.event.detail.track._attributes.slope) feat.properties.slope = Number(raw.event.detail.track._attributes.slope);
if (raw.event.detail.track._attributes.course) feat.properties.speed = Number(raw.event.detail.track._attributes.speed);
}
if (raw.event.detail.marti && raw.event.detail.marti.dest) {
if (!Array.isArray(raw.event.detail.marti.dest)) raw.event.detail.marti.dest = [raw.event.detail.marti.dest];
const dest: Array<Static<typeof MartiDestAttributes>> = raw.event.detail.marti.dest.map((d: Static<typeof MartiDest>) => {
return { ...d._attributes };
});
feat.properties.dest = dest.length === 1 ? dest[0] : dest
}
if (raw.event.detail.usericon && raw.event.detail.usericon._attributes && raw.event.detail.usericon._attributes.iconsetpath) {
feat.properties.icon = raw.event.detail.usericon._attributes.iconsetpath;
}
if (raw.event.detail.uid && raw.event.detail.uid._attributes && raw.event.detail.uid._attributes.Droid) {
feat.properties.droid = raw.event.detail.uid._attributes.Droid;
}
if (raw.event.detail.takv && raw.event.detail.takv._attributes) {
feat.properties.takv = raw.event.detail.takv._attributes;
}
if (raw.event.detail.__group && raw.event.detail.__group._attributes) {
feat.properties.group = raw.event.detail.__group._attributes;
}
if (raw.event.detail['_flow-tags_'] && raw.event.detail['_flow-tags_']._attributes) {
feat.properties.flow = raw.event.detail['_flow-tags_']._attributes;
}
if (raw.event.detail.status && raw.event.detail.status._attributes) {
feat.properties.status = raw.event.detail.status._attributes;
}
if (raw.event.detail.mission && raw.event.detail.mission._attributes) {
const mission: Static<typeof FeaturePropertyMission> = {
...raw.event.detail.mission._attributes
};
if (raw.event.detail.mission && raw.event.detail.mission.MissionChanges) {
const changes =
Array.isArray(raw.event.detail.mission.MissionChanges)
? raw.event.detail.mission.MissionChanges
: [ raw.event.detail.mission.MissionChanges ]
mission.missionChanges = []
for (const change of changes) {
mission.missionChanges.push({
contentUid: change.MissionChange.contentUid._text,
creatorUid: change.MissionChange.creatorUid._text,
isFederatedChange: change.MissionChange.isFederatedChange._text,
missionName: change.MissionChange.missionName._text,
timestamp: change.MissionChange.timestamp._text,
type: change.MissionChange.type._text,
details: {
...change.MissionChange.details._attributes,
...change.MissionChange.details.location
? change.MissionChange.details.location._attributes
: {}
}
})
}
}
if (raw.event.detail.mission && raw.event.detail.mission.missionLayer) {
const missionLayer: Static<typeof FeaturePropertyMissionLayer> = {};
if (raw.event.detail.mission.missionLayer.name && raw.event.detail.mission.missionLayer.name._text) {
missionLayer.name = raw.event.detail.mission.missionLayer.name._text;
}
if (raw.event.detail.mission.missionLayer.parentUid && raw.event.detail.mission.missionLayer.parentUid._text) {
missionLayer.parentUid = raw.event.detail.mission.missionLayer.parentUid._text;
}
if (raw.event.detail.mission.missionLayer.type && raw.event.detail.mission.missionLayer.type._text) {
missionLayer.type = raw.event.detail.mission.missionLayer.type._text;
}
if (raw.event.detail.mission.missionLayer.uid && raw.event.detail.mission.missionLayer.uid._text) {
missionLayer.uid = raw.event.detail.mission.missionLayer.uid._text;
}
mission.missionLayer = missionLayer;
}
feat.properties.mission = mission;
}
if (raw.event.detail.precisionlocation && raw.event.detail.precisionlocation._attributes) {
feat.properties.precisionlocation = raw.event.detail.precisionlocation._attributes;
}
if (raw.event.detail.strokeColor && raw.event.detail.strokeColor._attributes && raw.event.detail.strokeColor._attributes.value) {
const stroke = new Color(Number(raw.event.detail.strokeColor._attributes.value));
feat.properties.stroke = stroke.as_hex();
feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
}
if (raw.event.detail.strokeWeight && raw.event.detail.strokeWeight._attributes && raw.event.detail.strokeWeight._attributes.value) {
feat.properties['stroke-width'] = Number(raw.event.detail.strokeWeight._attributes.value);
}
if (raw.event.detail.strokeStyle && raw.event.detail.strokeStyle._attributes && raw.event.detail.strokeStyle._attributes.value) {
feat.properties['stroke-style'] = raw.event.detail.strokeStyle._attributes.value;
}
if (raw.event.detail.color) {
let color: Static<typeof ColorAttributes> | null = null;
if (Array.isArray(raw.event.detail.color) && raw.event.detail.color.length > 1) {
color = raw.event.detail.color[0];
if (!color._attributes) color._attributes = {};
for (let i = raw.event.detail.color.length - 1; i >= 1; i--) {
if (raw.event.detail.color[i]._attributes) {
Object.assign(color._attributes, raw.event.detail.color[i]._attributes);
}
}
} else if (Array.isArray(raw.event.detail.color) && raw.event.detail.color.length === 1) {
color = raw.event.detail.color[0];
} else if (!Array.isArray(raw.event.detail.color)) {
color = raw.event.detail.color;
}
if (color && color._attributes && color._attributes.argb) {
const parsedColor = new Color(Number(color._attributes.argb));
feat.properties['marker-color'] = parsedColor.as_hex();
feat.properties['marker-opacity'] = parsedColor.as_opacity() / 255;
}
}
// Line, Polygon style types
if (['u-d-f', 'u-d-r', 'b-m-r', 'u-rb-a'].includes(raw.event._attributes.type) && Array.isArray(raw.event.detail.link)) {
const coordinates = [];
for (const l of raw.event.detail.link) {
if (!l._attributes.point) continue;
coordinates.push(l._attributes.point.split(',').map((p: string) => { return Number(p.trim()) }).splice(0, 2).reverse());
}
// Range & Bearing Line
if (raw.event._attributes.type === 'u-rb-a') {
const detail = cot.detail();
if (!detail.range) throw new Error('Range value not provided')
if (!detail.bearing) throw new Error('Bearing value not provided')
// TODO Support inclination
const dest = destination(
cot.position(),
detail.range._attributes.value / 1000,
detail.bearing._attributes.value
).geometry.coordinates;
feat.geometry = {
type: 'LineString',
coordinates: [cot.position(), dest]
};
} 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])) {
if (raw.event._attributes.type === 'u-d-r') {
// CoT rectangles are only 4 points - GeoJSON needs to be closed
coordinates.push(coordinates[0])
}
feat.geometry = {
type: 'Polygon',
coordinates: [coordinates]
}
if (raw.event.detail.fillColor && raw.event.detail.fillColor._attributes && raw.event.detail.fillColor._attributes.value) {
const fill = new Color(Number(raw.event.detail.fillColor._attributes.value));
feat.properties['fill-opacity'] = fill.as_opacity() / 255;
feat.properties['fill'] = fill.as_hex();
}
} else {
feat.geometry = {
type: 'LineString',
coordinates
}
}
} else if (raw.event._attributes.type.startsWith('u-d-c-c') || raw.event._attributes.type.startsWith('u-r-b-c-c')) {
if (!raw.event.detail.shape) throw new Err(400, null, `${raw.event._attributes.type} (Circle) must define shape value`)
if (
!raw.event.detail.shape.ellipse
|| !raw.event.detail.shape.ellipse._attributes
) throw new Err(400, null, `${raw.event._attributes.type} (Circle) must define ellipse shape value`)
const ellipse = {
major: Number(raw.event.detail.shape.ellipse._attributes.major),
minor: Number(raw.event.detail.shape.ellipse._attributes.minor),
angle: Number(raw.event.detail.shape.ellipse._attributes.angle)
}
feat.geometry = Truncate(Ellipse(
feat.geometry.coordinates as number[],
Number(ellipse.major) / 1000,
Number(ellipse.minor) / 1000,
{
angle: ellipse.angle
}
), {
precision: COORDINATE_PRECISION,
mutate: true
}).geometry as Static<typeof Polygon>;
feat.properties.shape = {};
feat.properties.shape.ellipse = ellipse;
} else if (raw.event._attributes.type.startsWith('b-m-p-s-p-i')) {
// TODO: Currently the "shape" tag is only parsed here - asking ARA for clarification if it is a general use tag
if (raw.event.detail.shape && raw.event.detail.shape.polyline && raw.event.detail.shape.polyline.vertex) {
const coordinates = [];
const vertices = Array.isArray(raw.event.detail.shape.polyline.vertex) ? raw.event.detail.shape.polyline.vertex : [raw.event.detail.shape.polyline.vertex];
for (const v of vertices) {
coordinates.push([Number(v._attributes.lon), Number(v._attributes.lat)]);
}
if (coordinates.length === 1) {
feat.geometry = { type: 'Point', coordinates: coordinates[0] }
} else if (raw.event.detail.shape.polyline._attributes && raw.event.detail.shape.polyline._attributes.closed === true) {
coordinates.push(coordinates[0]);
feat.geometry = { type: 'Polygon', coordinates: [coordinates] }
} else {
feat.geometry = { type: 'LineString', coordinates }
}
}
if (
raw.event.detail.shape
&& raw.event.detail.shape.polyline
&& raw.event.detail.shape.polyline._attributes
) {
if (raw.event.detail.shape.polyline._attributes.fillColor) {
const fill = new Color(Number(raw.event.detail.shape.polyline._attributes.fillColor));
feat.properties['fill-opacity'] = fill.as_opacity() / 255;
feat.properties['fill'] = fill.as_hex();
}
if (raw.event.detail.shape.polyline._attributes.color) {
const stroke = new Color(Number(raw.event.detail.shape.polyline._attributes.color));
feat.properties.stroke = stroke.as_hex();
feat.properties['stroke-opacity'] = stroke.as_opacity() / 255;
}
}
}
feat.properties.metadata = cot.metadata;
feat.path = cot.path;
return feat;
}