Update to Vue3

Also switch from parcel to vite, from vuelayers to vue3-openlayers,
and update openlayers to v6
This commit is contained in:
Adam Goldsmith 2022-03-04 13:17:53 -05:00
parent a7a5daeacf
commit 55bf9a43b3
9 changed files with 1567 additions and 5681 deletions

View File

@ -4,27 +4,23 @@
"description": "",
"private": true,
"dependencies": {
"aprs-parser": "^1.0.4",
"aprs-parser": "github:ad1217/npm-aprs-parser#no-dynamic-require",
"distinct-colors": "^1.0.4",
"ol": "^5.3.3",
"vue": "^2.6.11",
"vue-hot-reload-api": "^2.3.4",
"vuelayers": "^0.11.23",
"ol": "^6.13.0",
"vue": "^3.2.31",
"vue3-openlayers": "^0.1.63",
"ws": "^5.2.2"
},
"devDependencies": {
"@vue/component-compiler-utils": "^3.1.2",
"parcel": "^1.12.4",
"vue-template-compiler": "^2.6.11"
"@modyfi/vite-plugin-yaml": "^1.0.1",
"@rollup/plugin-yaml": "^3.1.0",
"@vitejs/plugin-vue": "^2.2.4",
"vite": "^2.8.6"
},
"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",
"prestart": "npm run monkeyPatch",
"start": "parcel src/index.html",
"prebuild": "npm run monkeyPatch",
"build": "parcel build --public-url ./ src/index.html"
"start": "vite",
"build": "vite build"
},
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
"license": "ISC"

