Skip to content

Commit 01ba523

Browse files
committed
Improve live track display.
fixes #38
1 parent cca51f8 commit 01ba523

File tree

4 files changed

+130
-37
lines changed

4 files changed

+130
-37
lines changed

frontend/src/viewer/components/tracking-element.ts

+43-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toRad } from 'geolib';
12
import { CSSResult, customElement, html, internalProperty, LitElement, TemplateResult } from 'lit-element';
23
import { connect } from 'pwa-helpers';
34

@@ -8,6 +9,9 @@ import { Units } from '../reducers/map';
89
import { RootState, store } from '../store';
910
import { controlHostStyle } from './control-style';
1011

12+
const SPEECH_BUBBLE =
13+
'M2.5 2C1.7 2 1 2.7 1 3.5 l 0 8 c0 .8.7 1.5 1.5 1.5 H4 l 0 2.4 L 7.7 13 l 4.8 0 c.8 0 1.5 -.7 1.5 -1.5 l 0 -8 c 0 -.8 -.7 -1.5 -1.5 -1.5 z';
14+
1115
@customElement('tracking-element')
1216
export class TrackingElement extends connect(store)(LitElement) {
1317
@internalProperty()
@@ -114,22 +118,53 @@ export class TrackingElement extends connect(store)(LitElement) {
114118
let zIndex = 10;
115119
const age_hours = (now - ts) / (3600 * 1000);
116120
let opacity = age_hours > 12 ? 0.3 : 0.9;
117-
let scale = 0.3;
121+
122+
// Small circle by default.
123+
let scale = 3;
124+
let rotation = 0;
125+
let path: google.maps.SymbolPath | string = google.maps.SymbolPath.CIRCLE;
126+
let labelOrigin = new google.maps.Point(0, 3);
127+
let anchor: google.maps.Point | undefined;
128+
129+
// Display an arrow when we have a bearing (last point).
130+
if (feature.getProperty('bearing') != null) {
131+
rotation = Number(feature.getProperty('bearing'));
132+
scale = 3;
133+
const ANCHOR_Y = 2;
134+
anchor = new google.maps.Point(0, ANCHOR_Y);
135+
path = google.maps.SymbolPath.FORWARD_CLOSED_ARROW;
136+
const rad = toRad(-rotation);
137+
// x1 = x0cos(θ) – y0sin(θ)
138+
// y1 = x0sin(θ) + y0cos(θ)
139+
const x = -5 * Math.sin(rad);
140+
const y = 5 * Math.cos(rad);
141+
labelOrigin = new google.maps.Point(x, y + ANCHOR_Y);
142+
}
143+
144+
// Display speech bubble for messages and emergency.
118145
if (feature.getProperty('msg')) {
119-
scale = 0.6;
146+
scale = 1;
147+
anchor = new google.maps.Point(7, 9);
148+
labelOrigin = new google.maps.Point(0, 32);
149+
path = SPEECH_BUBBLE;
120150
opacity = 1;
121151
color = 'yellow';
122152
zIndex += 10;
123153
}
154+
124155
if (feature.getProperty('emergency')) {
125-
scale = 0.6;
156+
scale = 1;
157+
anchor = new google.maps.Point(7, 9);
158+
labelOrigin = new google.maps.Point(0, 32);
159+
path = SPEECH_BUBBLE;
126160
opacity = 1;
127161
color = 'red';
128162
zIndex += 10;
129163
}
164+
165+
// Display pilot name.
130166
let label: google.maps.MarkerLabel | null = null;
131167
if (feature.getProperty('is_last_fix') === true) {
132-
scale = 0.6;
133168
if (this.displayNames) {
134169
const minutesOld = Math.round((now - ts) / (60 * 1000));
135170
const age =
@@ -154,14 +189,15 @@ export class TrackingElement extends connect(store)(LitElement) {
154189
zIndex,
155190
cursor: 'zoom-in',
156191
icon: {
157-
path: 'M 0,10 A 10,10 0 0 0 20,10 A 10,10 0 0 0 0,10',
192+
path,
193+
rotation,
158194
fillColor: color,
159195
fillOpacity: opacity,
160196
strokeColor: 'black',
161197
strokeWeight: 1,
162198
strokeOpacity: opacity,
163-
anchor: new google.maps.Point(10, 10),
164-
labelOrigin: new google.maps.Point(10, 40),
199+
anchor,
200+
labelOrigin,
165201
scale,
166202
},
167203
} as google.maps.Data.StyleOptions;

run/src/trackers/inreach.ts

+3-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import request from 'request-zero';
22
import { DOMParser } from 'xmldom';
33

4-
import { LineString, Point, REFRESH_EVERY_MINUTES } from './trackers';
4+
import { createFeatures, Point, REFRESH_EVERY_MINUTES } from './trackers';
55

66
// Queries the datastore for the devices that have not been updated in REFRESH_EVERY_MINUTES.
77
// Queries the feeds until the timeout is reached and store the data back into the datastore.
@@ -21,7 +21,6 @@ export async function refresh(datastore: any, hour: number, timeoutSecs: number)
2121
let numActiveDevices = 0;
2222
for (; numDevices < devices.length; numDevices++) {
2323
const points: Point[] = [];
24-
const lineStrings: LineString[] = [];
2524
const device = devices[numDevices];
2625
let url: string = device.inreach;
2726
// Automatically inserts "Feed/Share" when missing in url.
@@ -54,7 +53,7 @@ export async function refresh(datastore: any, hour: number, timeoutSecs: number)
5453
const timestamp = getChildNode(placemark.childNodes, 'TimeStamp.when');
5554
const dataNode = getChildNode(placemark.childNodes, 'ExtendedData');
5655
const data = dataNode ? getData(dataNode) : null;
57-
const msg = getChildNode(placemark.childNodes, 'description')?.firstChild?.nodeValue || '';
56+
const msg = getChildNode(placemark.childNodes, 'description')?.firstChild?.nodeValue ?? '';
5857
if (coords && timestamp && data && coords.firstChild?.nodeValue && timestamp.firstChild?.nodeValue) {
5958
const [lon, lat, alt] = coords.firstChild.nodeValue
6059
.trim()
@@ -74,20 +73,14 @@ export async function refresh(datastore: any, hour: number, timeoutSecs: number)
7473
});
7574
}
7675
}
77-
if (points.length) {
78-
// points[0] is the oldest fix.
79-
points[points.length - 1].is_last_fix = true;
80-
const line = points.map((point) => [point.lon, point.lat]);
81-
lineStrings.push({ line, first_ts: points[0].ts });
82-
}
8376
} else {
8477
console.log(
8578
`Error refreshing inreach @ ${url}. HTTP code = ${response.code}, length = ${response.body.length}`,
8679
);
8780
}
8881
}
8982

