Update to Vue3
Also switch from parcel to vite, from vuelayers to vue3-openlayers, and update openlayers to v6
This commit is contained in:
parent
a7a5daeacf
commit
55bf9a43b3
24
package.json
24
package.json
@ -4,27 +4,23 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aprs-parser": "^1.0.4",
|
"aprs-parser": "github:ad1217/npm-aprs-parser#no-dynamic-require",
|
||||||
"distinct-colors": "^1.0.4",
|
"distinct-colors": "^1.0.4",
|
||||||
"ol": "^5.3.3",
|
"ol": "^6.13.0",
|
||||||
"vue": "^2.6.11",
|
"vue": "^3.2.31",
|
||||||
"vue-hot-reload-api": "^2.3.4",
|
"vue3-openlayers": "^0.1.63",
|
||||||
"vuelayers": "^0.11.23",
|
|
||||||
"ws": "^5.2.2"
|
"ws": "^5.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/component-compiler-utils": "^3.1.2",
|
"@modyfi/vite-plugin-yaml": "^1.0.1",
|
||||||
"parcel": "^1.12.4",
|
"@rollup/plugin-yaml": "^3.1.0",
|
||||||
"vue-template-compiler": "^2.6.11"
|
"@vitejs/plugin-vue": "^2.2.4",
|
||||||
|
"vite": "^2.8.6"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js",
|
|
||||||
"serve": "node src/server.js",
|
"serve": "node src/server.js",
|
||||||
"prestart": "npm run monkeyPatch",
|
"start": "vite",
|
||||||
"start": "parcel src/index.html",
|
"build": "vite build"
|
||||||
"prebuild": "npm run monkeyPatch",
|
|
||||||
"build": "parcel build --public-url ./ src/index.html"
|
|
||||||
},
|
},
|
||||||
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
|
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
6322
pnpm-lock.yaml
generated
6322
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
487
src/Map.vue
487
src/Map.vue
@ -1,283 +1,254 @@
|
|||||||
<template>
|
<template>
|
||||||
<vl-map data-projection="EPSG:4326">
|
<ol-map
|
||||||
<vl-view :zoom="10" :center="[-72.15, 43.9]">
|
:loadTilesWhileAnimating="true"
|
||||||
<vl-layer-tile>
|
:loadTilesWhileInteracting="true"
|
||||||
<vl-source-osm> </vl-source-osm>
|
class="map"
|
||||||
</vl-layer-tile>
|
>
|
||||||
<vl-layer-group>
|
<ol-view :zoom="10" :center="[-72.15, 43.9]" projection="EPSG:4326" />
|
||||||
<vl-layer-vector v-for="(gpxURL, name) in routes" :key="name">
|
|
||||||
<vl-source-vector :url="gpxURL" :format-factory="gpxFormatFactory">
|
|
||||||
</vl-source-vector>
|
|
||||||
<vl-style-box>
|
|
||||||
<vl-style-stroke color="hsl(200, 90%, 30%)" :width="5">
|
|
||||||
</vl-style-stroke>
|
|
||||||
</vl-style-box>
|
|
||||||
</vl-layer-vector>
|
|
||||||
</vl-layer-group>
|
|
||||||
|
|
||||||
<!-- Station Paths -->
|
<ol-tile-layer>
|
||||||
<vl-layer-group>
|
<ol-source-osm />
|
||||||
<vl-layer-group
|
</ol-tile-layer>
|
||||||
v-for="(packets, callsign, idx) in stationPaths"
|
|
||||||
:key="callsign"
|
|
||||||
>
|
|
||||||
<!--Paths -->
|
|
||||||
<vl-layer-vector render-mode="image">
|
|
||||||
<vl-source-vector>
|
|
||||||
<vl-feature>
|
|
||||||
<vl-geom-line-string
|
|
||||||
:coordinates="packetsToStationPathPoints(packets)"
|
|
||||||
>
|
|
||||||
</vl-geom-line-string>
|
|
||||||
</vl-feature>
|
|
||||||
</vl-source-vector>
|
|
||||||
<vl-style-box>
|
|
||||||
<vl-style-stroke :color="stationColors[idx].hex()" :width="2">
|
|
||||||
</vl-style-stroke>
|
|
||||||
</vl-style-box>
|
|
||||||
</vl-layer-vector>
|
|
||||||
|
|
||||||
<!-- Points -->
|
<ol-vector-layer v-for="gpxURL in routes" :key="gpxURL">
|
||||||
<vl-layer-vector render-mode="image">
|
<ol-source-vector :url="gpxURL" :format="new GPX()"> </ol-source-vector>
|
||||||
<vl-source-vector>
|
<ol-style>
|
||||||
<vl-feature>
|
<ol-style-stroke color="hsl(200, 90%, 30%)" :width="5">
|
||||||
<vl-geom-multi-point
|
</ol-style-stroke>
|
||||||
:coordinates="packetsToStationPathPoints(packets)"
|
</ol-style>
|
||||||
>
|
</ol-vector-layer>
|
||||||
</vl-geom-multi-point>
|
|
||||||
</vl-feature>
|
|
||||||
</vl-source-vector>
|
|
||||||
<vl-style-box>
|
|
||||||
<vl-style-circle :radius="3">
|
|
||||||
<vl-style-fill :color="stationColors[idx].hex()">
|
|
||||||
</vl-style-fill>
|
|
||||||
</vl-style-circle>
|
|
||||||
</vl-style-box>
|
|
||||||
</vl-layer-vector>
|
|
||||||
</vl-layer-group>
|
|
||||||
</vl-layer-group>
|
|
||||||
|
|
||||||
<!-- Digipeater locations -->
|
<!-- Station Paths -->
|
||||||
<vl-layer-vector>
|
<div>
|
||||||
<vl-source-vector>
|
<div v-for="(packets, callsign, idx) in stationPaths" :key="callsign">
|
||||||
<vl-feature v-for="(position, callsign) in digiPos" :key="callsign">
|
<!--Paths -->
|
||||||
<vl-geom-point :coordinates="position"> </vl-geom-point>
|
<ol-vector-layer render-mode="image">
|
||||||
<vl-style-box>
|
<ol-source-vector>
|
||||||
<vl-style-circle>
|
<ol-feature>
|
||||||
<vl-style-fill :color="digiColors[callsign].hex()">
|
<ol-geom-line-string
|
||||||
</vl-style-fill
|
:coordinates="packetsToStationPathPoints(packets)"
|
||||||
></vl-style-circle>
|
>
|
||||||
<vl-style-text :text="callsign" :offsetY="12"> </vl-style-text>
|
</ol-geom-line-string>
|
||||||
</vl-style-box>
|
</ol-feature>
|
||||||
</vl-feature>
|
</ol-source-vector>
|
||||||
</vl-source-vector>
|
<ol-style>
|
||||||
</vl-layer-vector>
|
<ol-style-stroke :color="stationColors[idx].hex()" :width="2">
|
||||||
|
</ol-style-stroke>
|
||||||
|
</ol-style>
|
||||||
|
</ol-vector-layer>
|
||||||
|
|
||||||
<!-- Packet Paths -->
|
<!-- Points -->
|
||||||
<vl-layer-vector render-mode="image">
|
<ol-vector-layer render-mode="image">
|
||||||
<vl-source-vector :features="packetPathsGeoJSON"> </vl-source-vector>
|
<ol-source-vector>
|
||||||
<vl-style-func :factory="() => packetPathStyleFunc"> </vl-style-func>
|
<ol-feature>
|
||||||
</vl-layer-vector>
|
<ol-geom-multi-point
|
||||||
</vl-view>
|
:coordinates="packetsToStationPathPoints(packets)"
|
||||||
</vl-map>
|
>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Digipeater locations -->
|
||||||
|
<ol-vector-layer>
|
||||||
|
<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>
|
||||||
|
<ol-source-vector :features="packetPaths"> </ol-source-vector>
|
||||||
|
<!-- TODO: fix style -->
|
||||||
|
<!-- <ol-style :overrideStyleFunction="packetPathStyleFunc"> </ol-style> -->
|
||||||
|
</ol-vector-layer>
|
||||||
|
</ol-map>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import Vue from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { APRSParser } from 'aprs-parser';
|
import APRSParser from 'aprs-parser/lib/APRSParser';
|
||||||
import distinctColors from 'distinct-colors';
|
import distinctColors from 'distinct-colors';
|
||||||
|
|
||||||
import VueLayers from 'vuelayers';
|
|
||||||
import { createStyle, createLineGeom } from 'vuelayers/lib/ol-ext';
|
|
||||||
import { Control } from 'ol/control';
|
|
||||||
import { GPX } from 'ol/format';
|
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 'vuelayers/lib/style.css';
|
import packetLog from '/../IS_packets.txt?raw';
|
||||||
|
const routes = Object.values(import.meta.globEager('./gpx/*.gpx')).map(
|
||||||
|
(gpx) => gpx.default
|
||||||
|
);
|
||||||
|
|
||||||
Vue.use(VueLayers);
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
import route_data from 'gpx/*.gpx';
|
function packetsToStationPathPoints(packets) {
|
||||||
import { readFileSync } from 'fs';
|
return packets.map((packet) => [packet.data.longitude, packet.data.latitude]);
|
||||||
const packetLog = readFileSync(__dirname + '/../IS_packets.txt', 'utf-8');
|
|
||||||
|
|
||||||
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;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
function pathToString(path) {
|
||||||
data() {
|
return path
|
||||||
return {
|
.filter(
|
||||||
packets: parsePackets(packetLog),
|
(station) => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/)
|
||||||
routes: route_data,
|
)
|
||||||
};
|
.map((station) => station.toString().trim().replace(/\*$/, ''));
|
||||||
},
|
}
|
||||||
|
|
||||||
methods: {
|
function groupByCall(acc, packet) {
|
||||||
gpxFormatFactory(options) {
|
let callsign = packet.from.toString().trim();
|
||||||
return new GPX(options);
|
if (!(callsign in acc)) acc[callsign] = [];
|
||||||
},
|
acc[callsign].push(packet);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
packetsToStationPathPoints(packets) {
|
function colorForDigi(digi) {
|
||||||
return packets.map((packet) => [
|
if (digi in digiColors.value) {
|
||||||
packet.data.longitude,
|
return digiColors.value[digi].hex();
|
||||||
packet.data.latitude,
|
} else {
|
||||||
]);
|
return '#000000';
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pathToString(path) {
|
function packetPathStyleFunc(feature, resolution) {
|
||||||
return path
|
let paths = feature.getProperties().properties.paths.slice(0);
|
||||||
.filter(
|
let styles = [];
|
||||||
(station) => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/)
|
|
||||||
)
|
|
||||||
.map((station) => station.toString().trim().replace(/\*$/, ''));
|
|
||||||
},
|
|
||||||
|
|
||||||
groupByCall(acc, packet) {
|
feature
|
||||||
let callsign = packet.from.toString().trim();
|
.getGeometry()
|
||||||
if (!(callsign in acc)) acc[callsign] = [];
|
.getLineStrings()
|
||||||
acc[callsign].push(packet);
|
.forEach((ls) => {
|
||||||
return acc;
|
let path = paths.shift().slice(0);
|
||||||
},
|
ls.forEachSegment((start, end) => {
|
||||||
|
let color = colorForDigi(path.shift());
|
||||||
|
|
||||||
colorForDigi(digi) {
|
styles.push(
|
||||||
if (digi in this.digiColors) {
|
new Style({
|
||||||
return this.digiColors[digi].hex();
|
geometry: new LineString([start, end]),
|
||||||
} else {
|
stroke: new Stroke({ color: color, width: 2 }),
|
||||||
return '#000000';
|
})
|
||||||
}
|
);
|
||||||
},
|
|
||||||
|
|
||||||
packetPathStyleFunc(feature, resolution) {
|
|
||||||
let paths = feature.getProperties().paths.slice(0);
|
|
||||||
let styles = [];
|
|
||||||
|
|
||||||
feature
|
|
||||||
.getGeometry()
|
|
||||||
.getLineStrings()
|
|
||||||
.forEach((ls) => {
|
|
||||||
let path = paths.shift().slice(0);
|
|
||||||
ls.forEachSegment((start, end) => {
|
|
||||||
let color = this.colorForDigi(path.shift());
|
|
||||||
|
|
||||||
styles.push(
|
|
||||||
createStyle({
|
|
||||||
geom: createLineGeom([start, end]),
|
|
||||||
strokeColor: color,
|
|
||||||
strokeWidth: 2,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return styles;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
positionalPackets() {
|
|
||||||
return (
|
|
||||||
this.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)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
stationPaths() {
|
|
||||||
// group by callsign
|
|
||||||
return this.positionalPackets.reduce(this.groupByCall, {});
|
|
||||||
},
|
|
||||||
|
|
||||||
digis() {
|
|
||||||
let digiCalls = new Set(
|
|
||||||
this.packets
|
|
||||||
.map((packet) => this.pathToString(packet.via))
|
|
||||||
.reduce((acc, stations) => acc.concat(stations))
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
this.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(this.groupByCall, {})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
digiPos() {
|
|
||||||
return Object.entries(this.digis).reduce((acc, [digi, packets]) => {
|
|
||||||
let lastPacket = packets[packets.length - 1];
|
|
||||||
acc[digi] = [lastPacket.data.longitude, lastPacket.data.latitude];
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
|
|
||||||
packetPathsGeoJSON() {
|
|
||||||
let digiPos = { ...this.digiPos }; // localize for performance
|
|
||||||
return Object.entries(this.stationPaths).map(([station, packets]) => {
|
|
||||||
let lines = packets.map((packet) => {
|
|
||||||
let path = this.pathToString(packet.via);
|
|
||||||
return {
|
|
||||||
// first point in path is originating station
|
|
||||||
coords: [
|
|
||||||
[packet.data.longitude, packet.data.latitude],
|
|
||||||
...path.map((hop) => digiPos[hop] || [0, 0]),
|
|
||||||
],
|
|
||||||
path: path,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'Feature',
|
|
||||||
id: station,
|
|
||||||
geometry: {
|
|
||||||
type: 'MultiLineString',
|
|
||||||
coordinates: lines.map((p) => p.coords),
|
|
||||||
},
|
|
||||||
properties: { paths: lines.map((p) => p.path) },
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
|
|
||||||
stationColors() {
|
console.log(styles);
|
||||||
return distinctColors({
|
|
||||||
count: Object.keys(this.stationPaths).length,
|
|
||||||
lightMin: 20,
|
|
||||||
lightMax: 80,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
digiColors() {
|
return styles;
|
||||||
let colors = distinctColors({
|
}
|
||||||
count: Object.keys(this.digis).length,
|
|
||||||
lightMin: 20,
|
const positionalPackets = computed(() => {
|
||||||
lightMax: 80,
|
return (
|
||||||
});
|
packets
|
||||||
return Object.keys(this.digis).reduce((acc, callsign, index) => {
|
.filter(
|
||||||
acc[callsign] = colors[index];
|
(packet) =>
|
||||||
return acc;
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -288,8 +259,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map {
|
.map {
|
||||||
height: 100%;
|
width: 100vw;
|
||||||
width: 100%;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-control.layer-toggles {
|
.ol-control.layer-toggles {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<tr :class="{ timedOut, lowVoltage, neverHeard: !status.lastHeard }">
|
<tr :class="{ timedOut, lowVoltage, 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,125 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
import config from './status_config.yaml';
|
import config from './status_config.yaml';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'StationRow',
|
callsign: String,
|
||||||
props: { callsign: String, tactical: String, messages: Array, now: Date },
|
tactical: String,
|
||||||
|
messages: Array,
|
||||||
|
now: Date,
|
||||||
|
});
|
||||||
|
|
||||||
data() {
|
const stationStatus = ref({
|
||||||
return {
|
lastHeard: null,
|
||||||
status: {
|
delta: null,
|
||||||
lastHeard: null,
|
lastVoltage: null,
|
||||||
delta: null,
|
lastTemperature: null,
|
||||||
lastVoltage: null,
|
});
|
||||||
lastTemperature: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
function notify(title, body) {
|
||||||
notify(title, body) {
|
return new Notification(title, { body: body, requireInteraction: true });
|
||||||
return new Notification(title, { body: body, requireInteraction: true });
|
}
|
||||||
},
|
|
||||||
|
|
||||||
formatTime(time, isDuration = false) {
|
function formatTime(time, isDuration = false) {
|
||||||
return new Date(time).toLocaleTimeString(
|
return new Date(time).toLocaleTimeString(
|
||||||
'en-GB',
|
'en-GB',
|
||||||
isDuration ? { timeZone: 'UTC' } : {}
|
isDuration ? { timeZone: 'UTC' } : {}
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
prettyDuration(duration) {
|
function prettyDuration(duration) {
|
||||||
let date = new Date(duration);
|
let date = new Date(duration);
|
||||||
return [
|
return [
|
||||||
...Object.entries({
|
...Object.entries({
|
||||||
hours: date.getUTCHours(),
|
hours: date.getUTCHours(),
|
||||||
minutes: date.getUTCMinutes(),
|
minutes: date.getUTCMinutes(),
|
||||||
seconds: date.getUTCSeconds(),
|
seconds: date.getUTCSeconds(),
|
||||||
milliseconds: date.getUTCMilliseconds(),
|
milliseconds: date.getUTCMilliseconds(),
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
.filter(([suffix, num]) => num > 0)
|
.filter(([suffix, num]) => num > 0)
|
||||||
.map(([suffix, num]) => num + ' ' + suffix)
|
.map(([suffix, num]) => num + ' ' + suffix)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
},
|
}
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
const tacticalAndOrCall = computed(() => {
|
||||||
messages() {
|
return props.tactical
|
||||||
Object.assign(
|
? `${props.tactical} [${props.callsign}]`
|
||||||
this.status,
|
: props.callsign;
|
||||||
this.messages.reduce((acc, message, idx, arr) => {
|
});
|
||||||
acc.lastHeard = message.date.getTime();
|
|
||||||
if (idx === 0) {
|
const timedOut = computed(() => {
|
||||||
acc.avgDelta = 0;
|
if (!stationStatus.value.lastHeard) {
|
||||||
} else {
|
return false;
|
||||||
let delta = message.date.getTime() - arr[idx - 1].date.getTime();
|
}
|
||||||
acc.avgDelta = (acc.avgDelta * (idx - 1) + delta) / idx;
|
|
||||||
|
let nowDelta = new Date(props.now.value - stationStatus.value.lastHeard);
|
||||||
|
return nowDelta.getTime() > config.timeoutLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowVoltage = computed(() => {
|
||||||
|
return (
|
||||||
|
stationStatus.value.lastVoltage &&
|
||||||
|
stationStatus.value.lastVoltage < config.lowVoltage
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.messages,
|
||||||
|
() => {
|
||||||
|
Object.assign(
|
||||||
|
stationStatus.value,
|
||||||
|
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];
|
||||||
}
|
}
|
||||||
if ('data' in message) {
|
acc.lastMicE = message.data.mice || acc.lastMicE;
|
||||||
if ('analog' in message.data) {
|
acc.lastComment = message.data.comment || acc.lastComment;
|
||||||
acc.lastVoltage = message.data.analog[0] / 10;
|
}
|
||||||
acc.lastTemperature = message.data.analog[1];
|
return acc;
|
||||||
}
|
}, {})
|
||||||
acc.lastMicE = message.data.mice || acc.lastMicE;
|
);
|
||||||
acc.lastComment = message.data.comment || acc.lastComment;
|
}
|
||||||
}
|
);
|
||||||
return acc;
|
|
||||||
}, {})
|
watch(
|
||||||
|
() => props.lowVoltage,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
notify(
|
||||||
|
`${tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
|
||||||
|
`Voltage: ${stationStatus.value.lastVoltage}`
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
lowVoltage(newVal) {
|
watch(timedOut, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.notify(
|
notify(
|
||||||
`${this.tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
|
`${tacticalAndOrCall.value} has not been heard for over ${prettyDuration(
|
||||||
`Voltage: ${this.status.lastVoltage}`
|
config.timeoutLength
|
||||||
);
|
)}!`,
|
||||||
}
|
`Last Heard: ${formatTime(
|
||||||
},
|
stationStatus.value.lastHeard
|
||||||
|
)} (${prettyDuration(
|
||||||
timedOut(newVal) {
|
props.now.value - stationStatus.value.lastHeard
|
||||||
if (newVal) {
|
)} ago!)`
|
||||||
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
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -25,91 +25,84 @@
|
|||||||
</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';
|
||||||
|
|
||||||
export default {
|
const parser = new APRSParser();
|
||||||
name: 'StationStatus',
|
let aprsStream = null;
|
||||||
components: { StationRow },
|
const messages = ref([]);
|
||||||
data() {
|
const messagesFromStation = ref({});
|
||||||
return {
|
const now = ref(new Date());
|
||||||
aprsStream: null,
|
const trackedStations = ref(config.trackedStations);
|
||||||
parser: new aprs.APRSParser(),
|
|
||||||
messages: [],
|
|
||||||
messagesFromStation: {},
|
|
||||||
now: new Date(),
|
|
||||||
trackedStations: config.trackedStations,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
onMounted(() => {
|
||||||
// request notification permissions
|
// request notification permissions
|
||||||
if (Notification.permission !== 'granted') {
|
if (Notification.permission !== 'granted') {
|
||||||
Notification.requestPermission((permission) => {
|
Notification.requestPermission((permission) => {
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
new Notification('Test notification', { body: 'whatever' });
|
new Notification('Test notification', { body: 'whatever' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to websocket aprs stream
|
||||||
|
connectToStream();
|
||||||
|
|
||||||
|
// update shared current time every second
|
||||||
|
window.setInterval(() => (now.value = new Date()), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function connectToStream() {
|
||||||
|
aprsStream = new WebSocket('ws://localhost:4321');
|
||||||
|
aprsStream.onclose = () => {
|
||||||
|
// Try to reconnect every 5 seconds
|
||||||
|
let interval = window.setTimeout(() => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
connectToStream();
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
aprsStream.onmessage = (event) => {
|
||||||
|
if (event.data !== '') {
|
||||||
|
handleMessage(JSON.parse(event.data));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to websocket aprs stream
|
function handleMessage(packet) {
|
||||||
this.connectToStream();
|
let message = parser.parse(packet[1]);
|
||||||
|
message.date = new Date(packet[0]);
|
||||||
|
|
||||||
// update shared current time every second
|
console.info(message);
|
||||||
window.setInterval(() => (this.now = new Date()), 1000);
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
methods: {
|
// message to TACTICAL setting a tactical nickname from an
|
||||||
connectToStream() {
|
// authorized call, so add/update it in trackedStations
|
||||||
this.aprsStream = new WebSocket('ws://localhost:4321');
|
if (
|
||||||
this.aprsStream.onclose = () => {
|
message.data &&
|
||||||
// Try to reconnect every 5 seconds
|
message.data.addressee &&
|
||||||
let interval = window.setTimeout(() => {
|
message.data.addressee.call === 'TACTICAL' &&
|
||||||
window.clearInterval(interval);
|
config.TACTICAL_whitelist.includes(message.from.toString())
|
||||||
this.connectToStream();
|
) {
|
||||||
}, 5000);
|
message.data.text.split(';').map((tac_assoc) => {
|
||||||
};
|
let [call, tac] = tac_assoc.split('=', 2);
|
||||||
this.aprsStream.onmessage = (event) =>
|
if (tac) {
|
||||||
this.handleMessage(JSON.parse(event.data));
|
trackedStations.value[call] = tac;
|
||||||
},
|
|
||||||
|
|
||||||
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]);
|
delete trackedStations.value[call];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 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) {
|
|
||||||
this.trackedStations[call] = tac;
|
|
||||||
} else {
|
|
||||||
delete this.trackedStations[call];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script src="./index.js"></script>
|
|
||||||
</body>
|
|
17
src/index.js
17
src/index.js
@ -1,7 +1,12 @@
|
|||||||
import Vue from 'vue';
|
import * as Vue from 'vue';
|
||||||
import App from './StatusScreen.vue';
|
|
||||||
|
|
||||||
new Vue({
|
// import OpenLayersMap from 'vue3-openlayers';
|
||||||
el: '#app',
|
// import 'vue3-openlayers/dist/vue3-openlayers.css';
|
||||||
render: (h) => h(App),
|
|
||||||
});
|
import StatusScreen from './StatusScreen.vue';
|
||||||
|
// import Map from './Map.vue';
|
||||||
|
|
||||||
|
const app = Vue.createApp(StatusScreen);
|
||||||
|
// app.use(OpenLayersMap);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
@ -27,8 +27,8 @@ client.on('data', function (data) {
|
|||||||
|
|
||||||
// strip whitespace, then handle multiple APRS packets per TCP packet
|
// strip whitespace, then handle multiple APRS packets per TCP packet
|
||||||
str.split('\r\n').forEach((packet) => {
|
str.split('\r\n').forEach((packet) => {
|
||||||
if (!packet.startsWith('#')) {
|
// ignore comments and empty lines
|
||||||
// ignore comments
|
if (!packet.startsWith('#') || packet === '') {
|
||||||
let date = new Date();
|
let date = new Date();
|
||||||
// create log dir if it doesn't exist
|
// create log dir if it doesn't exist
|
||||||
if (!fs.existsSync('log')) fs.mkdirSync('log');
|
if (!fs.existsSync('log')) fs.mkdirSync('log');
|
||||||
@ -52,6 +52,7 @@ wss.on('connection', (ws) => {
|
|||||||
fs.readFileSync(filename)
|
fs.readFileSync(filename)
|
||||||
.toString()
|
.toString()
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
.filter((line) => line !== '')
|
||||||
.forEach((line) => ws.send(line));
|
.forEach((line) => ws.send(line));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
9
vite.config.js
Normal file
9
vite.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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()],
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user