6314
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,110 +1,106 @@
<template>
<vl-map data-projection="EPSG:4326">
<vl-view :zoom="10" :center="[-72.15, 43.9]">
<vl-layer-tile>
<vl-source-osm> </vl-source-osm>
</vl-layer-tile>
<vl-layer-group>
<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>
<ol-map
:loadTilesWhileAnimating="true"
:loadTilesWhileInteracting="true"
class="map"
>
<ol-view :zoom="10" :center="[-72.15, 43.9]" projection="EPSG:4326" />
<ol-tile-layer>
<ol-source-osm />
</ol-tile-layer>
<ol-vector-layer v-for="gpxURL in routes" :key="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>
<!-- Station Paths -->
<vl-layer-group>
<vl-layer-group
v-for="(packets, callsign, idx) in stationPaths"
:key="callsign"
>
<div>
<div 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
<ol-vector-layer render-mode="image">
<ol-source-vector>
<ol-feature>
<ol-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>
</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>
<!-- Points -->
<vl-layer-vector render-mode="image">
<vl-source-vector>
<vl-feature>
<vl-geom-multi-point
<ol-vector-layer render-mode="image">
<ol-source-vector>
<ol-feature>
<ol-geom-multi-point
:coordinates="packetsToStationPathPoints(packets)"
>
</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>
</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 -->
<vl-layer-vector>
<vl-source-vector>
<vl-feature v-for="(position, callsign) in digiPos" :key="callsign">
<vl-geom-point :coordinates="position"> </vl-geom-point>
<vl-style-box>
<vl-style-circle>
<vl-style-fill :color="digiColors[callsign].hex()">
</vl-style-fill
></vl-style-circle>
<vl-style-text :text="callsign" :offsetY="12"> </vl-style-text>
</vl-style-box>
</vl-feature>
</vl-source-vector>
</vl-layer-vector>
<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 -->
<vl-layer-vector render-mode="image">
<vl-source-vector :features="packetPathsGeoJSON"> </vl-source-vector>
<vl-style-func :factory="() => packetPathStyleFunc"> </vl-style-func>
</vl-layer-vector>
</vl-view>
</vl-map>
<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>
<script>
import Vue from 'vue';
<script setup>
import { computed, ref } from 'vue';
import { APRSParser } from 'aprs-parser';
import APRSParser from 'aprs-parser/lib/APRSParser';
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 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);
import route_data from 'gpx/*.gpx';
import { readFileSync } from 'fs';
const packetLog = readFileSync(__dirname + '/../IS_packets.txt', 'utf-8');
function parsePackets(packetLog) {
let parser = new APRSParser();
return (
packetLog
const parser = new APRSParser();
const packets = packetLog
.trim()
.split('\n')
// parse to Date and APRS packet
@ -112,55 +108,37 @@ function parsePackets(packetLog) {
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]);
}
export default {
data() {
return {
packets: parsePackets(packetLog),
routes: route_data,
};
},
methods: {
gpxFormatFactory(options) {
return new GPX(options);
},
packetsToStationPathPoints(packets) {
return packets.map((packet) => [
packet.data.longitude,
packet.data.latitude,
]);
},
pathToString(path) {
function pathToString(path) {
return path
.filter(
(station) => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/)
)
.map((station) => station.toString().trim().replace(/\*$/, ''));
},
}
groupByCall(acc, packet) {
function groupByCall(acc, packet) {
let callsign = packet.from.toString().trim();
if (!(callsign in acc)) acc[callsign] = [];
acc[callsign].push(packet);
return acc;
},
}
colorForDigi(digi) {
if (digi in this.digiColors) {
return this.digiColors[digi].hex();
function colorForDigi(digi) {
if (digi in digiColors.value) {
return digiColors.value[digi].hex();
} else {
return '#000000';
}
},
}
packetPathStyleFunc(feature, resolution) {
let paths = feature.getProperties().paths.slice(0);
function packetPathStyleFunc(feature, resolution) {
let paths = feature.getProperties().properties.paths.slice(0);
let styles = [];
feature
@ -169,26 +147,25 @@ export default {
.forEach((ls) => {
let path = paths.shift().slice(0);
ls.forEachSegment((start, end) => {
let color = this.colorForDigi(path.shift());
let color = colorForDigi(path.shift());
styles.push(
createStyle({
geom: createLineGeom([start, end]),
strokeColor: color,
strokeWidth: 2,
new Style({
geometry: new LineString([start, end]),
stroke: new Stroke({ color: color, width: 2 }),
})
);
});
});
return styles;
},
},
console.log(styles);
computed: {
positionalPackets() {
return styles;
}
const positionalPackets = computed(() => {
return (
this.packets
packets
.filter(
(packet) =>
packet.date > new Date('2018-07-13') &&
@ -197,87 +174,81 @@ export default {
// filter to just positional data
.filter((packet) => 'data' in packet && 'latitude' in packet.data)
);
},
});
stationPaths() {
const stationPaths = computed(() => {
// group by callsign
return this.positionalPackets.reduce(this.groupByCall, {});
},
return positionalPackets.value.reduce(groupByCall, {});
});
digis() {
const digis = computed(() => {
let digiCalls = new Set(
this.packets
.map((packet) => this.pathToString(packet.via))
packets
.map((packet) => pathToString(packet.via))
.reduce((acc, stations) => acc.concat(stations))
);
return (
this.packets
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, {})
.reduce(groupByCall, {})
);
},
});
digiPos() {
return Object.entries(this.digis).reduce((acc, [digi, packets]) => {
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;
}, {});
},
});
packetPathsGeoJSON() {
let digiPos = { ...this.digiPos }; // localize for performance
return Object.entries(this.stationPaths).map(([station, packets]) => {
const packetPaths = computed(() => {
let digipeaterPostitions = digiPos.value;
return Object.entries(stationPaths.value).map(([station, packets]) => {
let lines = packets.map((packet) => {
let path = this.pathToString(packet.via);
let path = 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.map((hop) => digipeaterPostitions[hop] || [0, 0]),
],
path: path,
};
});
return {
type: 'Feature',
return new Feature({
id: station,
geometry: {
type: 'MultiLineString',
coordinates: lines.map((p) => p.coords),
},
geometry: new MultiLineString(lines.map((p) => p.coords)),
properties: { paths: lines.map((p) => p.path) },
};
});
},
});
});
stationColors() {
const stationColors = computed(() => {
return distinctColors({
count: Object.keys(this.stationPaths).length,
count: Object.keys(stationPaths.value).length,
lightMin: 20,
lightMax: 80,
});
},
});
digiColors() {
const digiColors = computed(() => {
let colors = distinctColors({
count: Object.keys(this.digis).length,
count: Object.keys(digis.value).length,
lightMin: 20,
lightMax: 80,
});
return Object.keys(this.digis).reduce((acc, callsign, index) => {
return Object.keys(digis.value).reduce((acc, callsign, index) => {
acc[callsign] = colors[index];
return acc;
}, {});
},
},
};
});
</script>
<style>
@ -288,8 +259,8 @@ body {
}
.map {
height: 100%;
width: 100%;
width: 100vw;
height: 100vh;
}
.ol-control.layer-toggles {

View File

@ -1,14 +1,14 @@
<template>
<tr :class="{ timedOut, lowVoltage, neverHeard: !status.lastHeard }">
<tr :class="{ timedOut, lowVoltage, neverHeard: !stationStatus.lastHeard }">
<td :title="callsign">{{ tacticalAndOrCall }}</td>
<template v-if="status.lastHeard">
<td>{{ formatTime(status.lastHeard) }}</td>
<td>{{ formatTime(now - status.lastHeard, true) }}</td>
<td>{{ formatTime(Math.round(status.avgDelta), true) }}</td>
<td>{{ status.lastMicE }}</td>
<td>{{ status.lastVoltage || '' }}</td>
<td>{{ status.lastTemperature || '' }}</td>
<td>{{ status.lastComment }}</td>
<template v-if="stationStatus.lastHeard">
<td>{{ formatTime(stationStatus.lastHeard) }}</td>
<td>{{ formatTime(now - stationStatus.lastHeard, true) }}</td>
<td>{{ formatTime(Math.round(stationStatus.avgDelta), true) }}</td>
<td>{{ stationStatus.lastMicE }}</td>
<td>{{ stationStatus.lastVoltage || '' }}</td>
<td>{{ stationStatus.lastTemperature || '' }}</td>
<td>{{ stationStatus.lastComment }}</td>
</template>
<template v-else>
<td>Never Heard</td>
@ -22,37 +22,36 @@
</tr>
</template>
<script>
<script setup>
import { ref, computed, watch } from 'vue';
import config from './status_config.yaml';
export default {
name: 'StationRow',
props: { callsign: String, tactical: String, messages: Array, now: Date },
const props = defineProps({
callsign: String,
tactical: String,
messages: Array,
now: Date,
});
data() {
return {
status: {
const stationStatus = ref({
lastHeard: null,
delta: null,
lastVoltage: null,
lastTemperature: null,
},
};
},
});
methods: {
notify(title, body) {
function notify(title, body) {
return new Notification(title, { body: body, requireInteraction: true });
},
}
formatTime(time, isDuration = false) {
function formatTime(time, isDuration = false) {
return new Date(time).toLocaleTimeString(
'en-GB',
isDuration ? { timeZone: 'UTC' } : {}
);
},
}
prettyDuration(duration) {
function prettyDuration(duration) {
let date = new Date(duration);
return [
...Object.entries({
@ -65,14 +64,36 @@ export default {
.filter(([suffix, num]) => num > 0)
.map(([suffix, num]) => num + ' ' + suffix)
.join(' ');
},
},
}
watch: {
messages() {
const tacticalAndOrCall = computed(() => {
return props.tactical
? `${props.tactical} [${props.callsign}]`
: props.callsign;
});
const timedOut = computed(() => {
if (!stationStatus.value.lastHeard) {
return false;
}
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(
this.status,
this.messages.reduce((acc, message, idx, arr) => {
stationStatus.value,
props.messages.reduce((acc, message, idx, arr) => {
acc.lastHeard = message.date.getTime();
if (idx === 0) {
acc.avgDelta = 0;
@ -91,56 +112,35 @@ export default {
return acc;
}, {})
);
},
}
);
lowVoltage(newVal) {
watch(
() => props.lowVoltage,
(newVal) => {
if (newVal) {
this.notify(
`${this.tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
`Voltage: ${this.status.lastVoltage}`
notify(
`${tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
`Voltage: ${stationStatus.value.lastVoltage}`
);
}
},
}
);
timedOut(newVal) {
watch(timedOut, (newVal) => {
if (newVal) {
this.notify(
`${
this.tacticalAndOrCall
} has not been heard for over ${this.prettyDuration(
notify(
`${tacticalAndOrCall.value} has not been heard for over ${prettyDuration(
config.timeoutLength
)}!`,
`Last Heard: ${this.formatTime(
this.status.lastHeard
)} (${this.prettyDuration(this.now - this.status.lastHeard)} ago!)`
`Last Heard: ${formatTime(
stationStatus.value.lastHeard
)} (${prettyDuration(
props.now.value - stationStatus.value.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>
<style>

View File

@ -25,28 +25,21 @@
</div>
</template>
<script>
import aprs from 'aprs-parser';
<script setup>
import { ref, onMounted } from 'vue';
import APRSParser from 'aprs-parser/lib/APRSParser';
import StationRow from './StationRow.vue';
import config from './status_config.yaml';
export default {
name: 'StationStatus',
components: { StationRow },
data() {
return {
aprsStream: null,
parser: new aprs.APRSParser(),
messages: [],
messagesFromStation: {},
now: new Date(),
trackedStations: config.trackedStations,
};
},
const parser = new APRSParser();
let aprsStream = null;
const messages = ref([]);
const messagesFromStation = ref({});
const now = ref(new Date());
const trackedStations = ref(config.trackedStations);
mounted() {
onMounted(() => {
// request notification permissions
if (Notification.permission !== 'granted') {
Notification.requestPermission((permission) => {
@ -57,37 +50,39 @@ export default {
}
// Connect to websocket aprs stream
this.connectToStream();
connectToStream();
// update shared current time every second
window.setInterval(() => (this.now = new Date()), 1000);
},
window.setInterval(() => (now.value = new Date()), 1000);
});
methods: {
connectToStream() {
this.aprsStream = new WebSocket('ws://localhost:4321');
this.aprsStream.onclose = () => {
function connectToStream() {
aprsStream = new WebSocket('ws://localhost:4321');
aprsStream.onclose = () => {
// Try to reconnect every 5 seconds
let interval = window.setTimeout(() => {
window.clearInterval(interval);
this.connectToStream();
connectToStream();
}, 5000);
};
this.aprsStream.onmessage = (event) =>
this.handleMessage(JSON.parse(event.data));
},
aprsStream.onmessage = (event) => {
if (event.data !== '') {
handleMessage(JSON.parse(event.data));
}
};
}
handleMessage(packet) {
let message = this.parser.parse(packet[1]);
function handleMessage(packet) {
let message = parser.parse(packet[1]);
message.date = new Date(packet[0]);
console.log(message);
this.messages.push(message);
console.info(message);
messages.value.push(message);
let callsign = message.from && message.from.toString();
if (callsign in this.messagesFromStation) {
this.messagesFromStation[callsign].push(message);
if (callsign in messagesFromStation.value) {
messagesFromStation.value[callsign].push(message);
} else {
this.$set(this.messagesFromStation, callsign, [message]);
messagesFromStation.value[callsign] = [message];
}
// message to TACTICAL setting a tactical nickname from an
@ -101,15 +96,13 @@ export default {
message.data.text.split(';').map((tac_assoc) => {
let [call, tac] = tac_assoc.split('=', 2);
if (tac) {
this.trackedStations[call] = tac;
trackedStations.value[call] = tac;
} else {
delete this.trackedStations[call];
delete trackedStations.value[call];
}
});
}
},
},
};
}
</script>
<style>

View File

@ -1,7 +0,0 @@
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="app"></div>
<script src="./index.js"></script>
</body>

View File

@ -1,7 +1,12 @@
import Vue from 'vue';
import App from './StatusScreen.vue';
import * as Vue from 'vue';
new Vue({
el: '#app',
render: (h) => h(App),
});
// import OpenLayersMap from 'vue3-openlayers';
// import 'vue3-openlayers/dist/vue3-openlayers.css';
import StatusScreen from './StatusScreen.vue';
// import Map from './Map.vue';
const app = Vue.createApp(StatusScreen);
// app.use(OpenLayersMap);
app.mount('#app');

View File

@ -27,8 +27,8 @@ client.on('data', function (data) {
// strip whitespace, then handle multiple APRS packets per TCP packet
str.split('\r\n').forEach((packet) => {
if (!packet.startsWith('#')) {
// ignore comments
// 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');
@ -52,6 +52,7 @@ wss.on('connection', (ws) => {
fs.readFileSync(filename)
.toString()
.split('\n')
.filter((line) => line !== '')
.forEach((line) => ws.send(line));
}
});

9
vite.config.js Normal file
View 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()],
});