Compare commits

..

3 Commits

Author SHA1 Message Date
2a55bd056f more cleanup 2025-04-17 22:09:18 -04:00
702da27468 more clean up 2025-04-17 21:27:54 -04:00
8e99669487 phase out old config stuff 2025-04-17 19:16:41 -04:00
28 changed files with 2871 additions and 651 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@vueuse/core": "^13.1.0", "@turf/turf": "^7.2.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"chartjs-adapter-moment": "^1.0.1", "chartjs-adapter-moment": "^1.0.1",
@ -19,6 +19,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"rbush": "^4.0.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"

View File

@ -143,16 +143,18 @@ export default class LayersControl {
} }
_hideLayer(name) { _hideLayer(name) {
console.debug('hideLayer: ', name); if (this._getLayers().includes(name)) {
this._disableLayerTransitions(name); this._disableLayerTransitions(name);
this._map.setLayoutProperty(name, 'visibility', 'none'); this._map.setLayoutProperty(name, 'visibility', 'none');
} }
}
_showLayer(name) { _showLayer(name) {
console.debug('showLayer: ', name); if (this._getLayers().includes(name)) {
this._disableLayerTransitions(name); this._disableLayerTransitions(name);
this._map.setLayoutProperty(name, 'visibility', 'visible'); this._map.setLayoutProperty(name, 'visibility', 'visible');
} }
}
_revertControlAction(config) { _revertControlAction(config) {
switch(config.type) { switch(config.type) {

View File

@ -1,11 +1,13 @@
<script setup> <script setup>
import { lastSeenAnnouncementId, CURRENT_ANNOUNCEMENT_ID } from '@/config'; import { CURRENT_ANNOUNCEMENT_ID } from '@/config';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useConfigStore } from '@/stores/configStore';
const ui = useUIStore(); const ui = useUIStore();
const config = useConfigStore();
function dismissAnnouncement() { function dismissAnnouncement() {
if (lastSeenAnnouncementId.value != CURRENT_ANNOUNCEMENT_ID) { if (config.lastSeenAnnouncementId != CURRENT_ANNOUNCEMENT_ID) {
lastSeenAnnouncementId.value = CURRENT_ANNOUNCEMENT_ID; config.lastSeenAnnouncementId = CURRENT_ANNOUNCEMENT_ID;
} }
ui.hideAnnouncement(); ui.hideAnnouncement();
} }

View File

@ -1,8 +1,8 @@
<script setup> <script setup>
import { selectedNodeLatestPowerMetric } from '@/store.js';
const props = defineProps({ const props = defineProps({
channel: Number, // Channel number (1, 2, or 3) channel: Number, // Channel number (1, 2, or 3),
latest: Array,
}); });
</script> </script>
@ -10,13 +10,13 @@
<li class="flex p-3"> <li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Channel {{ channel }}</div> <div class="text-sm font-medium text-gray-900">Channel {{ channel }}</div>
<div class="ml-auto text-sm text-gray-700"> <div class="ml-auto text-sm text-gray-700">
<span v-if="selectedNodeLatestPowerMetric"> <span v-if="latest">
<span v-if="selectedNodeLatestPowerMetric[`ch${channel}_voltage`]" > <span v-if="latest[`ch${channel}_voltage`]" >
{{ Number(selectedNodeLatestPowerMetric[`ch${channel}_voltage`]).toFixed(2) }}V {{ Number(latest[`ch${channel}_voltage`]).toFixed(2) }}V
</span> </span>
<span v-else>???</span> <span v-else>???</span>
<span v-if="selectedNodeLatestPowerMetric[`ch${channel}_current`]"> <span v-if="latest[`ch${channel}_current`]">
/ {{ Number(selectedNodeLatestPowerMetric[`ch${channel}_current`]).toFixed(2) }}mA / {{ Number(latest[`ch${channel}_current`]).toFixed(2) }}mA
</span> </span>
</span> </span>
<span v-else>???</span> <span v-else>???</span>

View File

@ -16,6 +16,8 @@ const search = computed({
get: () => ui.searchText, get: () => ui.searchText,
set: val => ui.search(val), set: val => ui.search(val),
}); });
// TODO: reload button is jumpy when it spins
</script> </script>
<template> <template>

View File

@ -1,12 +1,13 @@
<script setup> <script setup>
import { hasSeenInfoModal } from '@/config'; import { useConfigStore } from '@/stores/configStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
const ui = useUIStore(); const ui = useUIStore();
const config = useConfigStore();
function dismissInfoModal() { function dismissInfoModal() {
if (hasSeenInfoModal.value === false) { if (config.hasSeenInfoModal === false) {
hasSeenInfoModal.value = true; config.hasSeenInfoModal = true;
} }
ui.hideInfoModal(); ui.hideInfoModal();
} }

View File

@ -20,16 +20,62 @@ import { copyShareLinkForNode, getTimeSpan, buildPath } from '@/utils';
import { useConfigStore } from '@/stores/configStore'; import { useConfigStore } from '@/stores/configStore';
const configStore = useConfigStore(); const configStore = useConfigStore();
const emit = defineEmits(['showPositionHistory']); const emit = defineEmits(['showPositionHistory', 'showTraceRoute']);
const mapData = useMapStore(); const mapData = useMapStore();
function showPositionHistory(id) { const mqttMetrics = ref([]);
emit('showPositionHistory', id); const traceroutes = ref([]);
const deviceMetrics = ref([]);
const environmentMetrics = ref([]);
const powerMetrics = ref([]);
// Generalized function for loading data
async function loadData(path, params = {}, targetRef, metrics = false) {
try {
const response = await axios.get(buildPath(path), { params });
// Grab the first key from the response (assuming it's always an object with a single data array)
const key = Object.keys(response.data)[0];
const rawData = response.data[key] ?? [];
// Reverse if it's metric data, otherwise just assign
targetRef.value = metrics ? rawData.slice().reverse() : rawData;
} catch (error) {
console.error(`Error loading data from ${path}:`, error);
targetRef.value = [];
}
} }
function showTraceRoute(traceroute) { // Utility function to calculate the timeFrom value based on a given time range
emit('showTraceRoute', traceroute); function calculateTimeFrom(timeRange) {
state.selectedTraceRoute = traceroute; const time = getTimeSpan(timeRange).amount;
return new Date().getTime() - (time * 1000);
}
// Load MQTT metrics
function loadNodeMqttMetrics(nodeId) {
loadData(`/api/v1/nodes/${nodeId}/mqtt-metrics`, {}, mqttMetrics);
}
// Load Traceroutes
function loadNodeTraceroutes(nodeId) {
loadData(`/api/v1/nodes/${nodeId}/traceroutes`, { count: 5 }, traceroutes);
}
// Load Device Metrics
function loadNodeDeviceMetrics(nodeId) {
loadData(`/api/v1/nodes/${nodeId}/device-metrics`, { time_from: calculateTimeFrom(configStore.deviceMetricsTimeRange) }, deviceMetrics, true);
}
// Load Environment Metrics
function loadNodeEnvironmentMetrics(nodeId) {
loadData(`/api/v1/nodes/${nodeId}/environment-metrics`, { time_from: calculateTimeFrom(configStore.environmentMetricsTimeRange) }, environmentMetrics, true);
}
// Load Power Metrics
function loadNodePowerMetrics(nodeId) {
loadData(`/api/v1/nodes/${nodeId}/power-metrics`, { time_from: calculateTimeFrom(configStore.powerMetricsTimeRange) }, powerMetrics, true);
} }
function loadNode(nodeId) { function loadNode(nodeId) {
@ -40,73 +86,6 @@ function loadNode(nodeId) {
loadNodePowerMetrics(nodeId); loadNodePowerMetrics(nodeId);
} }
function loadNodeMqttMetrics(nodeId) {
state.selectedNodeMqttMetrics = [];
axios.get(buildPath(`/api/v1/nodes/${nodeId}/mqtt-metrics`)).then((response) => {
state.selectedNodeMqttMetrics = response.data.mqtt_metrics;
}).catch(() => {
// do nothing
});
}
function loadNodeTraceroutes(nodeId) {
state.selectedNodeTraceroutes = [];
axios.get(buildPath(`/api/v1/nodes/${nodeId}/traceroutes`), {
params: {
count: 5,
},
}).then((response) => {
state.selectedNodeTraceroutes = response.data.traceroutes;
}).catch(() => {
// do nothing
});
}
function loadNodeDeviceMetrics(nodeId) {
const time = getTimeSpan(configStore.deviceMetricsTimeRange).amount
const timeFrom = new Date().getTime() - (time * 1000);
axios.get(buildPath(`/api/v1/nodes/${nodeId}/device-metrics`), {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
state.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
}).catch(() => {
state.selectedNodeDeviceMetrics = [];
});
}
function loadNodeEnvironmentMetrics(nodeId) {
const time = getTimeSpan(configStore.environmentMetricsTimeRange).amount
const timeFrom = new Date().getTime() - (time * 1000);
axios.get(buildPath(`/api/v1/nodes/${nodeId}/environment-metrics`), {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
state.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
}).catch(() => {
state.selectedNodeEnvironmentMetrics = [];
});
}
function loadNodePowerMetrics(nodeId) {
const time = getTimeSpan(configStore.powerMetricsTimeRange).amount
const timeFrom = new Date().getTime() - (time * 1000);
axios.get(buildPath(`/api/v1/nodes/${nodeId}/power-metrics`), {
params: {
time_from: timeFrom,
},
}).then((response) => {
// reverse response, as it's newest to oldest, but we want oldest to newest
state.selectedNodePowerMetrics = response.data.power_metrics.reverse();
}).catch(() => {
state.selectedNodePowerMetrics = [];
});
}
watch( watch(
() => state.selectedNode, () => state.selectedNode,
(newValue) => { (newValue) => {
@ -263,17 +242,17 @@ watch(
<!-- lora config --> <!-- lora config -->
<LoraConfig :node="state.selectedNode"/> <LoraConfig :node="state.selectedNode"/>
<!-- position --> <!-- position -->
<Position @show-position-history="showPositionHistory" :node="state.selectedNode"/> <Position @show-position-history="(nodeId) => $emit('showPositionHistory', nodeId)" :node="state.selectedNode"/>
<!-- device metrics --> <!-- device metrics -->
<DeviceMetricsChart :node="state.selectedNode"/> <DeviceMetricsChart :node="state.selectedNode" :data="deviceMetrics"/>
<!-- environment metrics --> <!-- environment metrics -->
<EnvironmentMetricsChart :node="state.selectedNode"/> <EnvironmentMetricsChart :node="state.selectedNode" :data="environmentMetrics"/>
<!-- power metrics --> <!-- power metrics -->
<PowerMetricsChart/> <PowerMetricsChart :data="powerMetrics"/>
<!-- mqtt --> <!-- mqtt -->
<MqttHistory /> <MqttHistory :data="mqttMetrics"/>
<!-- traceroutes --> <!-- traceroutes -->
<Traceroutes @show-trace-route="showTraceRoute"/> <Traceroutes @show-trace-route="(traceroute) => $emit('showTraceRoute', traceroute)" :data="traceroutes"/>
<!-- other --> <!-- other -->
<OtherInfo :node="state.selectedNode"/> <OtherInfo :node="state.selectedNode"/>
<!-- share --> <!-- share -->

View File

@ -1,12 +1,10 @@
<script setup> <script setup>
const props = defineProps(['node']); const props = defineProps(['node', 'data']);
import { computed } from 'vue'; import { computed } from 'vue';
import { state } from '@/store.js';
import MetricsChart from '@/components/Chart/Metrics.vue'; import MetricsChart from '@/components/Chart/Metrics.vue';
const chartData = computed(() => { const chartData = computed(() => {
const metrics = state.selectedNodeDeviceMetrics; const metrics = props.data;
return { return {
labels: metrics.map(m => m.created_at), labels: metrics.map(m => m.created_at),

View File

@ -1,19 +1,14 @@
<script setup> <script setup>
const props = defineProps(['node']); const props = defineProps(['node', 'data']);
import { computed } from 'vue';
import { ref, computed, onMounted, watch } from 'vue'; import { formatTemperature } from '@/utils';
import { useConfigStore } from '@/stores/configStore';
import { state } from '@/store.js';
import { formatTemperature } from '@/utils.js';
import MetricsChart from '@/components/Chart/Metrics.vue'; import MetricsChart from '@/components/Chart/Metrics.vue';
const configStore = useConfigStore();
// Chart data prep // Chart data prep
const labels = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.created_at)); const labels = computed(() => props.data.map(m => m.created_at));
const temperatureMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.temperature)); const temperatureMetrics = computed(() => props.data.map(m => m.temperature));
const relativeHumidityMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.relative_humidity)); const relativeHumidityMetrics = computed(() => props.data.map(m => m.relative_humidity));
const barometricPressureMetrics = computed(() => state.selectedNodeEnvironmentMetrics.map(m => m.barometric_pressure)); const barometricPressureMetrics = computed(() => props.data.map(m => m.barometric_pressure));
const chartData = computed(() => ({ const chartData = computed(() => ({
labels: labels.value, labels: labels.value,
@ -112,7 +107,7 @@ const legendData = {
<li class="flex p-3"> <li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Temperature</div> <div class="text-sm font-medium text-gray-900">Temperature</div>
<div class="ml-auto text-sm text-gray-700"> <div class="ml-auto text-sm text-gray-700">
<span v-if="state.selectedNode?.temperature">{{ formatTemperature(state.selectedNode.temperature) }}</span> <span v-if="props.node?.temperature">{{ formatTemperature(props.node.temperature) }}</span>
<span v-else>???</span> <span v-else>???</span>
</div> </div>
</li> </li>
@ -120,8 +115,8 @@ const legendData = {
<li class="flex p-3"> <li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Relative Humidity</div> <div class="text-sm font-medium text-gray-900">Relative Humidity</div>
<div class="ml-auto text-sm text-gray-700"> <div class="ml-auto text-sm text-gray-700">
<span v-if="state.selectedNode?.relative_humidity"> <span v-if="props.node?.relative_humidity">
{{ Number(state.selectedNode.relative_humidity).toFixed(0) }}% {{ Number(props.node.relative_humidity).toFixed(0) }}%
</span> </span>
<span v-else>???</span> <span v-else>???</span>
</div> </div>
@ -130,8 +125,8 @@ const legendData = {
<li class="flex p-3"> <li class="flex p-3">
<div class="text-sm font-medium text-gray-900">Barometric Pressure</div> <div class="text-sm font-medium text-gray-900">Barometric Pressure</div>
<div class="ml-auto text-sm text-gray-700"> <div class="ml-auto text-sm text-gray-700">
<span v-if="state.selectedNode?.barometric_pressure"> <span v-if="props.node?.barometric_pressure">
{{ Number(state.selectedNode.barometric_pressure).toFixed(1) }} hPa {{ Number(props.node.barometric_pressure).toFixed(1) }} hPa
</span> </span>
<span v-else>???</span> <span v-else>???</span>
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { getRegionFrequencyRange } from '@/utils.js'; import { getRegionFrequencyRange } from '@/utils';
const props = defineProps({ const props = defineProps({
node: Object, node: Object,
}); });

View File

@ -1,9 +1,9 @@
<script setup> <script setup>
import { state } from '@/store.js'; const props = defineProps(['data']);
import moment from 'moment'; import moment from 'moment';
import { computed } from 'vue'; import { computed } from 'vue';
const mqttMetrics = computed(() => state.selectedNodeMqttMetrics); const mqttMetrics = computed(() => props.data);
</script> </script>
<template> <template>
<div> <div>

View File

@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { state, selectedNodeLatestPowerMetric } from '@/store.js';
import MetricsChart from '@/components/Chart/Metrics.vue'; import MetricsChart from '@/components/Chart/Metrics.vue';
import ChannelData from '@/components/Chart/PowerMetrics/ChannelData.vue'; import ChannelData from '@/components/Chart/PowerMetrics/ChannelData.vue';
const props = defineProps(['data']);
const legendData = { const legendData = {
'Channel 1': 'bg-blue-500', 'Channel 1': 'bg-blue-500',
@ -10,9 +10,14 @@ const legendData = {
'Channel 3': 'bg-orange-500', 'Channel 3': 'bg-orange-500',
}; };
const latestMetric = computed(() => {
const [ latest ] = (props.data ?? []).slice(-1);
return latest;
});
// Computed dataset for the chart container // Computed dataset for the chart container
const chartData = computed(() => { const chartData = computed(() => {
const metrics = state.selectedNodePowerMetrics; const metrics = props.data;
const labels = metrics.map(m => m.created_at); const labels = metrics.map(m => m.created_at);
const colors = ['#3b82f6', '#22c55e', '#f97316']; const colors = ['#3b82f6', '#22c55e', '#f97316'];
@ -109,6 +114,6 @@ const chartConfig = {
:legendData="legendData" :legendData="legendData"
:chartConfig="chartConfig" :chartConfig="chartConfig"
> >
<ChannelData v-for="i in [1, 2, 3]" :key="i" :channel="i" /> <ChannelData v-for="i in [1, 2, 3]" :key="i" :channel="i" :latest="latestMetric"/>
</MetricsChart> </MetricsChart>
</template> </template>

View File

@ -2,7 +2,7 @@
const props = defineProps({ const props = defineProps({
node: Object, node: Object,
}); });
import { getShareLinkForNode, copyShareLinkForNode } from '@/utils.js'; import { getShareLinkForNode, copyShareLinkForNode } from '@/utils';
</script> </script>
<template> <template>

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
const props = defineProps(['data']);
const emit = defineEmits(['showTraceRoute']); const emit = defineEmits(['showTraceRoute']);
import moment from 'moment'; import moment from 'moment';
import { state } from '@/store.js';
import { useMapStore } from '@/stores/mapStore'; import { useMapStore } from '@/stores/mapStore';
const mapData = useMapStore(); const mapData = useMapStore();
</script> </script>
@ -12,8 +12,8 @@ const mapData = useMapStore();
<div class="text-sm text-gray-600">Only 5 most recent are shown</div> <div class="text-sm text-gray-600">Only 5 most recent are shown</div>
</div> </div>
<ul role="list" class="flex-1 divide-y divide-gray-200"> <ul role="list" class="flex-1 divide-y divide-gray-200">
<template v-if="state.selectedNodeTraceroutes.length > 0"> <template v-if="props.data.length > 0">
<li @click="$emit('showTraceRoute', traceroute)" v-for="traceroute of state.selectedNodeTraceroutes"> <li @click="$emit('showTraceRoute', traceroute)" v-for="traceroute of props.data">
<div class="relative flex items-center"> <div class="relative flex items-center">
<div class="block flex-1 px-4 py-2"> <div class="block flex-1 px-4 py-2">
<div class="relative flex min-w-0 flex-1 items-center"> <div class="relative flex min-w-0 flex-1 items-center">

View File

@ -4,11 +4,16 @@ import { state } from '@/store';
import CloseActionButton from '@/components/CloseActionButton.vue'; import CloseActionButton from '@/components/CloseActionButton.vue';
const emit = defineEmits(['dismiss']); const emit = defineEmits(['dismiss']);
const props = defineProps({
node: {
type: Object,
required: false,
validator(value) {
return value === null || typeof value === 'object';
},
}
});
function dismissShowingNodeNeighbours() {
state.selectedNodeToShowNeighbours = null;
emit('dismiss');
}
</script> </script>
<template> <template>
@ -21,30 +26,30 @@ function dismissShowingNodeNeighbours() {
leave-from-class="translate-y-0" leave-from-class="translate-y-0"
leave-to-class="translate-y-full" leave-to-class="translate-y-full"
> >
<div v-show="state.selectedNodeToShowNeighbours" class="fixed left-0 right-0 bottom-0"> <div v-show="props.node !== null" class="fixed left-0 right-0 bottom-0">
<div v-if="state.selectedNodeToShowNeighbours" class="mx-auto w-screen max-w-md p-4"> <div v-if="props.node !== null" class="mx-auto w-screen max-w-md p-4">
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border"> <div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
<div class="p-2"> <div class="p-2">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h2 class="font-bold"> <h2 class="font-bold">
{{ state.selectedNodeToShowNeighbours.short_name }} Neighbors {{ props.node.short_name }} Neighbors
</h2> </h2>
<h3 <h3
v-if="state.selectedNodeToShowNeighboursType === 'weHeard'" v-if="state.neighborsModalType === 'weHeard'"
class="text-sm" class="text-sm"
> >
Nodes heard by {{ state.selectedNodeToShowNeighbours.short_name }} Nodes heard by {{ props.node.short_name }}
</h3> </h3>
<h3 <h3
v-if="state.selectedNodeToShowNeighboursType === 'theyHeard'" v-if="state.neighborsModalType === 'theyHeard'"
class="text-sm" class="text-sm"
> >
Nodes that heard {{ state.selectedNodeToShowNeighbours.short_name }} Nodes that heard {{ props.node.short_name }}
</h3> </h3>
</div> </div>
<CloseActionButton <CloseActionButton
@click="dismissShowingNodeNeighbours" @click="$emit('dismiss')"
class="my-auto ml-3" class="my-auto ml-3"
/> />
</div> </div>

View File

@ -1,13 +1,20 @@
<script setup> <script setup>
const emit = defineEmits(['dismiss']); const emit = defineEmits(['dismiss']);
const props = defineProps({
node: {
type: Object,
required: false,
validator(value) {
return value === null || typeof value === 'object';
},
}
});
import { state } from '@/store'; import { state } from '@/store';
import moment from 'moment'; import moment from 'moment';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
const ui = useUIStore(); const ui = useUIStore();
function dismissShowingNodePositionHistory() { function dismissShowingNodePositionHistory() {
state.selectedNodePositionHistory = [];
state.selectedNodeToShowPositionHistory = null;
ui.collapsePositionHistoryModal(); ui.collapsePositionHistoryModal();
emit('dismiss'); emit('dismiss');
} }
@ -44,8 +51,8 @@ function onPositionHistoryQuickRangeClick(range) {
leave-active-class="transition duration-300 ease-in-out transform" leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-y-0" leave-from-class="translate-y-0"
leave-to-class="translate-y-full"> leave-to-class="translate-y-full">
<div v-show="state.selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0"> <div v-show="props.node != null" class="fixed left-0 right-0 bottom-0">
<div v-if="state.selectedNodeToShowPositionHistory != null" class="mx-auto w-screen max-w-md p-4"> <div v-if="props.node != null" class="mx-auto w-screen max-w-md p-4">
<div class="flex h-full flex-col bg-white shadow-xl rounded-xl border"> <div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
<div> <div>
<div class="flex p-2"> <div class="flex p-2">
@ -61,7 +68,7 @@ function onPositionHistoryQuickRangeClick(range) {
</div> </div>
</a> </a>
</div> </div>
<div class="my-auto mr-auto font-bold">{{ state.selectedNodeToShowPositionHistory.short_name }} Position History</div> <div class="my-auto mr-auto font-bold">{{ props.node.short_name }} Position History</div>
<div class="flex my-auto ml-3 space-x-2"> <div class="flex my-auto ml-3 space-x-2">
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory"> <a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory">
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full"> <div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">

View File

@ -131,8 +131,8 @@ function formatDate(date) {
<br /><br /> <br /><br />
<button @click="state.selectedNode = node" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200 mb-1">Show Full Details</button> <button @click="state.selectedNode = node" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200 mb-1">Show Full Details</button>
<br /> <br />
<button @click="$emit('showNeighbors', 'theyHeard', node.node_id)" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button> <button @click="$emit('showNeighbors', node.node_id, 'theyHeard')" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button>
<br /> <br />
<button @click="$emit('showNeighbors', 'weHeard', node.node_id)" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200">Show Neighbours (We Heard)</button> <button @click="$emit('showNeighbors', node.node_id, 'weHeard')" class="border border-gray-300 bg-gray-100 p-1 w-full rounded-sm hover:bg-gray-200">Show Neighbours (We Heard)</button>
</div> </div>
</template> </template>

View File

@ -1,18 +1,18 @@
<script setup> <script setup>
const emit = defineEmits(['goTo']); const emit = defineEmits(['goTo', 'dismiss']);
const props = defineProps(['data']);
import moment from 'moment'; import moment from 'moment';
import { computed } from 'vue'; import { computed } from 'vue';
import { state } from '@/store';
import { useMapStore } from '@/stores/mapStore'; import { useMapStore } from '@/stores/mapStore';
import NodeEntry from '@/components/Traceroute/NodeEntry.vue'; import NodeEntry from '@/components/Traceroute/NodeEntry.vue';
const mapData = useMapStore(); const mapData = useMapStore();
// selected node IDs // selected node IDs
const toNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.to)); const toNode = computed(() => mapData.findNodeById(props.data.to));
const fromNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.from)); const fromNode = computed(() => mapData.findNodeById(props.data.from));
const gatewayNode = computed(() => mapData.findNodeById(state.selectedTraceRoute.gateway_id)); const gatewayNode = computed(() => mapData.findNodeById(props.data.gateway_id));
// pre-resolve the route nodes into an array // pre-resolve the route nodes into an array
const routeNodes = computed(() => const routeNodes = computed(() =>
state.selectedTraceRoute.route?.map(id => ({ props.data.route?.map(id => ({
id, id,
node: mapData.findNodeById(id), node: mapData.findNodeById(id),
})) ?? [] })) ?? []
@ -29,7 +29,7 @@ const routeNodes = computed(() =>
leave-active-class="transition-opacity duration-300 ease-linear" leave-active-class="transition-opacity duration-300 ease-linear"
leave-from-class="opacity-100" leave-from-class="opacity-100"
leave-to-class="opacity-0"> leave-to-class="opacity-0">
<div v-show="state.selectedTraceRoute != null" @click="state.selectedTraceRoute = null" class="fixed inset-0 bg-gray-900/75"></div> <div v-show="props.data != null" @click="$emit('dismiss')" class="fixed inset-0 bg-gray-900/75"></div>
</transition> </transition>
<!-- sidebar --> <!-- sidebar -->
@ -40,19 +40,19 @@ const routeNodes = computed(() =>
leave-active-class="transition duration-300 ease-in-out transform" leave-active-class="transition duration-300 ease-in-out transform"
leave-from-class="translate-x-0" leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"> leave-to-class="-translate-x-full">
<div v-show="state.selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0"> <div v-show="props.data != null" class="fixed top-0 left-0 bottom-0">
<div v-if="state.selectedTraceRoute != null" class="w-screen h-full max-w-md overflow-hidden"> <div v-if="props.data != null" class="w-screen h-full max-w-md overflow-hidden">
<div class="flex h-full flex-col bg-white shadow-xl"> <div class="flex h-full flex-col bg-white shadow-xl">
<!-- slideover header --> <!-- slideover header -->
<div class="p-2 border-b border-gray-200 shadow-sm"> <div class="p-2 border-b border-gray-200 shadow-sm">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h2 class="font-bold">Traceroute #{{ state.selectedTraceRoute.id }}</h2> <h2 class="font-bold">Traceroute #{{ props.data.id }}</h2>
<h3 class="text-sm">{{ moment(new Date(state.selectedTraceRoute.updated_at)).fromNow() }} - {{ state.selectedTraceRoute.route.length }} hops {{ state.selectedTraceRoute.channel_id ? `on ${state.selectedTraceRoute.channel_id}` : '' }}</h3> <h3 class="text-sm">{{ moment(new Date(props.data.updated_at)).fromNow() }} - {{ props.data.route.length }} hops {{ props.data.channel_id ? `on ${props.data.channel_id}` : '' }}</h3>
</div> </div>
<div class="my-auto ml-3 flex h-7 items-center"> <div class="my-auto ml-3 flex h-7 items-center">
<a href="javascript:void(0)" class="rounded-full" @click="state.selectedTraceRoute = null"> <a href="javascript:void(0)" class="rounded-full" @click="$emit('dismiss')">
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full"> <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@ -83,7 +83,7 @@ const routeNodes = computed(() =>
<div> <div>
<div class="bg-gray-200 p-2 font-semibold">Raw Data</div> <div class="bg-gray-200 p-2 font-semibold">Raw Data</div>
<div class="text-sm text-gray-700"> <div class="text-sm text-gray-700">
<pre class="bg-gray-100 rounded-sm p-2 overflow-x-auto">{{ JSON.stringify(state.selectedTraceRoute, null, 4) }}</pre> <pre class="bg-gray-100 rounded-sm p-2 overflow-x-auto">{{ JSON.stringify(props.data, null, 4) }}</pre>
</div> </div>
</div> </div>

View File

@ -1,11 +1,12 @@
import { useMapStore } from '@/stores/mapStore'; import { useMapStore } from '@/stores/mapStore';
import moment from 'moment'; import moment from 'moment';
import { nodesMaxAge, nodesOfflineAge } from '@/config'; // TODO: use config store import { useConfigStore } from '@/stores/configStore';
import { icons } from '@/map'; import { icons } from '@/map';
import { hasNodeUplinkedToMqttRecently, isValidCoordinates } from '@/utils'; import { hasNodeUplinkedToMqttRecently, isValidCoordinates } from '@/utils';
export function useNodeProcessor() { export function useNodeProcessor() {
const mapStore = useMapStore(); // Access your mapStore from Pinia const mapStore = useMapStore();
const config = useConfigStore();
// This function processes new node data // This function processes new node data
const processNewNodes = (newNodes) => { const processNewNodes = (newNodes) => {
@ -15,9 +16,9 @@ export function useNodeProcessor() {
for (const node of newNodes) { for (const node of newNodes) {
// Skip nodes older than configured node max age // Skip nodes older than configured node max age
if (nodesMaxAge.value) { if (config.nodesMaxAge !== null) {
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at)); const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if (lastUpdatedAgeInMillis > nodesMaxAge.value * 1000) { if (lastUpdatedAgeInMillis > config.nodesMaxAge * 1000) {
continue; continue;
} }
} }
@ -42,9 +43,9 @@ export function useNodeProcessor() {
let icon = icons.mqttDisconnected; let icon = icons.mqttDisconnected;
// Use offline icon for nodes older than configured node offline age // Use offline icon for nodes older than configured node offline age
if (nodesOfflineAge.value) { if (config.nodesOfflineAge !== null) {
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at)); const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) { if (lastUpdatedAgeInMillis > config.nodesOfflineAge * 1000) {
icon = icons.offline; icon = icons.offline;
} }
} }

View File

@ -0,0 +1,84 @@
import moment from 'moment';
import { useMapStore } from '@/stores/mapStore';
import { useConfigStore } from '@/stores/configStore';
import { isValidCoordinates } from '@/utils';
export function useWaypointProcessor() {
const mapStore = useMapStore();
const config = useConfigStore();
// This function processes new waypoint data
const processNewWaypoints = (newWaypoints) => {
const now = moment();
const processedWaypoints = [];
const processedMarkers = [];
for (const waypoint of newWaypoints) {
// skip waypoints older than configured waypoint max age
if (config.waypointsMaxAge !== null) {
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
if (lastUpdatedAgeInMillis > config.waypointsMaxAge * 1000) {
continue;
}
}
// skip expired waypoints
if (waypoint.expire < Date.now() / 1000) {
continue;
}
// skip waypoints without position
if (!waypoint.latitude || !waypoint.longitude) {
continue;
}
// skip nodes with invalid position
if (isNaN(waypoint.latitude) || isNaN(waypoint.longitude)) {
continue;
}
// fix lat long
waypoint.latitude = waypoint.latitude / 10000000;
waypoint.longitude = waypoint.longitude / 10000000;
// TODO: determine emoji to show as marker icon
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
const emojiText = String.fromCodePoint(emoji);
if (!isValidCoordinates(waypoint.latitude, waypoint.longitude)) {
continue;
}
// create waypoint marker
const marker = {
type: 'Feature',
properties: {
layer: 'waypoints',
},
geometry: {
type: 'Point',
coordinates: [waypoint.longitude, waypoint.latitude]
}
};
// add waypoint & marker to cache
processedWaypoints.push(waypoint);
processedMarkers.push(marker);
}
// Return processed data (waypoints and markers)
return { processedWaypoints, processedMarkers };
};
// Process new data and store it (bulk processing)
const parseWaypointsResponse = (newWaypoints) => {
const { processedWaypoints, processedMarkers } = processNewWaypoints(newWaypoints);
// Clear old data and update in bulk
mapStore.clearWaypoints();
mapStore.setWaypoints(processedWaypoints);
mapStore.setWaypointMarkers(processedMarkers);
};
return {
parseWaypointsResponse,
};
};

View File

@ -1,27 +1,3 @@
import { useStorage } from '@vueuse/core';
// static // static
export const CURRENT_ANNOUNCEMENT_ID = 1; export const CURRENT_ANNOUNCEMENT_ID = 1;
export const BASE_PATH = 'http://localhost:9090'; export const BASE_PATH = 'http://localhost:9090';
// boolean
export const autoUpdatePositionInUrl = useStorage('auto-update-url', true);
export const enableMapAnimations = useStorage('map-animations', true);
export const hasSeenInfoModal = useStorage('seen-info-modal', false);
// time in seconds
export const nodesMaxAge = useStorage('nodes-max-age', null);
export const nodesDisconnectedAge = useStorage('nodes-max-disconnected-age', 604800);
export const nodesOfflineAge = useStorage('nodes-offline-age', null);
export const waypointsMaxAge = useStorage('waypoints-max-age', 604800);
// number
export const goToNodeZoomLevel = useStorage('zoom-to-node', 15);
export const lastSeenAnnouncementId = useStorage('last-seen-announcement-id', 1);
// distance in meters
export const neighboursMaxDistance = useStorage('neighbors-distance', null);
// device info ranges
export const deviceMetricsTimeRange = useStorage('device-metrics-range', '3d');
export const powerMetricsTimeRange = useStorage('power-metrics-range', '3d');
export const environmentMetricsTimeRange = useStorage('environment-metrics-range', '3d');
// map config
export const enabledOverlayLayers = useStorage('enabled-overlay-layers', ['Legend', 'Position History']);
export const selectedTileLayerName = useStorage('selected-tile-layer', 'OpenStreetMap');

View File

@ -1,8 +1,6 @@
export default { export default {
mounted(el, binding) { mounted(el, binding) {
console.log('mounted')
el._clickOutsideHandler = (event) => { el._clickOutsideHandler = (event) => {
console.log('handler')
if (!(el === event.target || el.contains(event.target))) { if (!(el === event.target || el.contains(event.target))) {
binding.value(event); binding.value(event);
} }

View File

@ -11,6 +11,9 @@ export function setMap(map) {
export function getMap() { export function getMap() {
return instance; return instance;
} }
export function unsetMap() {
instance = null;
}
export const layerGroups = { export const layerGroups = {
nodes: {}, nodes: {},

View File

@ -1,25 +1,19 @@
import { reactive, computed } from 'vue'; import { reactive } from 'vue';
export const state = reactive({ export const state = reactive({
// state // state
searchText: '', // moved to ui store, maybe should not be there though? searchText: '', // moved to ui store, maybe should not be there though?
selectedNodeOutlineCircle: null, selectedNodeOutlineCircle: null,
selectedNodeMqttMetrics: [],
selectedNodeTraceroutes: [], // new selected node/data stuff
selectedNodeDeviceMetrics: [], positionHistoryNode: null,
selectedNodePowerMetrics: [], neighborsNode: null,
selectedNodeEnvironmentMetrics: [], traceRouteData: null,
selectedNodeToShowNeighbours: null,
selectedNodeToShowNeighbours: null, // new modal specific stuff
selectedNodeToShowNeighboursType: null, neighborsModalType: null,
// position history // position history
selectedNodeToShowPositionHistory: null,
positionHistoryDateTimeTo: null, positionHistoryDateTimeTo: null,
positionHistoryDateTimeFrom: null, positionHistoryDateTimeFrom: null,
}); });
export const selectedNodeLatestPowerMetric = computed(() => {
const [ latestPowerMetric ] = state.selectedNodePowerMetrics.slice(-1);
return latestPowerMetric;
});

View File

@ -21,7 +21,7 @@ export const useConfigStore = defineStore('config', {
lastSeenAnnouncementId: 1, lastSeenAnnouncementId: 1,
// Distance values (for max distances) // Distance values (for max distances)
neighboursMaxDistance: null, neighborsMaxDistance: null,
// Device info ranges (can be persisted) // Device info ranges (can be persisted)
deviceMetricsTimeRange: '3d', deviceMetricsTimeRange: '3d',

View File

@ -1,55 +1,111 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import RBush from 'rbush';
import { point, bbox, distance } from '@turf/turf';
export const useMapStore = defineStore('map', { export const useMapStore = defineStore('map', {
state: () => ({ state: () => ({
// core stuff
nodes: [], nodes: [],
nodeIndex: new RBush(),
waypoints: [], waypoints: [],
nodeMarkers: {}, nodeMarkers: {},
waypointMarkers: [], waypointMarkers: [],
// temp stuff
positionHistory: [],
}), }),
actions: { actions: {
// Bulk set nodes and rebuild the spatial index
setNodes(nodes) { setNodes(nodes) {
this.nodes = nodes; this.nodes = nodes;
this.indexNodes(nodes); // Efficient reindexing after bulk update
}, },
// Bulk set node markers
setNodeMarkers(markers) { setNodeMarkers(markers) {
this.nodeMarkers = markers; this.nodeMarkers = markers;
}, },
addNode(node, marker) {
// TODO do validation i.e. does it exist already? // Clear nodes and markers
this.nodes.push(node);
// allow undefined/null marker (nodes don't always have a marker)
if (typeof marker === 'object') this.addNodeMarker(marker);
},
addNodeMarker(marker) {
// TODO do validation checking here -- i.e. do we have the node? is marker.proprties.id set? does it exist already?
this.nodeMarkers[marker.properties.id] = marker;
},
clearNodes() { clearNodes() {
this.nodes = []; this.nodes = [];
this.nodeMarkers = {}; this.nodeMarkers = {};
this.nodeIndex.clear(); // Clear the spatial index when nodes are cleared
}, },
// Bulk set waypoints
setWaypoints(waypoints) { setWaypoints(waypoints) {
this.waypoints = waypoints; this.waypoints = waypoints;
}, },
addWaypoint(waypoint, marker) {
this.waypoints.push(waypoint); // Bulk set waypoint markers
this.addWaypointMarker(marker); setWaypointMarkers(markers) {
}, this.waypointMarkers = markers;
addWaypointMarker(marker) {
this.waypointMarkers.push(marker);
}, },
// Clear waypoints and markers
clearWaypoints() { clearWaypoints() {
this.waypoints = []; this.waypoints = [];
this.waypointMarkers = []; this.waypointMarkers = [];
}, },
// Find a node by ID
findNodeById(id) { findNodeById(id) {
return this.nodes.find((node) => node.node_id.toString() === id.toString()) ?? null; return this.nodes.find(node => node.node_id.toString() === id.toString()) ?? null;
}, },
// Find a node marker by ID
findNodeMarkerById(id) { findNodeMarkerById(id) {
return this.nodeMarkers[id] ?? null; return this.nodeMarkers[id] ?? null;
}, },
// Find a random node
findRandomNode() { findRandomNode() {
return this.nodes[Math.floor(Math.random() * this.nodes.length)] ?? null; return this.nodes[Math.floor(Math.random() * this.nodes.length)] ?? null;
}, },
// Rebuild the spatial index for a set of nodes
indexNodes(nodes) {
// Clear the previous index
this.nodeIndex.clear();
// Rebuild the index for the new set of nodes
nodes.forEach((node) => {
const nodeMarker = this.findNodeMarkerById(node.node_id);
if (nodeMarker?.geometry?.coordinates) {
const coords = nodeMarker.geometry.coordinates;
const box = bbox(point(coords)); // Get the bounding box for the point
this.nodeIndex.insert({ minX: box[0], minY: box[1], maxX: box[2], maxY: box[3], node });
}
});
},
// Find nearby nodes within a specific distance
findNearbyNodes(node, maxDistance, checkForHeard) {
const nodeMarker = this.findNodeMarkerById(node.node_id);
if (!nodeMarker?.geometry?.coordinates) return [];
const coords = nodeMarker.geometry.coordinates;
const searchBox = bbox(point(coords)); // Get bounding box around the point
// Query the spatial index to find nearby nodes
const nearbyNodes = this.nodeIndex.search({
minX: searchBox[0], minY: searchBox[1], maxX: searchBox[2], maxY: searchBox[3]
});
return nearbyNodes.filter(({ node: nearbyNode }) => {
const nearbyNodeMarker = this.findNodeMarkerById(nearbyNode.node_id);
const distanceToNode = distance(nodeMarker, nearbyNodeMarker, { units: 'meters' });
// For 'theyHeard', check if current node is in the nearby node's neighbors
if (checkForHeard) {
const match = nearbyNode.neighbors?.find(n => n.node_id === node.node_id);
return match && distanceToNode <= maxDistance;
} else {
return distanceToNode <= maxDistance;
}
}).map(({ node: nearbyNode }) => ({
node: nearbyNode,
neighborData: nearbyNode.neighbors?.find(n => n.node_id === node.node_id),
}));
},
}, },
}); });

View File

@ -12,15 +12,18 @@ import NodeTooltip from '@/components/NodeTooltip.vue';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useMapStore } from '@/stores/mapStore'; import { useMapStore } from '@/stores/mapStore';
import { useConfigStore } from '@/stores/configStore';
import { useNodeProcessor } from '@/composables/useNodeProcessor'; import { useNodeProcessor } from '@/composables/useNodeProcessor';
import { useWaypointProcessor } from '@/composables/useWaypointProcessor';
import axios from 'axios'; import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import { point, distance } from '@turf/turf';
import LegendControl from '@/LegendControl'; import LegendControl from '@/LegendControl';
import LayerControl from '@/LayerControl'; import LayerControl from '@/LayerControl';
import { onMounted, useTemplateRef, ref, watch, markRaw, nextTick, createApp, shallowRef } from 'vue'; import { onMounted, useTemplateRef, ref, watch, createApp, shallowRef, onUnmounted } from 'vue';
import { state } from '@/store'; import { state } from '@/store';
import { import {
layerGroups, layerGroups,
@ -32,30 +35,15 @@ import {
clearAllNeighbors, clearAllNeighbors,
clearAllWaypoints, clearAllWaypoints,
clearAllPositionHistory, clearAllPositionHistory,
cleanUpPositionHistory,
closeAllTooltips, closeAllTooltips,
closeAllPopups, closeAllPopups,
cleanUpNodeNeighbors,
clearNodeOutline, clearNodeOutline,
clearMap, clearMap,
setMap, setMap,
getMap, getMap,
unsetMap,
} from '@/map'; } from '@/map';
import { import { CURRENT_ANNOUNCEMENT_ID } from '@/config';
nodesMaxAge,
nodesDisconnectedAge,
nodesOfflineAge,
waypointsMaxAge,
enableMapAnimations,
goToNodeZoomLevel,
autoUpdatePositionInUrl,
neighboursMaxDistance,
enabledOverlayLayers,
selectedTileLayerName,
hasSeenInfoModal,
lastSeenAnnouncementId,
CURRENT_ANNOUNCEMENT_ID,
} from '@/config';
import { import {
getColorForSnr, getColorForSnr,
getPositionPrecisionInMeters, getPositionPrecisionInMeters,
@ -75,18 +63,20 @@ const mapEl = useTemplateRef('appMap');
const popup = shallowRef(null); // Keep a single popup reference const popup = shallowRef(null); // Keep a single popup reference
const popupTarget = ref(null); // DOM container for Vue teleport const popupTarget = ref(null); // DOM container for Vue teleport
const isTooltipLocked = ref(false); // Locked open (via click) const isTooltipLocked = ref(false); // Locked open (via click)
const selectedNode = ref(null); const selectedNode = ref(null); // related to tooltip only
const ui = useUIStore(); const ui = useUIStore();
const mapData = useMapStore(); const mapData = useMapStore();
const config = useConfigStore();
const { parseNodesResponse } = useNodeProcessor(); const { parseNodesResponse } = useNodeProcessor();
const { parseWaypointsResponse } = useWaypointProcessor();
// watchers // watchers
watch( watch(
() => state.positionHistoryDateTimeTo, () => state.positionHistoryDateTimeTo,
(newValue) => { (newValue) => {
if (newValue != null) { if (newValue != null) {
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id); loadNodePositionHistory(state.positionHistoryNode.node_id);
} }
}, {deep: true} }, {deep: true}
); );
@ -94,30 +84,60 @@ watch(
() => state.positionHistoryDateTimeFrom, () => state.positionHistoryDateTimeFrom,
(newValue) => { (newValue) => {
if (newValue != null) { if (newValue != null) {
loadNodePositionHistory(state.selectedNodeToShowPositionHistory.node_id); loadNodePositionHistory(state.positionHistoryNode.node_id);
} }
}, {deep: true} }, {deep: true}
); );
watch( watch(
() => mapData.nodeMarkers, () => mapData.nodeMarkers,
(newMarkers, oldMarkers) => { (newMarkers) => {
// This will trigger whenever nodeMarkers change // This will trigger whenever nodeMarkers change
updateMapNodeSource(newMarkers); updateMapSourceData('nodes', newMarkers);
}, },
{ deep: true } // Ensure that nested changes are also observed { deep: true } // Ensure that nested changes are also observed
); );
function updateMapNodeSource(markers) { watch(
const source = getMap().getSource('nodes'); () => mapData.waypointMarkers,
if (source) { (newMarkers) => {
// This will trigger whenever waypointMarkers change
updateMapSourceData('waypoints', newMarkers);
},
{ deep: true } // Ensure that nested changes are also observed
);
function updateMapSourceData(sourceName, features) {
const source = getMap().getSource(sourceName);
if (source !== null) {
source.setData({ source.setData({
type: 'FeatureCollection', type: 'FeatureCollection',
features: Object.values(markers), features: Object.values(features),
}); });
} }
} }
function resetNodeNeighbors() {
state.neighborsNode = null;
state.neighborsModalType = null;
cleanUpNodeNeighbors();
}
function cleanUpNodeNeighbors() {
// do map stuff (clean up markers and whatnot)
}
function resetPositionHistory() {
state.positionHistoryNode = null; // clear node, closes ui
mapStore.positionHistory = []; // clears out position history cache
cleanUpPositionHistory();
}
function cleanUpPositionHistory() {
// do map stuff (clean up markers and whatnot)
}
// TODO: this still scales pretty badly, also colors are off
function showNodeOutline(id) { function showNodeOutline(id) {
// remove any existing node circle // remove any existing node circle
clearNodeOutline(); clearNodeOutline();
@ -141,7 +161,6 @@ function showNodeOutline(id) {
let adjustedRadius = radiusInMeters * zoomFactor; let adjustedRadius = radiusInMeters * zoomFactor;
// Set a minimum radius (e.g., 10 meters) to avoid disappearing circles // Set a minimum radius (e.g., 10 meters) to avoid disappearing circles
adjustedRadius = Math.max(adjustedRadius, 10); adjustedRadius = Math.max(adjustedRadius, 10);
console.log(adjustedRadius)
// Create a circle as a GeoJSON feature // Create a circle as a GeoJSON feature
const geojsonCircle = { const geojsonCircle = {
@ -151,330 +170,125 @@ function showNodeOutline(id) {
coordinates: nodeMarker.geometry.coordinates, coordinates: nodeMarker.geometry.coordinates,
}, },
properties: { properties: {
radius: adjustedRadius // You can store the radius in the properties if needed radius: adjustedRadius,
} }
}; };
getMap().getSource('node-outlines').setData(geojsonCircle); getMap().getSource('node-outlines').setData(geojsonCircle);
} }
} }
function showNeighbors(type, id) { function showNodeNeighbors(id, direction = 'weHeard') {
let func = showNodeNeighboursThatHeardUs;
if (type === 'weHeard') func = showNodeNeighboursThatWeHeard;
func(id);
}
function showNodeNeighboursThatHeardUs(id) {
cleanUpNodeNeighbors(); cleanUpNodeNeighbors();
// find node const node = useMapStore().findNodeById(id);
const node = mapData.findNodeById(id); if (!node) return;
if (!node) {
const nodeMarker = useMapStore().findNodeMarkerById(node.node_id);
if (!nodeMarker?.geometry?.coordinates) return;
state.neighborsNode = node;
state.neighborsModalType = direction;
const neighborFeatures = [];
const neighbors = direction === 'weHeard' ? node.neighbors ?? [] : mapData.findNearbyNodes(node, config.neighborsMaxDistance, true);
// Process neighbors
neighbors.forEach((neighborData) => {
const neighborNode = direction === 'weHeard' ? neighborData : neighborData.node;
const neighbor = direction === 'weHeard' ? neighborData : neighborData.neighborData;
if (neighbor.snr === 0) return;
const neighborNodeMarker = useMapStore().findNodeMarkerById(neighborNode.node_id);
if (!neighborNodeMarker?.geometry?.coordinates) return;
// Calculate the distance in meters between the current node and the neighbor
const from = point(neighborNodeMarker.geometry.coordinates);
const to = point(nodeMarker.geometry.coordinates);
const distanceInMeters = distance(from, to, { units: 'meters' }).toFixed(2);
// Enforce max distance for weHeaerd
if (config.neighborsMaxDistance != null && parseFloat(distanceInMeters) > config.neighborsMaxDistance && direction === 'weHeard') {
return; return;
} }
// find node marker // Create the neighbor feature
const nodeMarker = mapData.findNodeMarkerById(node.node_id); const feature = createNeighborFeature(node, neighborNode, neighbor.snr, direction, distanceInMeters);
if (!nodeMarker) { if (feature) {
return; neighborFeatures.push(feature);
} }
// show overlay
state.selectedNodeToShowNeighbours = node;
state.selectedNodeToShowNeighboursType = 'theyHeard';
// find all nodes that have us as a neighbour
const neighbourNodeInfos = [];
for (const nodeThatMayHaveHeardUs of mapData.nodes) {
// find our node in this nodes neighbours
const nodeNeighbours = nodeThatMayHaveHeardUs.neighbours ?? [];
const neighbour = nodeNeighbours.find(function(neighbour) {
return neighbour.node_id.toString() === node.node_id.toString();
}); });
if (neighborFeatures.length > 0) renderNeighborLines(neighborFeatures);
// we exist as a neighbour
if (neighbour) {
neighbourNodeInfos.push({
node: nodeThatMayHaveHeardUs,
neighbour: neighbour,
});
}
}
// ensure we have neighbours to show
if (neighbourNodeInfos.length === 0) {
return;
}
// add node neighbours
for (const neighbourNodeInfo of neighbourNodeInfos) {
const neighbourNode = neighbourNodeInfo.node;
const neighbour = neighbourNodeInfo.neighbour;
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
if (neighbour.snr === 0) {
continue;
}
// find neighbour node marker
const neighbourNodeMarker = mapData.findNodeMarkerById(neighbourNode.node_id);
if (!neighbourNodeMarker) {
continue;
}
// calculate distance in meters between nodes (rounded to 2 decimal places)
const distanceInMeters = neighbourNodeMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
// don't show this neighbour connection if further than config allows
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
continue;
}
// add neighbour line to map
const line = L.polyline([
nodeMarker.getLatLng(), // from us
neighbourNodeMarker.getLatLng(), // to neighbour
], {
color: getColourForSnr(neighbour.snr),
opacity: 1,
}).arrowheads({
size: '10px',
fill: true,
offsets: {
start: '25px',
end: '25px',
},
}).addTo(layerGroups.nodeNeighbors);
const tooltip = getNeighbourTooltipContent('theyHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
}).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
}
} }
function showNodeNeighboursThatWeHeard(id) { // Create a GeoJSON feature for each neighbor
cleanUpNodeNeighbors(); function createNeighborFeature(node, neighborNode, snr, direction, distanceInMeters) {
const neighborNodeMarker = useMapStore().findNodeMarkerById(neighborNode.node_id);
const nodeMarker = useMapStore().findNodeMarkerById(node.node_id);
// find node // Create the GeoJSON line feature for the neighbor connection
const node = mapData.findNodeById(id); return {
if (!node) { id: `line-${node.node_id}-${neighborNode.node_id}`,
return;
}
// find node marker
const nodeMarker = mapData.findNodeMarkerById(node.node_id);
if (!nodeMarker) {
return;
}
// show overlay
state.selectedNodeToShowNeighbours = node;
state.selectedNodeToShowNeighboursType = 'weHeard';
// ensure we have neighbours to show
const neighbours = node.neighbours ?? [];
if (neighbours.length === 0) {
return;
}
for (const neighbour of neighbours) {
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
if (neighbour.snr === 0) {
continue;
}
// find neighbor node
const neighbourNode = mapData.findNodeById(neighbour.node_id);
if (!neighbourNode) {
continue;
}
// find neighbor node marker
const neighbourNodeMarker = mapData.findNodeMarkerById(neighbour.node_id);
if (!neighbourNodeMarker) {
continue;
}
// calculate distance in meters between nodes (rounded to 2 decimal places)
const distanceInMeters = nodeMarker.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) {
continue;
}
// add neighbour line to map
const line = L.polyline([
neighbourNodeMarker.getLatLng(), // from neighbor
nodeMarker.getLatLng(), // to us
], {
color: getColorForSnr(neighbour.snr),
opacity: 1,
}).arrowheads({
size: '10px',
fill: true,
offsets: {
start: '25px',
end: '25px',
},
}).addTo(layerGroups.nodeNeighbors);
const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
}).bindPopup(tooltip).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
}
}
function onNodesUpdated(nodes) {
const now = moment();
// clear cach
mapData.clearNodes();
for (const node of nodes) {
// skip nodes older than configured node max age
if (nodesMaxAge.value) {
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if (lastUpdatedAgeInMillis > nodesMaxAge.value * 1000) {
continue;
}
}
// add node to cache
mapData.addNode(node);
// skip nodes without position
if (!node.latitude || !node.longitude) {
continue;
}
// skip nodes with invalid position
if (isNaN(node.latitude) || isNaN(node.longitude)) {
continue;
}
// fix lat long
node.latitude = node.latitude / 10000000;
node.longitude = node.longitude / 10000000;
// icon based on mqtt connection state
let icon = icons.mqttDisconnected;
// use offline icon for nodes older than configured node offline age
if (nodesOfflineAge.value) {
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
if (lastUpdatedAgeInMillis > nodesOfflineAge.value * 1000) {
icon = icons.offline;
}
}
// determine if node was recently heard uplinking packets to mqtt
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
if (nodeHasUplinkedToMqttRecently) {
icon = icons.mqttConnected;
}
if (!isValidCoordinates(node.latitude, node.longitude)) {
continue;
}
// create node marker
const marker = {
type: 'Feature', type: 'Feature',
properties: {
id: node.node_id,
role: node.role_name,
layer: 'nodes',
color: icon,
},
geometry: { geometry: {
type: 'Point', type: 'LineString',
coordinates: [node.longitude, node.latitude] coordinates: [
} neighborNodeMarker.geometry.coordinates, // from neighbor
nodeMarker.geometry.coordinates, // to our node
],
},
properties: {
snr,
color: getColorForSnr(snr),
tooltip: getNeighbourTooltipContent(direction, node, neighborNode, distanceInMeters, snr), // TODO
},
}; };
// add marker to cache
mapData.addNodeMarker(marker);
}
// set data
const source = getMap().getSource('nodes');
if (source) {
source.setData({
type: 'FeatureCollection',
features: Object.values(mapData.nodeMarkers),
});
}
} }
function onWaypointsUpdated(waypoints) { function renderNeighborLines(features) {
const now = moment(); const sourceId = 'node-neighbors';
// clear cache const lineLayerId = 'node-neighbors-line';
mapData.clearWaypoints();
// add waypoints
for (const waypoint of waypoints) {
// skip waypoints older than configured waypoint max age
if (waypointsMaxAge.value) {
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
if (lastUpdatedAgeInMillis > waypointsMaxAge.value * 1000) {
continue;
}
}
// skip expired waypoints // Cleanup any previous layers/sources
if (waypoint.expire < Date.now() / 1000) { if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
continue; if (map.getSource(sourceId)) map.removeSource(sourceId);
}
// skip waypoints without position // Add source
if (!waypoint.latitude || !waypoint.longitude) { map.addSource(sourceId, {
continue; type: 'geojson',
} data: {
// skip nodes with invalid position
if (isNaN(waypoint.latitude) || isNaN(waypoint.longitude)) {
continue;
}
// fix lat long
waypoint.latitude = waypoint.latitude / 10000000;
waypoint.longitude = waypoint.longitude / 10000000;
// TODO: determine emoji to show as marker icon
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
const emojiText = String.fromCodePoint(emoji);
if (!isValidCoordinates(node.latitude, node.longitude)) {
continue;
}
// create waypoint marker
const marker = {
type: 'Feature',
properties: {
layer: 'waypoints',
},
geometry: {
type: 'Point',
coordinates: [waypoint.longitude, waypoint.latitude]
}
};
// add waypoint & marker to cache
mapData.addWaypoint(waypoint, marker);
}
// set data
const source = getMap().getSource('waypoints');
if (source) {
source.setData({
type: 'FeatureCollection', type: 'FeatureCollection',
features: Object.values(mapData.waypointMarkers), features,
},
});
// Add solid line layer (no animation, no arrows)
map.addLayer({
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
},
});
// Interactivity: tooltips/popups
map.on('click', lineLayerId, (e) => {
const feature = e.features[0];
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(feature.properties.tooltip)
.addTo(map);
});
map.on('mouseenter', lineLayerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', lineLayerId, () => {
map.getCanvas().style.cursor = '';
}); });
}
} }
// TODO // TODO
@ -567,7 +381,7 @@ function showNodePositionHistory(nodeId) {
// update ui // update ui
state.selectedNode = null; state.selectedNode = null;
state.selectedNodeToShowPositionHistory = node; state.positionHistoryNode = node;
ui.expandPositionHistoryModal(); ui.expandPositionHistoryModal();
// close node info tooltip as position history shows under it // close node info tooltip as position history shows under it
@ -583,7 +397,7 @@ function showNodePositionHistory(nodeId) {
} }
function loadNodePositionHistory(nodeId) { function loadNodePositionHistory(nodeId) {
state.selectedNodePositionHistory = []; mapStore.positionHistory = [];
axios.get(buildPath(`/api/v1/nodes/${nodeId}/position-history`), { axios.get(buildPath(`/api/v1/nodes/${nodeId}/position-history`), {
params: { params: {
// parse from datetime-local format, and send as unix timestamp in milliseconds // parse from datetime-local format, and send as unix timestamp in milliseconds
@ -591,8 +405,8 @@ function loadNodePositionHistory(nodeId) {
time_to: moment(state.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"), time_to: moment(state.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
}, },
}).then((response) => { }).then((response) => {
state.selectedNodePositionHistory = response.data.position_history; mapStore.positionHistory = response.data.position_history;
if (state.selectedNodeToShowPositionHistory != null) { if (state.positionHistoryNode != null) {
clearAllPositionHistory(); clearAllPositionHistory();
onPositionHistoryUpdated(response.data.position_history); onPositionHistoryUpdated(response.data.position_history);
}; };
@ -622,7 +436,7 @@ function goToNode(id, animate, zoom){
// find node // find node
const node = mapData.findNodeById(id); const node = mapData.findNodeById(id);
if (!node) { if (!node) {
alert("Could not find node: " + id); alert('Could not find node: ' + id);
return false; return false;
} }
@ -641,7 +455,7 @@ function goToNode(id, animate, zoom){
const coords = nodeMarker.geometry.coordinates; const coords = nodeMarker.geometry.coordinates;
getMap().flyTo({ getMap().flyTo({
center: coords, center: coords,
zoom: parseFloat(zoom || goToNodeZoomLevel.value) zoom: parseFloat(zoom || config.goToNodeZoomLevel)
}); });
getMap().once('moveend', async () => { getMap().once('moveend', async () => {
// add position bubble for node // add position bubble for node
@ -655,6 +469,14 @@ function goToNode(id, animate, zoom){
} }
function showTraceRoute(traceroute) {
state.traceRouteData = traceroute;
}
function resetTraceRoute() {
state.traceRouteData = null;
}
function onSearchResultNodeClick(node) { function onSearchResultNodeClick(node) {
// clear search // clear search
ui.search(''); ui.search('');
@ -670,6 +492,14 @@ function onSearchResultNodeClick(node) {
state.selectedNode = node; state.selectedNode = node;
} }
onUnmounted(() => {
const map = getMap();
if (map !== null) {
map.remove();
unsetMap();
}
});
onMounted(() => { onMounted(() => {
const bounds = [ const bounds = [
[-100, 70], // top left [-100, 70], // top left
@ -686,12 +516,10 @@ onMounted(() => {
layers: [], layers: [],
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
}, },
//center: [-15, 150],
center: [0, 0], center: [0, 0],
zoom: 2, zoom: 2,
fadeDuration: 0, fadeDuration: 0,
renderWorldCopies: false renderWorldCopies: false
//maxBounds: [[-180, -85], [180, 85]]
}); });
setMap(map); setMap(map);
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right'); map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
@ -704,7 +532,7 @@ onMounted(() => {
}), 'top-left'); }), 'top-left');
const layerControl = new LayerControl({ const layerControl = new LayerControl({
maps: tileLayers, maps: tileLayers,
initialMap: 'OpenStreetMap', initialMap: config.selectedTileLayerName,
controls: { controls: {
Nodes: { Nodes: {
type: 'radio', type: 'radio',
@ -712,7 +540,7 @@ onMounted(() => {
layers: { layers: {
'All': { 'All': {
type: 'layer_control', type: 'layer_control',
hideAllExcept: ['nodes', 'node-outlines'], hideAllExcept: ['nodes', 'node-outlines', 'node-neighbors-line'],
disableCluster: 'nodes', disableCluster: 'nodes',
}, },
'Routers': { 'Routers': {
@ -723,7 +551,7 @@ onMounted(() => {
}, },
'Clustered': { 'Clustered': {
type: 'layer_control', type: 'layer_control',
hideAllExcept: ['clusters', 'unclustered-points', 'cluster-count', 'node-outlines'], hideAllExcept: ['clusters', 'unclustered-points', 'cluster-count', 'node-outlines', 'node-neighbors-line'],
}, },
'None': { 'None': {
type: 'layer_control', type: 'layer_control',
@ -733,7 +561,7 @@ onMounted(() => {
}, },
Overlays: { Overlays: {
type: 'checkbox', type: 'checkbox',
default: ['Legend', 'Position History'], default: config.enabledOverlayLayers,
layers: { layers: {
'Legend': { 'Legend': {
type: 'toggle_element', type: 'toggle_element',
@ -927,7 +755,6 @@ function measureTooltipSize(node) {
} }
async function openLockedTooltipFromNode(feature) { async function openLockedTooltipFromNode(feature) {
console.log('openLockedTooltipFromNode', feature);
const nodeId = feature?.properties?.id; const nodeId = feature?.properties?.id;
const node = mapData.findNodeById(nodeId ?? ''); const node = mapData.findNodeById(nodeId ?? '');
const coordinates = feature?.geometry?.coordinates?.slice(); const coordinates = feature?.geometry?.coordinates?.slice();
@ -1122,10 +949,10 @@ async function determineAnchorForNode(node, coordinates) {
} }
onMounted(() => { onMounted(() => {
if (lastSeenAnnouncementId.value !== CURRENT_ANNOUNCEMENT_ID) { if (config.lastSeenAnnouncementId !== CURRENT_ANNOUNCEMENT_ID) {
ui.showAnnouncement(); ui.showAnnouncement();
} }
if (!isMobile() && hasSeenInfoModal.value === false) { if (!isMobile() && config.hasSeenInfoModal === false) {
ui.showInfoModal(); ui.showInfoModal();
} }
}) })
@ -1148,11 +975,11 @@ onMounted(() => {
<InfoModal /> <InfoModal />
<HardwareModelList /> <HardwareModelList />
<Settings /> <Settings />
<NodeInfo @show-position-history="showNodePositionHistory"/> <NodeInfo @show-position-history="showNodePositionHistory" @show-trace-route="showTraceRoute"/>
<NodeNeighborsModal @dismiss="cleanUpNodeNeighbors" /> <NodeNeighborsModal @dismiss="resetNodeNeighbors" :node="state.neighborsNode"/>
<NodePositionHistoryModal @dismiss="cleanUpPositionHistory" /> <NodePositionHistoryModal @dismiss="resetPositionHistory" :node="state.positionHistoryNode"/>
<TracerouteInfo @go-to="goToNode" /> <TracerouteInfo @go-to="goToNode" @dismiss="resetTraceRoute" :data="state.traceRouteData"/>
<Teleport v-if="popupTarget && selectedNode" :to="popupTarget"> <Teleport v-if="popupTarget && selectedNode" :to="popupTarget">
<NodeTooltip :node="selectedNode" @show-neighbors="showNeighbors"/> <NodeTooltip :node="selectedNode" @show-neighbors="showNodeNeighbors"/>
</Teleport> </Teleport>
</template> </template>