Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
a12c95dcf8 | |||
e9c2cd9e95 | |||
1571086844 | |||
21d0c7cca3 | |||
e14904e59b | |||
2feabd4d87 | |||
6c015c322c | |||
9426e2ead6 | |||
269cdb65f1 | |||
4ed100b97e | |||
9d9a8b4068 | |||
c8325d992d | |||
c8e047e23b | |||
170fee4fa5 | |||
368b2a72f4 | |||
1b5c434afc | |||
6e5ae038b7 | |||
55bf9a43b3 | |||
a7a5daeacf | |||
e9d6d0a733 | |||
6f0a537a06 | |||
dcb034dc10 | |||
a228b444ac | |||
d4826c540c | |||
87b8f9e9ba |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
/.tern-port
|
/.tern-port
|
||||||
/node_modules/
|
node_modules/
|
||||||
/dist/
|
/dist/
|
||||||
.tern-port
|
.tern-port
|
||||||
/.cache/
|
/.cache/
|
||||||
|
/server/log/
|
||||||
|
/.log/
|
||||||
|
3
.prettierrc
Normal file
3
.prettierrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
trailingComma: es5
|
||||||
|
singleQuote: true
|
||||||
|
jsxBracketSameLine: true
|
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>APRS Notifier</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
12
map/index.html
Normal file
12
map/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>APRS Map</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/map.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
7459
package-lock.json
generated
7459
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@ -3,26 +3,25 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aprs-parser": "^1.0.4",
|
"aprs-parser": "github:ad1217/npm-aprs-parser#no-dynamic-require",
|
||||||
"ol": "^5.3.3",
|
"distinct-colors": "^3.0.0",
|
||||||
"vue": "^2.6.10",
|
"ol": "^9.2.4",
|
||||||
"vue-hot-reload-api": "^2.3.3",
|
"parse-duration": "^1.1.0",
|
||||||
"ws": "^5.2.2"
|
"vue": "^3.4.30",
|
||||||
|
"vue3-openlayers": "^10.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/component-compiler-utils": "^3.0.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"parcel": "^1.12.3",
|
"@rollup/plugin-yaml": "^4.1.2",
|
||||||
"vue-template-compiler": "^2.6.10"
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"vite": "^5.3.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"start": "pnpm run -r --parallel dev",
|
||||||
"monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js",
|
"dev": "vite",
|
||||||
"serve": "node src/server.js",
|
"build": "vite build"
|
||||||
"prestart": "npm run monkeyPatch",
|
|
||||||
"start": "npx parcel src/index.html",
|
|
||||||
"prebuild": "npm run monkeyPatch",
|
|
||||||
"build": "npx parcel build --public-url ./ src/index.html"
|
|
||||||
},
|
},
|
||||||
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
|
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
1248
pnpm-lock.yaml
Normal file
1248
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
server/.pnpm-debug.log
Normal file
1
server/.pnpm-debug.log
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
9
server/package.json
Normal file
9
server/package.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start": "npm run dev",
|
||||||
|
"dev": "node ./src/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.17.1"
|
||||||
|
}
|
||||||
|
}
|
31
server/pnpm-lock.yaml
Normal file
31
server/pnpm-lock.yaml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
ws:
|
||||||
|
specifier: ^8.17.1
|
||||||
|
version: 8.17.1
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
ws@8.17.1:
|
||||||
|
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
ws@8.17.1: {}
|
59
server/src/server.js
Normal file
59
server/src/server.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const WebSocket = require('ws');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const client = new net.Socket();
|
||||||
|
const wss = new WebSocket.Server({ host: '127.0.0.1', port: 4321 });
|
||||||
|
|
||||||
|
wss.broadcast = function (data) {
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
client.connect(14580, 'rotate.aprs2.net', () =>
|
||||||
|
client.write('user KC1GDW pass -1 filter r/43.90/-72.15/75\r\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
function datestamp(date) {
|
||||||
|
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on('data', function (data) {
|
||||||
|
let str = data.toString('utf8').replace(/^\s+|\s+$/g, '');
|
||||||
|
console.log(str);
|
||||||
|
|
||||||
|
// strip whitespace, then handle multiple APRS packets per TCP packet
|
||||||
|
str.split('\r\n').forEach((packet) => {
|
||||||
|
// ignore comments and empty lines
|
||||||
|
if (!packet.startsWith('#') || packet === '') {
|
||||||
|
let date = new Date();
|
||||||
|
// create log dir if it doesn't exist
|
||||||
|
if (!fs.existsSync('log')) fs.mkdirSync('log');
|
||||||
|
fs.appendFile(
|
||||||
|
`log/log${datestamp(date)}.json`,
|
||||||
|
JSON.stringify([date, packet]) + '\n',
|
||||||
|
(err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
wss.broadcast(JSON.stringify([date, packet]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
let date = new Date();
|
||||||
|
let filename = `log/log${datestamp(date)}.json`;
|
||||||
|
|
||||||
|
if (fs.existsSync(filename)) {
|
||||||
|
fs.readFileSync(filename)
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line !== '')
|
||||||
|
.forEach((line) => ws.send(line));
|
||||||
|
ws.send("FINISHED REPLAY");
|
||||||
|
}
|
||||||
|
});
|
276
src/Map.vue
Normal file
276
src/Map.vue
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<ol-map
|
||||||
|
:loadTilesWhileAnimating="true"
|
||||||
|
:loadTilesWhileInteracting="true"
|
||||||
|
class="map"
|
||||||
|
>
|
||||||
|
<ol-view :zoom="10" :center="[-72.15, 43.9]" projection="EPSG:4326" />
|
||||||
|
|
||||||
|
<ol-tile-layer title="Base Map">
|
||||||
|
<ol-source-osm />
|
||||||
|
</ol-tile-layer>
|
||||||
|
|
||||||
|
<ol-layer-group title="GPX Routes">
|
||||||
|
<ol-vector-layer v-for="gpxURL in routes" :key="gpxURL" :title="gpxURL">
|
||||||
|
<ol-source-vector :url="gpxURL" :format="new GPX()"> </ol-source-vector>
|
||||||
|
<ol-style>
|
||||||
|
<ol-style-stroke color="hsl(200, 90%, 30%)" :width="5">
|
||||||
|
</ol-style-stroke>
|
||||||
|
</ol-style>
|
||||||
|
</ol-vector-layer>
|
||||||
|
</ol-layer-group>
|
||||||
|
|
||||||
|
<!-- Station Paths -->
|
||||||
|
<div>
|
||||||
|
<ol-layer-group title="Paths">
|
||||||
|
<div v-for="(packets, callsign, idx) in stationPaths" :key="callsign">
|
||||||
|
<!--Paths -->
|
||||||
|
<ol-vector-layer render-mode="image" :title="callsign">
|
||||||
|
<ol-source-vector>
|
||||||
|
<ol-feature>
|
||||||
|
<ol-geom-line-string
|
||||||
|
:coordinates="packetsToStationPathPoints(packets)"
|
||||||
|
>
|
||||||
|
</ol-geom-line-string>
|
||||||
|
</ol-feature>
|
||||||
|
</ol-source-vector>
|
||||||
|
<ol-style>
|
||||||
|
<ol-style-stroke :color="stationColors[idx].hex()" :width="2">
|
||||||
|
</ol-style-stroke>
|
||||||
|
</ol-style>
|
||||||
|
</ol-vector-layer>
|
||||||
|
</div>
|
||||||
|
</ol-layer-group>
|
||||||
|
|
||||||
|
<ol-layer-group title="Points">
|
||||||
|
<div v-for="(packets, callsign, idx) in stationPaths" :key="callsign">
|
||||||
|
<!-- Points -->
|
||||||
|
<ol-vector-layer render-mode="image" :title="callsign">
|
||||||
|
<ol-source-vector>
|
||||||
|
<ol-feature>
|
||||||
|
<ol-geom-multi-point
|
||||||
|
:coordinates="packetsToStationPathPoints(packets)"
|
||||||
|
>
|
||||||
|
</ol-geom-multi-point>
|
||||||
|
</ol-feature>
|
||||||
|
</ol-source-vector>
|
||||||
|
<ol-style>
|
||||||
|
<ol-style-circle :radius="3">
|
||||||
|
<ol-style-fill :color="stationColors[idx].hex()">
|
||||||
|
</ol-style-fill>
|
||||||
|
</ol-style-circle>
|
||||||
|
</ol-style>
|
||||||
|
</ol-vector-layer>
|
||||||
|
</div>
|
||||||
|
</ol-layer-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Digipeater locations -->
|
||||||
|
<ol-vector-layer title="Digipeater Locations">
|
||||||
|
<ol-source-vector>
|
||||||
|
<ol-feature v-for="(position, callsign) in digiPos" :key="callsign">
|
||||||
|
<ol-geom-point :coordinates="position"> </ol-geom-point>
|
||||||
|
<ol-style>
|
||||||
|
<ol-style-circle>
|
||||||
|
<ol-style-fill :color="digiColors[callsign].hex()">
|
||||||
|
</ol-style-fill>
|
||||||
|
</ol-style-circle>
|
||||||
|
<ol-style-text :text="callsign" :offsetY="12"> </ol-style-text>
|
||||||
|
</ol-style>
|
||||||
|
</ol-feature>
|
||||||
|
</ol-source-vector>
|
||||||
|
</ol-vector-layer>
|
||||||
|
|
||||||
|
<!-- Packet Paths -->
|
||||||
|
<ol-vector-layer title="Packet Paths" :visible="false">
|
||||||
|
<ol-source-vector :features="packetPaths"> </ol-source-vector>
|
||||||
|
<!-- TODO: fix style -->
|
||||||
|
<!-- <ol-style :overrideStyleFunction="packetPathStyleFunc"> </ol-style> -->
|
||||||
|
</ol-vector-layer>
|
||||||
|
|
||||||
|
<ol-layerswitcher-control />
|
||||||
|
</ol-map>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import APRSParser from 'aprs-parser/lib/APRSParser';
|
||||||
|
import distinctColors from 'distinct-colors';
|
||||||
|
|
||||||
|
import { GPX } from 'ol/format';
|
||||||
|
import Feature from 'ol/Feature';
|
||||||
|
import MultiLineString from 'ol/geom/MultiLineString';
|
||||||
|
import LineString from 'ol/geom/LineString';
|
||||||
|
import Style from 'ol/style/Style';
|
||||||
|
import Stroke from 'ol/style/Stroke';
|
||||||
|
|
||||||
|
import packetLog from '../IS_packets.txt?raw';
|
||||||
|
const routes = Object.values(
|
||||||
|
import.meta.glob('./gpx/*.gpx', { eager: true, import: 'default' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const parser = new APRSParser();
|
||||||
|
const packets = packetLog
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
// parse to Date and APRS packet
|
||||||
|
.map((line) => {
|
||||||
|
let packet = parser.parse(line.slice(29));
|
||||||
|
packet.date = new Date(line.slice(0, 18));
|
||||||
|
return packet;
|
||||||
|
});
|
||||||
|
|
||||||
|
function packetsToStationPathPoints(packets) {
|
||||||
|
return packets.map((packet) => [packet.data.longitude, packet.data.latitude]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToString(path) {
|
||||||
|
return path
|
||||||
|
.filter(
|
||||||
|
(station) => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/)
|
||||||
|
)
|
||||||
|
.map((station) => station.toString().trim().replace(/\*$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByCall(acc, packet) {
|
||||||
|
let callsign = packet.from.toString().trim();
|
||||||
|
if (!(callsign in acc)) acc[callsign] = [];
|
||||||
|
acc[callsign].push(packet);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForDigi(digi) {
|
||||||
|
if (digi in digiColors.value) {
|
||||||
|
return digiColors.value[digi].hex();
|
||||||
|
} else {
|
||||||
|
return '#000000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packetPathStyleFunc(feature, resolution) {
|
||||||
|
let paths = feature.getProperties().properties.paths.slice(0);
|
||||||
|
let styles = [];
|
||||||
|
|
||||||
|
feature
|
||||||
|
.getGeometry()
|
||||||
|
.getLineStrings()
|
||||||
|
.forEach((ls) => {
|
||||||
|
let path = paths.shift().slice(0);
|
||||||
|
ls.forEachSegment((start, end) => {
|
||||||
|
let color = colorForDigi(path.shift());
|
||||||
|
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
geometry: new LineString([start, end]),
|
||||||
|
stroke: new Stroke({ color: color, width: 2 }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(styles);
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionalPackets = computed(() => {
|
||||||
|
return (
|
||||||
|
packets
|
||||||
|
.filter(
|
||||||
|
(packet) =>
|
||||||
|
packet.date > new Date('2018-07-13') &&
|
||||||
|
packet.date < new Date('2018-07-14')
|
||||||
|
)
|
||||||
|
// filter to just positional data
|
||||||
|
.filter((packet) => 'data' in packet && 'latitude' in packet.data)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationPaths = computed(() => {
|
||||||
|
// group by callsign
|
||||||
|
return positionalPackets.value.reduce(groupByCall, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const digis = computed(() => {
|
||||||
|
let digiCalls = new Set(
|
||||||
|
packets
|
||||||
|
.map((packet) => pathToString(packet.via))
|
||||||
|
.reduce((acc, stations) => acc.concat(stations))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
packets
|
||||||
|
// filter to digis
|
||||||
|
.filter((packet) => digiCalls.has(packet.from.toString().trim()))
|
||||||
|
// filter to just positional data
|
||||||
|
.filter((packet) => 'data' in packet && 'latitude' in packet.data)
|
||||||
|
// group by call
|
||||||
|
.reduce(groupByCall, {})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const digiPos = computed(() => {
|
||||||
|
return Object.entries(digis.value).reduce((acc, [digi, packets]) => {
|
||||||
|
let lastPacket = packets[packets.length - 1];
|
||||||
|
acc[digi] = [lastPacket.data.longitude, lastPacket.data.latitude];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const packetPaths = computed(() => {
|
||||||
|
let digipeaterPostitions = digiPos.value;
|
||||||
|
return Object.entries(stationPaths.value).map(([station, packets]) => {
|
||||||
|
let lines = packets.map((packet) => {
|
||||||
|
let path = pathToString(packet.via);
|
||||||
|
return {
|
||||||
|
// first point in path is originating station
|
||||||
|
coords: [
|
||||||
|
[packet.data.longitude, packet.data.latitude],
|
||||||
|
...path.map((hop) => digipeaterPostitions[hop] || [0, 0]),
|
||||||
|
],
|
||||||
|
path: path,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Feature({
|
||||||
|
id: station,
|
||||||
|
geometry: new MultiLineString(lines.map((p) => p.coords)),
|
||||||
|
properties: { paths: lines.map((p) => p.path) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationColors = computed(() => {
|
||||||
|
return distinctColors({
|
||||||
|
count: Object.keys(stationPaths.value).length,
|
||||||
|
lightMin: 20,
|
||||||
|
lightMax: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const digiColors = computed(() => {
|
||||||
|
let colors = distinctColors({
|
||||||
|
count: Object.keys(digis.value).length,
|
||||||
|
lightMin: 20,
|
||||||
|
lightMax: 80,
|
||||||
|
});
|
||||||
|
return Object.keys(digis.value).reduce((acc, callsign, index) => {
|
||||||
|
acc[callsign] = colors[index];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr :class="{ timedOut, lowVoltage, neverHeard: !status.lastHeard }">
|
<tr :class="{ timedOut, isLowVoltage, neverHeard: !stationStatus.lastHeard }">
|
||||||
<td :title="callsign">{{ tacticalAndOrCall }}</td>
|
<td :title="callsign">{{ tacticalAndOrCall }}</td>
|
||||||
<template v-if="status.lastHeard">
|
<template v-if="stationStatus.lastHeard">
|
||||||
<td>{{ formatTime(status.lastHeard) }}</td>
|
<td>{{ formatTime(stationStatus.lastHeard) }}</td>
|
||||||
<td>{{ formatTime(now - status.lastHeard, true) }}</td>
|
<td>{{ formatTime(now - stationStatus.lastHeard, true) }}</td>
|
||||||
<td>{{ formatTime(Math.round(status.avgDelta), true) }}</td>
|
<td>{{ formatTime(Math.round(stationStatus.avgDelta), true) }}</td>
|
||||||
<td>{{ status.lastMicE }}</td>
|
<td>{{ stationStatus.lastMicE }}</td>
|
||||||
<td>{{ status.lastVoltage || "" }}</td>
|
<td>{{ stationStatus.lastVoltage || '' }}</td>
|
||||||
<td>{{ status.lastTemperature || "" }}</td>
|
<td>{{ stationStatus.lastTemperature || '' }}</td>
|
||||||
<td>{{ status.lastComment }}</td>
|
<td>{{ stationStatus.lastComment }}</td>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<td>Never Heard</td>
|
<td>Never Heard</td>
|
||||||
@ -22,125 +22,131 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import config from "./status_config.yaml";
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import parseDuration from 'parse-duration';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: "StationRow",
|
callsign: String,
|
||||||
props: { callsign: String, tactical: String, messages: Array, now: Date },
|
tactical: String,
|
||||||
|
timeoutLength: String,
|
||||||
|
lowVoltage: Number,
|
||||||
|
finishedReplay: Boolean,
|
||||||
|
messages: Array,
|
||||||
|
now: Date,
|
||||||
|
});
|
||||||
|
|
||||||
data() {
|
function notify(title, body) {
|
||||||
return {
|
if (props.finishedReplay) {
|
||||||
status: {
|
return new Notification(title, { body: body, requireInteraction: true });
|
||||||
lastHeard: null,
|
|
||||||
delta: null,
|
|
||||||
lastVoltage: null,
|
|
||||||
lastTemperature: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
notify(title, body) {
|
|
||||||
return new Notification(title, { body: body, requireInteraction: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
formatTime(time, isDuration = false) {
|
|
||||||
return new Date(time).toLocaleTimeString(
|
|
||||||
"en-GB",
|
|
||||||
isDuration ? { timeZone: "UTC" } : {}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
prettyDuration(duration) {
|
|
||||||
let date = new Date(duration);
|
|
||||||
return [
|
|
||||||
...Object.entries({
|
|
||||||
hours: date.getUTCHours(),
|
|
||||||
minutes: date.getUTCMinutes(),
|
|
||||||
seconds: date.getUTCSeconds(),
|
|
||||||
milliseconds: date.getUTCMilliseconds()
|
|
||||||
})
|
|
||||||
]
|
|
||||||
.filter(([suffix, num]) => num > 0)
|
|
||||||
.map(([suffix, num]) => num + " " + suffix)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
messages() {
|
|
||||||
Object.assign(
|
|
||||||
this.status,
|
|
||||||
this.messages.reduce((acc, message, idx, arr) => {
|
|
||||||
acc.lastHeard = message.date.getTime();
|
|
||||||
if (idx === 0) {
|
|
||||||
acc.avgDelta = 0;
|
|
||||||
} else {
|
|
||||||
let delta = message.date.getTime() - arr[idx - 1].date.getTime();
|
|
||||||
acc.avgDelta = (acc.avgDelta * (idx - 1) + delta) / idx;
|
|
||||||
}
|
|
||||||
if ("data" in message) {
|
|
||||||
if ("analog" in message.data) {
|
|
||||||
acc.lastVoltage = message.data.analog[0] / 10;
|
|
||||||
acc.lastTemperature = message.data.analog[1];
|
|
||||||
}
|
|
||||||
acc.lastMicE = message.data.mice || acc.lastMicE;
|
|
||||||
acc.lastComment = message.data.comment || acc.lastComment;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
lowVoltage(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.notify(
|
|
||||||
`${this.tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
|
|
||||||
`Voltage: ${this.status.lastVoltage}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
timedOut(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.notify(
|
|
||||||
`${
|
|
||||||
this.tacticalAndOrCall
|
|
||||||
} has not been heard for over ${this.prettyDuration(
|
|
||||||
config.timeoutLength
|
|
||||||
)}!`,
|
|
||||||
`Last Heard: ${this.formatTime(
|
|
||||||
this.status.lastHeard
|
|
||||||
)} (${this.prettyDuration(this.now - this.status.lastHeard)} ago!)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
tacticalAndOrCall() {
|
|
||||||
return this.tactical
|
|
||||||
? `${this.tactical} [${this.callsign}]`
|
|
||||||
: this.callsign;
|
|
||||||
},
|
|
||||||
|
|
||||||
timedOut() {
|
|
||||||
if (!this.status.lastHeard) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nowDelta = new Date(this.now - this.status.lastHeard);
|
|
||||||
return nowDelta.getTime() > config.timeoutLength;
|
|
||||||
},
|
|
||||||
|
|
||||||
lowVoltage() {
|
|
||||||
return (
|
|
||||||
this.status.lastVoltage && this.status.lastVoltage < config.lowVoltage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function formatTime(time, isDuration = false) {
|
||||||
|
return new Date(time).toLocaleTimeString(
|
||||||
|
'en-GB',
|
||||||
|
isDuration ? { timeZone: 'UTC' } : {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutLengthMs = computed(() => {
|
||||||
|
return parseDuration(props.timeoutLength);
|
||||||
|
});
|
||||||
|
|
||||||
|
function prettyDuration(duration) {
|
||||||
|
let date = new Date(duration);
|
||||||
|
return [
|
||||||
|
...Object.entries({
|
||||||
|
hours: date.getUTCHours(),
|
||||||
|
minutes: date.getUTCMinutes(),
|
||||||
|
seconds: date.getUTCSeconds(),
|
||||||
|
milliseconds: date.getUTCMilliseconds(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.filter(([suffix, num]) => num > 0)
|
||||||
|
.map(([suffix, num]) => num + ' ' + suffix)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tacticalAndOrCall = computed(() => {
|
||||||
|
return props.tactical
|
||||||
|
? `${props.tactical} [${props.callsign}]`
|
||||||
|
: props.callsign;
|
||||||
|
});
|
||||||
|
|
||||||
|
const timedOut = computed(() => {
|
||||||
|
if (stationStatus.value.lastHeard === null) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
props.now.getTime() - stationStatus.value.lastHeard >
|
||||||
|
timeoutLengthMs.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLowVoltage = computed(() => {
|
||||||
|
return (
|
||||||
|
stationStatus.value.lastVoltage &&
|
||||||
|
stationStatus.value.lastVoltage < props.lowVoltage
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stationStatus = computed(() => {
|
||||||
|
const status = {
|
||||||
|
lastHeard: null,
|
||||||
|
delta: null,
|
||||||
|
lastVoltage: null,
|
||||||
|
lastTemperature: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
status,
|
||||||
|
props.messages.reduce((acc, message, idx, arr) => {
|
||||||
|
acc.lastHeard = message.date.getTime();
|
||||||
|
if (idx === 0) {
|
||||||
|
acc.avgDelta = 0;
|
||||||
|
} else {
|
||||||
|
let delta = message.date.getTime() - arr[idx - 1].date.getTime();
|
||||||
|
acc.avgDelta = (acc.avgDelta * (idx - 1) + delta) / idx;
|
||||||
|
}
|
||||||
|
if ('data' in message) {
|
||||||
|
if ('analog' in message.data) {
|
||||||
|
acc.lastVoltage = message.data.analog[0] / 10;
|
||||||
|
acc.lastTemperature = message.data.analog[1];
|
||||||
|
}
|
||||||
|
acc.lastMicE = message.data.mice || acc.lastMicE;
|
||||||
|
acc.lastComment = message.data.comment || acc.lastComment;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
return status;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isLowVoltage, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
notify(
|
||||||
|
`${tacticalAndOrCall}'s battery has dropepd below ${props.lowVoltage}V`,
|
||||||
|
`Voltage: ${stationStatus.value.lastVoltage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(timedOut, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
notify(
|
||||||
|
`${tacticalAndOrCall.value} has not been heard for over ${prettyDuration(
|
||||||
|
timeoutLengthMs.value
|
||||||
|
)}!`,
|
||||||
|
`Last Heard: ${formatTime(
|
||||||
|
stationStatus.value.lastHeard
|
||||||
|
)} (${prettyDuration(
|
||||||
|
props.now.getTime() - stationStatus.value.lastHeard
|
||||||
|
)} ago!)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -148,7 +154,7 @@ tr.timedOut {
|
|||||||
background-color: red;
|
background-color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr.lowVoltage {
|
tr.isLowVoltage {
|
||||||
background-color: yellow;
|
background-color: yellow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<button
|
||||||
|
class="notification-request"
|
||||||
|
v-show="!canNotify"
|
||||||
|
@click="requestNotification"
|
||||||
|
>
|
||||||
|
Enable Notifications
|
||||||
|
</button>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Callsign</th>
|
<th>Callsign</th>
|
||||||
@ -13,10 +21,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<StationRow
|
<StationRow
|
||||||
v-for="(tactical, callsign) in trackedStations"
|
v-for="(stationProps, callsign) in trackedStations"
|
||||||
:key="callsign"
|
:key="callsign"
|
||||||
:callsign="callsign"
|
:callsign="callsign"
|
||||||
:tactical="tactical"
|
v-bind="{ ...config.default, ...stationProps }"
|
||||||
|
:finishedReplay="finishedReplay"
|
||||||
:messages="messagesFromStation[callsign] || []"
|
:messages="messagesFromStation[callsign] || []"
|
||||||
:now="now"
|
:now="now"
|
||||||
>
|
>
|
||||||
@ -25,94 +34,121 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import aprs from "aprs-parser";
|
import { ref, onMounted } from 'vue';
|
||||||
|
import APRSParser from 'aprs-parser/lib/APRSParser';
|
||||||
|
|
||||||
import StationRow from "./StationRow.vue";
|
import StationRow from './StationRow.vue';
|
||||||
|
import config from './status_config.yaml';
|
||||||
|
|
||||||
import config from "./status_config.yaml";
|
const parser = new APRSParser();
|
||||||
|
let aprsStream = null;
|
||||||
|
const finishedReplay = ref(false);
|
||||||
|
const messages = ref([]);
|
||||||
|
const messagesFromStation = ref({});
|
||||||
|
const now = ref(new Date());
|
||||||
|
const trackedStations = ref(normalizeConfigStations());
|
||||||
|
const canNotify = ref(Notification.permission === 'granted');
|
||||||
|
|
||||||
export default {
|
function normalizeConfigStations() {
|
||||||
name: "StationStatus",
|
return [...Object.entries(config.trackedStations)]
|
||||||
components: { StationRow },
|
.map(([callsign, tacticalOrProps]) => {
|
||||||
data() {
|
if (typeof tacticalOrProps === 'string') {
|
||||||
return {
|
return [callsign, { tactical: tacticalOrProps }];
|
||||||
aprsStream: null,
|
|
||||||
parser: new aprs.APRSParser(),
|
|
||||||
messages: [],
|
|
||||||
messagesFromStation: {},
|
|
||||||
now: new Date(),
|
|
||||||
trackedStations: config.trackedStations
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
// request notification permissions
|
|
||||||
if (Notification.permission !== "granted") {
|
|
||||||
Notification.requestPermission(permission => {
|
|
||||||
if (permission === "granted") {
|
|
||||||
new Notification("Test notification", { body: "whatever" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to websocket aprs stream
|
|
||||||
this.connectToStream();
|
|
||||||
|
|
||||||
// update shared current time every second
|
|
||||||
window.setInterval(() => (this.now = new Date()), 1000);
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
connectToStream() {
|
|
||||||
this.aprsStream = new WebSocket("ws://localhost:4321");
|
|
||||||
this.aprsStream.onclose = () => {
|
|
||||||
// Try to reconnect every 5 seconds
|
|
||||||
let interval = window.setTimeout(() => {
|
|
||||||
window.clearInterval(interval);
|
|
||||||
this.connectToStream();
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
this.aprsStream.onmessage = event =>
|
|
||||||
this.handleMessage(JSON.parse(event.data));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleMessage(packet) {
|
|
||||||
let message = this.parser.parse(packet[1]);
|
|
||||||
message.date = new Date(packet[0]);
|
|
||||||
|
|
||||||
console.log(message);
|
|
||||||
this.messages.push(message);
|
|
||||||
let callsign = message.from && message.from.toString();
|
|
||||||
if (callsign in this.messagesFromStation) {
|
|
||||||
this.messagesFromStation[callsign].push(message);
|
|
||||||
} else {
|
} else {
|
||||||
this.$set(this.messagesFromStation, callsign, [message]);
|
return [callsign, tacticalOrProps];
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.reduce((acc, [callsign, props]) => {
|
||||||
|
console.log(callsign, props);
|
||||||
|
acc[callsign] = props;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
// message to TACTICAL setting a tactical nickname from an
|
onMounted(() => {
|
||||||
// authorized call, so add/update it in trackedStations
|
// Connect to websocket aprs stream
|
||||||
if (
|
connectToStream();
|
||||||
message.data &&
|
|
||||||
message.data.addressee &&
|
// update shared current time every second
|
||||||
message.data.addressee.call === "TACTICAL" &&
|
window.setInterval(() => (now.value = new Date()), 1000);
|
||||||
config.TACTICAL_whitelist.includes(message.from.toString())
|
});
|
||||||
) {
|
|
||||||
message.data.text.split(";").map(tac_assoc => {
|
function connectToStream() {
|
||||||
let [call, tac] = tac_assoc.split("=", 2);
|
aprsStream = new WebSocket('ws://localhost:4321');
|
||||||
if (tac) {
|
aprsStream.onclose = () => {
|
||||||
this.trackedStations[call] = tac;
|
// Try to reconnect every 5 seconds
|
||||||
} else {
|
let interval = window.setTimeout(() => {
|
||||||
delete this.trackedStations[call];
|
window.clearInterval(interval);
|
||||||
}
|
connectToStream();
|
||||||
});
|
}, 5000);
|
||||||
}
|
};
|
||||||
|
aprsStream.onmessage = (event) => {
|
||||||
|
if (event.data === 'FINISHED REPLAY') {
|
||||||
|
finishedReplay.value = true;
|
||||||
|
} else if (event.data !== '') {
|
||||||
|
handleMessage(JSON.parse(event.data));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(packet) {
|
||||||
|
let message = parser.parse(packet[1]);
|
||||||
|
message.date = new Date(packet[0]);
|
||||||
|
|
||||||
|
console.info(message);
|
||||||
|
messages.value.push(message);
|
||||||
|
let callsign = message.from && message.from.toString();
|
||||||
|
if (callsign in messagesFromStation.value) {
|
||||||
|
messagesFromStation.value[callsign].push(message);
|
||||||
|
} else {
|
||||||
|
messagesFromStation.value[callsign] = [message];
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// message to TACTICAL setting a tactical nickname from an
|
||||||
|
// authorized call, so add/update it in trackedStations
|
||||||
|
if (
|
||||||
|
message.data &&
|
||||||
|
message.data.addressee &&
|
||||||
|
message.data.addressee.call === 'TACTICAL' &&
|
||||||
|
config.TACTICAL_whitelist.includes(message.from.toString())
|
||||||
|
) {
|
||||||
|
message.data.text.split(';').map((tac_assoc) => {
|
||||||
|
let [call, tac] = tac_assoc.split('=', 2);
|
||||||
|
if (tac) {
|
||||||
|
if (!Object.hasOwn(trackedStations.value, call)) {
|
||||||
|
trackedStations.value[call] = {};
|
||||||
|
}
|
||||||
|
trackedStations.value[call].tactical = tac;
|
||||||
|
} else {
|
||||||
|
delete trackedStations.value[call];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestNotification() {
|
||||||
|
// request notification permissions
|
||||||
|
if (Notification.permission !== 'granted') {
|
||||||
|
Notification.requestPermission((permission) => {
|
||||||
|
canNotify.value = permission;
|
||||||
|
if (permission === 'granted') {
|
||||||
|
new Notification('Test notification', { body: 'whatever' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.notification-request {
|
||||||
|
position: fixed;
|
||||||
|
right: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
@ -132,7 +168,7 @@ table th {
|
|||||||
/* border magic for sticky header */
|
/* border magic for sticky header */
|
||||||
/* https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element */
|
/* https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element */
|
||||||
th::before {
|
th::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -142,7 +178,7 @@ th::before {
|
|||||||
top: 1px;
|
top: 1px;
|
||||||
}
|
}
|
||||||
th::after {
|
th::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"> </div>
|
|
||||||
<script src="./index.js"></script>
|
|
||||||
</body>
|
|
12
src/index.js
12
src/index.js
@ -1,7 +1,7 @@
|
|||||||
import Vue from 'vue';
|
import * as Vue from 'vue';
|
||||||
import App from './StatusScreen.vue';
|
|
||||||
|
|
||||||
new Vue({
|
import StatusScreen from './StatusScreen.vue';
|
||||||
el: '#app',
|
|
||||||
render: h => h(App),
|
const app = Vue.createApp(StatusScreen);
|
||||||
});
|
|
||||||
|
app.mount('#app');
|
||||||
|
48
src/map.css
48
src/map.css
@ -1,48 +0,0 @@
|
|||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.ol-control.layer-toggles {
|
|
||||||
top: 0.5em;
|
|
||||||
right: 0.5em;
|
|
||||||
background-color: rgba(70, 115, 164, 0.7);
|
|
||||||
color: #eee;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-height: calc(100vh - 1em);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.layer-toggles > div {
|
|
||||||
margin: 0.5em;
|
|
||||||
margin-right: 1em;
|
|
||||||
}
|
|
||||||
.ol-control.layer-toggles:hover {
|
|
||||||
background-color: rgba(0,60,136,0.7);
|
|
||||||
}
|
|
||||||
.layer-toggles > div > label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.expand {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.expand + span::before {
|
|
||||||
content: '\25B6';
|
|
||||||
}
|
|
||||||
.expand:checked + span::before {
|
|
||||||
content: '\25BC';
|
|
||||||
}
|
|
||||||
.expand ~ .collapsible-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.expand:checked ~ .collapsible-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.collapsible-content {
|
|
||||||
margin-left: 0.8em;
|
|
||||||
}
|
|
||||||
.collapsible-content > label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
11
src/map.html
11
src/map.html
@ -1,11 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" type="text/css" href="map.css">
|
|
||||||
<title>OpenLayers example</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="map" class="map"></div>
|
|
||||||
<script src="./map.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
317
src/map.js
317
src/map.js
@ -1,314 +1,11 @@
|
|||||||
import 'ol/ol.css';
|
import * as Vue from 'vue';
|
||||||
import {Map as olMap, View} from 'ol';
|
|
||||||
import {Control} from 'ol/control';
|
|
||||||
import {Group as LayerGroup, Tile as TileLayer, Vector as VectorLayer} from 'ol/layer';
|
|
||||||
import {OSM, Vector as VectorSource} from 'ol/source';
|
|
||||||
import {GPX} from 'ol/format';
|
|
||||||
import Feature from 'ol/Feature';
|
|
||||||
import {fromLonLat} from 'ol/proj';
|
|
||||||
import {Circle as CircleStyle, Icon, Fill, Stroke, Style, Text} from 'ol/style';
|
|
||||||
import Projection from 'ol/proj/Projection';
|
|
||||||
import {LineString, Point} from 'ol/geom';
|
|
||||||
|
|
||||||
import {readFileSync} from 'fs';
|
import OpenLayersMap from 'vue3-openlayers';
|
||||||
const packetLog = readFileSync(__dirname + '/../IS_packets.txt', 'utf-8');
|
import 'vue3-openlayers/dist/vue3-openlayers.css';
|
||||||
|
|
||||||
import {APRSParser} from 'aprs-parser';
|
import Map from './Map.vue';
|
||||||
|
|
||||||
let tile_layer = new TileLayer({source: new OSM()});
|
const app = Vue.createApp(Map);
|
||||||
|
app.use(OpenLayersMap);
|
||||||
|
|
||||||
import route_data from 'gpx/*.gpx';
|
app.mount('#app');
|
||||||
|
|
||||||
let routes = new LayerGroup({
|
|
||||||
title: "Routes",
|
|
||||||
layers: Object.keys(route_data).map(name => new VectorLayer({
|
|
||||||
title: name.replace(/_/g, " "),
|
|
||||||
source: new VectorSource({
|
|
||||||
url: route_data[name],
|
|
||||||
format: new GPX()
|
|
||||||
}),
|
|
||||||
style: new Style({
|
|
||||||
stroke: new Stroke(
|
|
||||||
{color: 'hsl(200, 90%, 30%)', width: 5}
|
|
||||||
)})
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let map = new olMap({
|
|
||||||
target: 'map',
|
|
||||||
layers: [
|
|
||||||
tile_layer,
|
|
||||||
routes
|
|
||||||
],
|
|
||||||
view: new View({
|
|
||||||
center: fromLonLat([-72.15, 43.90]),
|
|
||||||
zoom: 10
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
class ColorGenerator {
|
|
||||||
constructor(count) {
|
|
||||||
let mult = Math.floor(360 / count);
|
|
||||||
this.hues = Array.from(Array(count).keys()).map(x => x * mult);
|
|
||||||
|
|
||||||
// Shuffle (this is not a great shuffle, but I don't care)
|
|
||||||
this.hues.forEach((current, index, arr) => {
|
|
||||||
let randomIndex = Math.floor(Math.random() * index);
|
|
||||||
[arr[index], arr[randomIndex]] = [arr[randomIndex], arr[index]];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
get() {
|
|
||||||
return this.hues.pop();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function transformGeometry(geometry) {
|
|
||||||
return geometry.transform(new Projection({code: "EPSG:4326"}),
|
|
||||||
tile_layer.getSource().getProjection());
|
|
||||||
}
|
|
||||||
|
|
||||||
function plotPaths(packets) {
|
|
||||||
let path_layers = new LayerGroup({title: "Station Paths"});
|
|
||||||
map.addLayer(path_layers);
|
|
||||||
|
|
||||||
let paths = packets
|
|
||||||
.filter(packet => packet.date > new Date("2018-07-14") && packet.date < new Date("2018-07-15"))
|
|
||||||
// filter to just positional data
|
|
||||||
.filter(packet => 'data' in packet && 'latitude' in packet.data)
|
|
||||||
// join into Arrays of points
|
|
||||||
.reduce((acc, packet) => {
|
|
||||||
let callsign = packet.from.toString().trim();
|
|
||||||
if (!acc.has(callsign)) acc.set(callsign, []);
|
|
||||||
acc.get(callsign).push([packet.data.longitude, packet.data.latitude, packet]);
|
|
||||||
return acc;
|
|
||||||
}, new Map());
|
|
||||||
|
|
||||||
let colorGen = new ColorGenerator(paths.size);
|
|
||||||
|
|
||||||
// plot on map
|
|
||||||
paths.forEach((points, callsign) => {
|
|
||||||
let color = 'hsl(' + colorGen.get() + ', 75%, 50%)';
|
|
||||||
let path_layer = new VectorLayer({
|
|
||||||
title: "Path",
|
|
||||||
source: new VectorSource({features: [
|
|
||||||
new Feature({geometry: transformGeometry(new LineString(points))})
|
|
||||||
]}),
|
|
||||||
style: new Style({stroke: new Stroke({color: color, width: 2})})
|
|
||||||
});
|
|
||||||
|
|
||||||
let points_layer = new VectorLayer({
|
|
||||||
title: "Points",
|
|
||||||
source: new VectorSource({
|
|
||||||
features: points.map(point => new Feature({
|
|
||||||
geometry: transformGeometry(new Point(point)),
|
|
||||||
packet: point[3] // TODO: this seems a bit bad
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
style: new Style({
|
|
||||||
image: new CircleStyle({
|
|
||||||
radius: 4,
|
|
||||||
fill: new Fill({color: color})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let callsign_layer = new LayerGroup({
|
|
||||||
title: callsign,
|
|
||||||
layers: [path_layer, points_layer]
|
|
||||||
});
|
|
||||||
path_layers.getLayers().push(callsign_layer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function plotPacketPaths(packets) {
|
|
||||||
function pathToString(path) {
|
|
||||||
return path
|
|
||||||
.filter(station => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/))
|
|
||||||
.map(station => station.toString().trim().replace(/\*$/, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let digiCalls =
|
|
||||||
new Set(packets
|
|
||||||
.map(packet => pathToString(packet.via))
|
|
||||||
.reduce((acc, stations) => acc.concat(stations)));
|
|
||||||
|
|
||||||
let digiPos = packets
|
|
||||||
// filter to digis
|
|
||||||
.filter(packet => digiCalls.has(packet.from.toString().trim()))
|
|
||||||
// filter to just positional data
|
|
||||||
.filter(packet => 'data' in packet && 'latitude' in packet.data)
|
|
||||||
// convert to callsign -> position mapping
|
|
||||||
.reduce((stations, packet) =>
|
|
||||||
stations.set(packet.from.toString().trim(),
|
|
||||||
[packet.data.longitude, packet.data.latitude]),
|
|
||||||
new Map());
|
|
||||||
|
|
||||||
let colorGen = new ColorGenerator(digiPos.size);
|
|
||||||
let colorMap = Array.from(digiPos.keys()).reduce(
|
|
||||||
(acc, callsign, index) =>
|
|
||||||
acc.set(callsign, colorGen.hues[index]), new Map());
|
|
||||||
|
|
||||||
// plot digis
|
|
||||||
// TODO: icons
|
|
||||||
let digi_style = new Style({
|
|
||||||
image: new CircleStyle({
|
|
||||||
radius: 5,
|
|
||||||
fill: new Fill({color: 'grey'})
|
|
||||||
}),
|
|
||||||
text: new Text({
|
|
||||||
font: 'bold 11px "Open Sans", "Arial Unicode MS", "sans-serif"',
|
|
||||||
overflow: true,
|
|
||||||
fill: new Fill({color: 'black'})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let digi_layer = new VectorLayer({
|
|
||||||
title: "Digipeater Labels",
|
|
||||||
zIndex: 1, // TODO: probably not the best way to do this
|
|
||||||
source: new VectorSource({
|
|
||||||
features: Array.from(digiPos.keys()).map(callsign =>
|
|
||||||
new Feature({
|
|
||||||
geometry: transformGeometry(new Point(digiPos.get(callsign))),
|
|
||||||
callsign: callsign
|
|
||||||
})
|
|
||||||
)}),
|
|
||||||
style: feature => {
|
|
||||||
digi_style.setText(new Text({
|
|
||||||
text: feature.get('callsign'),
|
|
||||||
offsetY: 12
|
|
||||||
}));
|
|
||||||
return digi_style;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
map.addLayer(digi_layer);
|
|
||||||
|
|
||||||
let packet_path_layers = new LayerGroup({title: "Packet Paths"});
|
|
||||||
let layers_map = new Map();
|
|
||||||
map.addLayer(packet_path_layers);
|
|
||||||
packets
|
|
||||||
.filter(packet => packet.date > new Date("2018-07-14") && packet.date < new Date("2018-07-15"))
|
|
||||||
// filter by callsign
|
|
||||||
//.filter(packet => packet.from.toString() === "W1HS-9")
|
|
||||||
// filter to just positional data
|
|
||||||
.filter(packet => 'data' in packet && 'latitude' in packet.data)
|
|
||||||
.forEach(packet => {
|
|
||||||
pathToString(packet.via)
|
|
||||||
.forEach((station, index, stations) => {
|
|
||||||
if (digiPos.get(station) === undefined) {
|
|
||||||
console.log(station);
|
|
||||||
}
|
|
||||||
|
|
||||||
// first point in path is originating station
|
|
||||||
let previous = (index === 0) ?
|
|
||||||
[packet.data.longitude, packet.data.latitude] :
|
|
||||||
digiPos.get(stations[index - 1]) || [0, 0];
|
|
||||||
|
|
||||||
let pathFeature = new Feature(transformGeometry(
|
|
||||||
new LineString([previous, digiPos.get(station) || [0, 0]])));
|
|
||||||
|
|
||||||
// TODO: want to color per station that hears it, probably means
|
|
||||||
// making a lot more features
|
|
||||||
let color = colorMap.get(station);
|
|
||||||
pathFeature.setStyle(new Style({
|
|
||||||
stroke: new Stroke(
|
|
||||||
{color: 'hsl(' + color + ', 60%, 60%)', width: 2}
|
|
||||||
)}));
|
|
||||||
|
|
||||||
if (!layers_map.has(station)) {
|
|
||||||
layers_map.set(station, new VectorLayer({
|
|
||||||
title: station,
|
|
||||||
source: new VectorSource(),
|
|
||||||
renderMode: 'image'
|
|
||||||
}));
|
|
||||||
packet_path_layers.getLayers().push(layers_map.get(station));
|
|
||||||
}
|
|
||||||
layers_map.get(station).getSource().addFeature(pathFeature);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function layer_toggle(layer) {
|
|
||||||
if (layer.toggle_element === undefined) {
|
|
||||||
layer.toggle_element = document.createElement('label');
|
|
||||||
|
|
||||||
let checkbox = layer.toggle_element.appendChild(
|
|
||||||
document.createElement('input'));
|
|
||||||
checkbox.type = "checkbox";
|
|
||||||
checkbox.checked = layer.getVisible();
|
|
||||||
checkbox.addEventListener('change', event => {
|
|
||||||
layer.setVisible(event.target.checked);
|
|
||||||
});
|
|
||||||
layer.toggle_element.appendChild(
|
|
||||||
document.createTextNode(layer.get('title')));
|
|
||||||
}
|
|
||||||
return layer.toggle_element;
|
|
||||||
}
|
|
||||||
|
|
||||||
function layer_toggles(layer, parentElement) {
|
|
||||||
if (layer instanceof LayerGroup) {
|
|
||||||
if (layer.group_toggle === undefined) {
|
|
||||||
let label = layer.group_toggle = parentElement.appendChild(
|
|
||||||
document.createElement('label'));
|
|
||||||
let input = label.appendChild(document.createElement('input'));
|
|
||||||
input.type = 'checkbox';
|
|
||||||
input.className = "expand";
|
|
||||||
label.appendChild(document.createElement('span'));
|
|
||||||
|
|
||||||
label.appendChild(layer_toggle(layer)); // whole LayerGroup
|
|
||||||
|
|
||||||
let child_toggle_element = label.appendChild(
|
|
||||||
document.createElement('input'));
|
|
||||||
child_toggle_element.type = 'checkbox';
|
|
||||||
child_toggle_element.checked = true;
|
|
||||||
child_toggle_element.addEventListener('change', event => {
|
|
||||||
layer.getLayers().forEach(subLayer => {
|
|
||||||
subLayer.setVisible(event.target.checked);
|
|
||||||
subLayer.toggle_element.querySelector("input").checked =
|
|
||||||
event.target.checked;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let container = label.appendChild(document.createElement('div'));
|
|
||||||
container.className = 'collapsible-content';
|
|
||||||
layer.getLayers().forEach(
|
|
||||||
subLayer => layer_toggles(subLayer, container));
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
parentElement.appendChild(layer_toggle(layer));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_layer_toggles(event, element) {
|
|
||||||
event.map.getLayers().getArray()
|
|
||||||
.filter(layer => layer.get('title') !== undefined)
|
|
||||||
.forEach(layer => layer_toggles(layer, element));
|
|
||||||
}
|
|
||||||
|
|
||||||
(function makeLayerToogleControl() {
|
|
||||||
let element = document.createElement('div');
|
|
||||||
element.className = 'layer-toggles ol-unselectable ol-control';
|
|
||||||
let inner = element.appendChild(document.createElement('div'));
|
|
||||||
|
|
||||||
let control = new Control(
|
|
||||||
{element: element,
|
|
||||||
render: event => render_layer_toggles(event, inner)});
|
|
||||||
map.addControl(control);
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
function parsePackets(packetLog) {
|
|
||||||
let parser = new APRSParser();
|
|
||||||
return packetLog.trim().split("\n")
|
|
||||||
// parse to Date and APRS packet
|
|
||||||
.map(line => {
|
|
||||||
let packet = parser.parse(line.slice(29));
|
|
||||||
packet.date = new Date(line.slice(0,18));
|
|
||||||
return packet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let packets = parsePackets(packetLog);
|
|
||||||
plotPaths(packets);
|
|
||||||
plotPacketPaths(packets);
|
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
const WebSocket = require("ws");
|
|
||||||
const net = require("net");
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const client = new net.Socket();
|
|
||||||
const wss = new WebSocket.Server({ host: "127.0.0.1", port: 4321 });
|
|
||||||
|
|
||||||
wss.broadcast = function(data) {
|
|
||||||
wss.clients.forEach(client => {
|
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
|
||||||
client.send(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
client.connect(14580, "rotate.aprs2.net", () =>
|
|
||||||
client.write("user KC1GDW pass -1 filter r/43.90/-72.15/75\r\n")
|
|
||||||
);
|
|
||||||
|
|
||||||
function datestamp(date) {
|
|
||||||
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.on("data", function(data) {
|
|
||||||
let str = data.toString("utf8").replace(/^\s+|\s+$/g, "");
|
|
||||||
console.log(str);
|
|
||||||
|
|
||||||
// strip whitespace, then handle multiple APRS packets per TCP packet
|
|
||||||
str.split("\r\n").forEach(packet => {
|
|
||||||
if (!packet.startsWith("#")) {
|
|
||||||
// ignore comments
|
|
||||||
let date = new Date();
|
|
||||||
// create log dir if it doesn't exist
|
|
||||||
if (!fs.existsSync("log")) fs.mkdirSync("log");
|
|
||||||
fs.appendFile(
|
|
||||||
`log/log${datestamp(date)}.json`,
|
|
||||||
JSON.stringify([date, packet]) + "\n",
|
|
||||||
err => {
|
|
||||||
if (err) throw err;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
wss.broadcast(JSON.stringify([date, packet]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wss.on("connection", ws => {
|
|
||||||
let date = new Date();
|
|
||||||
let filename = `log/log${datestamp(date)}.json`;
|
|
||||||
|
|
||||||
if (fs.existsSync(filename)) {
|
|
||||||
fs.readFileSync(filename)
|
|
||||||
.toString()
|
|
||||||
.split("\n")
|
|
||||||
.forEach(line => ws.send(line));
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,42 +1,38 @@
|
|||||||
timeoutLength: 600000 # 10 * 60 * 1000 = 10 minutes
|
|
||||||
lowVoltage: 11.9
|
|
||||||
|
|
||||||
TACTICAL_whitelist:
|
TACTICAL_whitelist:
|
||||||
- KC1GDW-7
|
- KC1GDW-7
|
||||||
- W1FN
|
- W1FN
|
||||||
|
|
||||||
|
default:
|
||||||
|
timeoutLength: 10 minutes
|
||||||
|
lowVoltage: 11.9
|
||||||
|
|
||||||
trackedStations:
|
trackedStations:
|
||||||
# Digis/iGates
|
# Digis/iGates
|
||||||
W1FN-1:
|
W1FN-1:
|
||||||
W1FN-3:
|
tactical: Moose Mt
|
||||||
|
timeoutLength: 25 minutes
|
||||||
W1FN-5:
|
W1FN-5:
|
||||||
W1FN-6:
|
tactical: Hanover
|
||||||
W1FN-7:
|
timeoutLength: 25 minutes
|
||||||
W1FN-8:
|
|
||||||
W1FN-9:
|
W1FN-9:
|
||||||
W1FN-10:
|
tactical: Bath
|
||||||
|
timeoutLength: 25 minutes
|
||||||
N1GMC-1:
|
N1GMC-1:
|
||||||
|
tactical: Dame Hill Rd, Orford
|
||||||
|
timeoutLength: 25 minutes
|
||||||
N1GMC-2:
|
N1GMC-2:
|
||||||
|
tactical: Sunday Mt, Orford
|
||||||
|
timeoutLength: 25 minutes
|
||||||
|
KB1FDA-1:
|
||||||
|
tactical: Corinth
|
||||||
|
timeoutLength: 25 minutes
|
||||||
|
|
||||||
# Vehicles
|
# Vehicles
|
||||||
W1HS-9: Rover SF-1
|
K1EHZ-10: Recovery
|
||||||
KC1LFU-5: Rover 1-2
|
NA1T-9: Ride Manager
|
||||||
KC1LFU-1: Rover 2-1
|
KC1LFU-1: Logistics Coord.
|
||||||
KC1LFU-2: Rover 2-3
|
KC1GDW-9: Bike Tech
|
||||||
N1EMF-7: Rover 3-4
|
WA1ZCN-11: Trouble
|
||||||
K1DSP-9: Rover 4-5
|
KC1LFU-2: Rover 1
|
||||||
AB1XQ-9: Rover 5-8
|
K1EHZ-4: Rover 2
|
||||||
N5IEP-1: Rover 5-6
|
W1TGA-9: Rover 3
|
||||||
KC1BOS-2: Rover 6-7
|
|
||||||
K1EHZ-4: Rover 7-8
|
|
||||||
KC1LFU-6: Rover 2-8
|
|
||||||
KC1LFU-7: Rover 8-2
|
|
||||||
KC1LFU-3: Rover FPL
|
|
||||||
WB1BRE-14: Metric Rover
|
|
||||||
KC1LFU-9: Recovery 1
|
|
||||||
W1LKS-9: Recovery 2
|
|
||||||
WB1BRE-15: Shuttle 1
|
|
||||||
KC1LFU-10: Supply 1
|
|
||||||
NA1T-9: Safety 1
|
|
||||||
AE1H-9: Trouble 1
|
|
||||||
AE1H-8: Trouble 2
|
|
||||||
|
18
vite.config.js
Normal file
18
vite.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import yaml from '@rollup/plugin-yaml';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
assetsInclude: ['**/*.gpx'],
|
||||||
|
plugins: [vue(), yaml()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
map: resolve(__dirname, 'map/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user