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": "", "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

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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>

View File

@ -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>

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 * 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');

View File

@ -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
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()],
});