90-
device.features = JSON.stringify([...points, ...lineStrings]);
83+
device.features = JSON.stringify(createFeatures(points));
9184
device.updated = Date.now();
9285
device.active = points.length > 0;
9386

run/src/trackers/spot.ts

+12-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import request from 'request-zero';
22

3-
import { LineString, Point, REFRESH_EVERY_MINUTES } from './trackers';
3+
import { createFeatures, Point, REFRESH_EVERY_MINUTES } from './trackers';
44

55
// Queries the datastore for the devices that have not been updated in REFRESH_EVERY_MINUTES.
66
// Queries the feeds until the timeout is reached and store the data back into the datastore.
@@ -20,41 +20,34 @@ export async function refresh(datastore: any, hour: number, timeoutSecs: number)
2020
let numActiveDevices = 0;
2121
for (; numDevices < devices.length; numDevices++) {
2222
const points: Point[] = [];
23-
const lineStrings: LineString[] = [];
2423
const device = devices[numDevices];
2524
const id: string = device.spot;
2625
if (/^\w+$/i.test(id)) {
2726
const url = `https://api.findmespot.com/spot-main-web/consumer/rest-api/2.0/public/feed/${id}/message.json?startDate=${startDate}`;
2827
const response = await request(url);
2928
if (response.code == 200) {
3029
console.log(`Refreshing spot @ ${id}`);
31-
const messages = JSON.parse(response.body)?.response?.feedMessageResponse?.messages?.message;
32-
if (messages && Array.isArray(messages)) {
30+
const fixes = JSON.parse(response.body)?.response?.feedMessageResponse?.messages?.message;
31+
if (fixes && Array.isArray(fixes)) {
3332
numActiveDevices++;
34-
messages.forEach((m: any) => {
33+
fixes.forEach((f: any) => {
3534
points.push({
36-
lon: m.longitude,
37-
lat: m.latitude,
38-
ts: m.unixTime * 1000,
39-
alt: m.altitude,
40-
name: m.messengerName,
41-
emergency: m.messageType == 'HELP',
42-
msg: m.messageContent,
35+
lon: f.longitude,
36+
lat: f.latitude,
37+
ts: f.unixTime * 1000,
38+
alt: f.altitude,
39+
name: f.messengerName,
40+
emergency: f.messageType == 'HELP',
41+
msg: f.messageContent,
4342
});
4443
});
4544
}
4645
} else {
4746
console.log(`Error refreshing spot @ ${id}`);
4847
}
49-
if (points.length) {
50-
// points[0] is the most recent fix.
51-
points[0].is_last_fix = true;
52-
const line = points.map((point) => [point.lon, point.lat]);
53-
lineStrings.push({ line, first_ts: points[points.length - 1].ts });
54-
}
5548
}
5649

57-
device.features = JSON.stringify([...points, ...lineStrings]);
50+
device.features = JSON.stringify(createFeatures(points));
5851
device.updated = Date.now();
5952
device.active = points.length > 0;
6053

run/src/trackers/trackers.ts

+72-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getRhumbLineBearing } from 'geolib';
2+
13
// Only refreshes tracker after 3 minutes.
24
export const REFRESH_EVERY_MINUTES = 3;
35

@@ -7,6 +9,12 @@ export const REFRESH_MAX_HOURS = 24;
79
// Do not fetch for more than 40 seconds.
810
export const REFRESH_TIMEOUT_SECONDS = 40;
911

12+
// Break lines if gap is more than.
13+
const TRACK_GAP_MINUTES = 60;
14+
15+
// Do not keep points that are less than apart.
16+
const MIN_POINT_GAP_MINUTES = 2;
17+
1018
export interface Point {
1119
lat: number;
1220
lon: number;
@@ -15,13 +23,16 @@ export interface Point {
1523
ts: number;
1624
name: string;
1725
emergency: boolean;
18-
msg: string;
26+
msg?: string;
1927
// speed is supported by inreach only.
2028
speed?: number;
2129
// Whether the gps fix is valid - inreach only.
2230
valid?: boolean;
2331
// Is this the last fix for the tracker ?
2432
is_last_fix?: boolean;
33+
// Direction in degree from the last point.
34+
// Populated for the the last point only.
35+
bearing?: number;
2536
}
2637

2738
export interface LineString {
@@ -30,3 +41,63 @@ export interface LineString {
3041
// Timestamp of the oldest fix in milliseconds.
3142
first_ts: number;
3243
}
44+
45+
// Creates GeoJson features for a list of points:
46+
// - Order the points by ascending timestamps,
47+
// - Remove points that are to close to each other,
48+
// - Compute properties specific to the last point,
49+
// - Create a line for each split.
50+
export function createFeatures(points: Point[]): Array<Point | LineString> {
51+
if (points.length == 0) {
52+
return [];
53+
}
54+
// Sort points with older TS first.
55+
points.sort((a, b) => (a.ts > b.ts ? 1 : -1));
56+
// Remove points that are too close to each other.
57+
// Keep the first, last, and any protected point.
58+
let previousTs: number | undefined = points[0].ts;
59+
const simplifiedPoints: Point[] = [points[0]];
60+
for (let i = 1; i < points.length - 2; i++) {
61+
const point = points[i];
62+
if (isProtectedPoint(point) || point.ts - previousTs > MIN_POINT_GAP_MINUTES * 60 * 1000) {
63+
simplifiedPoints.push(point);
64+
previousTs = point.ts;
65+
}
66+
}
67+
simplifiedPoints.push(points[points.length - 1]);
68+
points = simplifiedPoints;
69+
// Add extra info to the very last point.
70+
const numPoints = points.length;
71+
points[numPoints - 1].is_last_fix = true;
72+
if (points.length >= 2) {
73+
points[numPoints - 1].bearing = Math.round(getRhumbLineBearing(points[numPoints - 2], points[numPoints - 1]));
74+
}
75+
// Group points by track (split if the gap is too large).
76+
const lines: LineString[] = [];
77+
previousTs = undefined;
78+
let currentLine: LineString | undefined;
79+
points.forEach((point) => {
80+
const ts = point.ts;
81+
// Create a new line at the beginning and at each gap.
82+
if (previousTs == null || ts - previousTs > TRACK_GAP_MINUTES * 60 * 1000) {
83+
if (currentLine != null) {
84+
lines.push(currentLine);
85+
}
86+
currentLine = { line: [], first_ts: ts };
87+
}
88+
// Accumulate fixes.
89+
currentLine?.line.push([point.lon, point.lat]);
90+
previousTs = ts;
91+
});
92+
if (currentLine) {
93+
lines.push(currentLine);
94+
}
95+
96+
return [...points, ...lines];
97+
}
98+
99+
// Returns wether a point should not be removed.
100+
// Points with emergency or messages must not be removed.
101+
function isProtectedPoint(point: Point): boolean {
102+
return point.emergency == true || (point.msg != null && point.msg.length > 0);
103+
}

0 commit comments

Comments
 (0)