3933 lines
190 KiB
HTML
3933 lines
190 KiB
HTML
<html>
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
<title>STHLM-MESH MAP</title>
|
|
<meta name="title" content="Meshtastic Map">
|
|
<meta name="description" content="An interactive map of all Meshtastic nodes.">
|
|
<link rel="icon" type="image/png" href="/icon.png"/>
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://map.sthlm-mesh.se">
|
|
<meta property="og:title" content="STHLM-MESH MAP">
|
|
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
|
|
|
|
<!-- tailwind css -->
|
|
<script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
|
|
|
|
<!-- leaflet map -->
|
|
<link rel="stylesheet" href="assets/js/leaflet@1.9.3/dist/leaflet.css" />
|
|
<script src="assets/js/leaflet@1.9.3/dist/leaflet.js"></script>
|
|
|
|
<!-- leaflet plugins -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.polylineoffset.js"></script>
|
|
<script src="assets/js/leaflet-plugins/leaflet.geometryutil.js"></script>
|
|
<script src="assets/js/leaflet-plugins/leaflet-arrowheads.js"></script>
|
|
|
|
<!-- leaflet markercluster -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.css"/>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.Default.css"/>
|
|
|
|
<!-- leaflet groupedlayercontrol -->
|
|
<script src="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js"></script>
|
|
<link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css"/>
|
|
|
|
<!-- moment -->
|
|
<script src="assets/js/moment@2.29.1/moment.min.js"></script>
|
|
|
|
<!-- vuejs -->
|
|
<script src="assets/js/vue@3.4.26/dist/vue.global.js"></script>
|
|
|
|
<!-- axios -->
|
|
<script src="assets/js/axios@1.6.8/dist/axios.min.js"></script>
|
|
|
|
<!-- chart js -->
|
|
<script src="assets/js/chart.js@4.4.2/dist/chart.umd.js"></script>
|
|
<script src="assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js"></script>
|
|
|
|
<style>
|
|
|
|
/* used to prevent ui flicker before vuejs loads */
|
|
[v-cloak] {
|
|
display: none;
|
|
}
|
|
|
|
.icon-mqtt-connected {
|
|
background-color: #16a34a;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.icon-mqtt-disconnected {
|
|
background-color: #2563eb;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.icon-offline {
|
|
background-color: #dc2626;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.icon-position-history {
|
|
background-color: #a855f7;
|
|
border-radius: 25px;
|
|
border: 1px solid white;
|
|
}
|
|
|
|
.waypoint-label {
|
|
font-size: 26px;
|
|
background-color: transparent;
|
|
}
|
|
|
|
.link {
|
|
color: #2563eb;
|
|
}
|
|
|
|
.link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.tooltip {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.tooltip .tooltip-text {
|
|
visibility: hidden;
|
|
width: 80px;
|
|
background-color: black;
|
|
color: #fff;
|
|
text-align: center;
|
|
padding: 4px 0;
|
|
border-radius: 6px;
|
|
position: absolute;
|
|
z-index: 10000;
|
|
top: 100%;
|
|
left: 50%;
|
|
margin-top: 8px;
|
|
margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
|
|
}
|
|
|
|
.tooltip .tooltip-text::after {
|
|
content: " ";
|
|
position: absolute;
|
|
bottom: 100%; /* At the top of the tooltip */
|
|
left: 50%;
|
|
margin-left: -5px;
|
|
border-width: 5px;
|
|
border-style: solid;
|
|
border-color: transparent transparent black transparent;
|
|
}
|
|
|
|
.tooltip:hover .tooltip-text {
|
|
visibility: visible;
|
|
}
|
|
|
|
.z-search {
|
|
z-index: 1001;
|
|
}
|
|
|
|
.z-sidebar {
|
|
z-index: 1002;
|
|
}
|
|
|
|
</style>
|
|
|
|
</head>
|
|
<body class="h-full bg-gray-200">
|
|
<div id="app" v-cloak>
|
|
|
|
<div class="flex flex-col h-full w-full overflow-hidden">
|
|
<div class="flex flex-col h-full">
|
|
|
|
<!-- header -->
|
|
<div class="flex p-3 h-16" style="background-color: 30a552;">
|
|
|
|
<!-- close mobile search button -->
|
|
<div v-if="isShowingMobileSearch" class="my-auto">
|
|
<a @click="isShowingMobileSearch = false" href="javascript:void(0)" class="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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- icon -->
|
|
<div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-3">
|
|
<img class="w-10 h-10 rounded" src="icon.png"/>
|
|
</div>
|
|
|
|
<!-- app info -->
|
|
<div v-if="!isShowingMobileSearch" class="my-auto leading-tight">
|
|
<a href="https://sthlm-mesh.se"><div class="font-bold" style="color: #ffffff; font-size: larger;">STHLM-MESH</div></a>
|
|
</div>
|
|
|
|
<!-- search bar -->
|
|
<div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !isShowingMobileSearch }">
|
|
<input v-model="searchText" type="text" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :placeholder="`Search ${nodes.length} nodes...`">
|
|
<div v-if="searchText !== ''" class="absolute z-search bg-white w-full border border-gray-200 rounded-lg shadow-md mt-1 overflow-y-scroll max-h-80 divide-y divide-gray-200">
|
|
|
|
<template v-if="searchedNodes.length > 0">
|
|
<div @click="onSearchResultNodeClick(node)" class="flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ node.short_name }}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-gray-900" :class="{ 'text-red-500': node.latitude == null || node.longitude == null }">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
|
|
<div class="flex space-x-1 text-sm text-gray-700">
|
|
<div>{{ node.node_id_hex }} / {{ node.node_id }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="searchedNodes.length === 500" class="text-gray-500 text-sm px-2 py-1">
|
|
Only the first 500 results are shown.
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="p-2">
|
|
No results found...
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- header action buttons -->
|
|
<div v-if="!isShowingMobileSearch" class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
|
<a @click="isShowingInfoModal = true" href="javascript:void(0)" class="tooltip 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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">About</span>
|
|
</div>
|
|
</a>
|
|
<a @click="isShowingMobileSearch = true" href="javascript:void(0)" class="tooltip rounded-full block lg:hidden">
|
|
<div id="search-button" 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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path>
|
|
<path d="M21 21l-6 -6"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Search</span>
|
|
</div>
|
|
</a>
|
|
<a @click="isShowingSettings = true" href="javascript:void(0)" class="tooltip 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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Settings</span>
|
|
</div>
|
|
</a>
|
|
<a href="#" class="tooltip rounded-full" onclick="reload()">
|
|
<div id="reload-button" 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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
|
|
<path d="M20 4v5h-5"></path>
|
|
</svg>
|
|
</div>
|
|
<div class="hidden sm:block">
|
|
<span class="tooltip-text">Reload</span>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- map -->
|
|
<div id="map" style="width:100%;height:100%;"></div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- info modal -->
|
|
<div class="relative z-sidebar">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="isShowingInfoModal" @click="dismissInfoModal" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- modal -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="translate-y-full"
|
|
enter-to-class="translate-y-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-y-0"
|
|
leave-to-class="translate-y-full">
|
|
<div @click="dismissInfoModal" v-show="isShowingInfoModal" class="fixed left-0 right-0 top-0 bottom-0 lg:pointer-events-none">
|
|
<div class="flex w-full h-full overflow-y-auto p-4">
|
|
<div @click.stop class="mx-auto my-auto w-full max-w-2xl flex-col bg-white shadow-xl rounded-xl p-2 lg:pointer-events-auto">
|
|
<div class="relative flex">
|
|
|
|
<!-- close button -->
|
|
<div class="absolute top-0 right-0">
|
|
<div class="h-7">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissInfoModal">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- content -->
|
|
<div class="flex flex-col w-full py-2 space-y-2">
|
|
|
|
<!-- app info -->
|
|
<div class="w-full mx-auto text-center">
|
|
<img src="./icon.png" class="mx-auto w-16 h-16 rounded mb-1"/>
|
|
<h1 class="font-bold">Meshtastic Map</h1>
|
|
<h2>Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a></h2>
|
|
<h2>Forked by <a class="link" target="_blank" href="http://github.com/Roslund/">Roslund</a></h2>
|
|
</div>
|
|
|
|
<!-- Beskrivning -->
|
|
<div>
|
|
<div class="font-bold mb-2">Beskrivning</div>
|
|
<div class="space-y-2">
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div>Detta är <b>STHLM-MESH</b>'s egen karta, som drivs av våran MQTT Server. Taken är att ha en karta som enbart fokuserar på stockholm. Den är baserad på Liam Cottle's open source projekt Meshtastic Map. Våran fork finner du på Github. MQTT servern vidarebefordrar alla paket till Liam Cottle's Karta.</div>
|
|
</div>
|
|
</div>
|
|
<div class="font-bold mb-2">Beskrivning</div>
|
|
<div class="space-y-2">
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div class="font-semibold">Hur kan jag ansluta min nod till MQTT servern?</div>
|
|
<div>Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla.</div>
|
|
<div>Vill du koppla upp din nod, kontakta @Roslund på Discord.</div>
|
|
</div>
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div class="font-semibold">Inställningar:</div>
|
|
<ul class="list-disc list-inside">
|
|
<li>Address: mqtt.sthlm-mesh.se</li>
|
|
<li>Encryption Enabled: Yes</li>
|
|
<li>JSON Output: No</li>
|
|
<li>TLS Enabled: No</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- legal -->
|
|
<div>
|
|
<div class="font-bold mb-2">Legal</div>
|
|
<div class="bg-gray-100 rounded p-2 border border-gray-200">
|
|
<div>This project is not affiliated with or endorsed by the <a class="link" target="_blank" href="https://meshtastic.org">Meshtastic</a> project.</div>
|
|
<div>The Meshtastic logo is the trademark of Meshtastic LLC.</div>
|
|
<div>Map tiles provided by <a class="link" target="_blank" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- dismiss button -->
|
|
<div class="mx-auto">
|
|
<a href="javascript:void(0)" @click="dismissInfoModal">
|
|
<div class="bg-gray-200 hover:bg-gray-300 px-6 py-2 rounded-md shadow">
|
|
Dismiss
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- hardware models sidebar -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="isShowingHardwareModels" @click="isShowingHardwareModels = !isShowingHardwareModels" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="-translate-x-full"
|
|
enter-to-class="translate-x-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-x-0"
|
|
leave-to-class="-translate-x-full">
|
|
<div v-show="isShowingHardwareModels" class="fixed top-0 left-0 bottom-0">
|
|
<div class="w-screen max-w-md overflow-hidden">
|
|
<div class="flex h-full flex-col bg-white shadow-xl">
|
|
|
|
<!-- slideover header -->
|
|
<div class="p-2 border-b border-gray-200 shadow">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">Meshtastic Devices</h2>
|
|
<h3 class="text-sm">Ordered by most popular</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="isShowingHardwareModels = !isShowingHardwareModels">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- list of hardware models -->
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200 overflow-y-auto">
|
|
<li v-for="hardwareModel of hardwareModelStats">
|
|
<div class="group relative flex items-center">
|
|
<a href="#" class="block flex-1 px-4 py-2">
|
|
<div class="absolute inset-0 group-hover:bg-gray-100" aria-hidden="true"></div>
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<span class="relative inline-block flex-shrink-0 mr-4">
|
|
<img class="h-20 w-20 rounded object-contain" :src="`/images/devices/${hardwareModel.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
|
|
</span>
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">{{ hardwareModel.hardware_model_name }}</p>
|
|
<p class="truncate text-sm text-gray-500">{{ hardwareModel.count }} nodes on the map</p>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- node info sidebar -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="selectedNode != null" @click="selectedNode = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="-translate-x-full"
|
|
enter-to-class="translate-x-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-x-0"
|
|
leave-to-class="-translate-x-full">
|
|
<div v-show="selectedNode != null" class="fixed top-0 left-0 bottom-0">
|
|
<div v-if="selectedNode != null" class="w-screen max-w-md overflow-hidden">
|
|
<div class="flex h-full flex-col bg-white shadow-xl">
|
|
|
|
<!-- slideover header -->
|
|
<div class="p-2 border-b border-gray-200 shadow">
|
|
<div class="flex">
|
|
<div class="my-auto mr-2">
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ selectedNode.short_name }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="my-auto mr-auto">
|
|
<h2 class="font-bold">Node Info</h2>
|
|
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-2 flex h-7 items-center space-x-2">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="copyShareLinkForNode(selectedNode.node_id)">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
|
|
<path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
<a href="javascript:void(0)" class="rounded-full" @click="selectedNode = null">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto">
|
|
|
|
<!-- no position banner -->
|
|
<div v-if="findNodeMarkerById(selectedNode.node_id) == null" class="flex bg-orange-500 text-white p-2">
|
|
<div class="my-auto mr-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
|
<path fill-rule="evenodd" d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div class="my-auto">This node has not reported a position.</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col my-2">
|
|
<div class="mx-auto">
|
|
<img class="h-48 w-48 rounded object-contain" :src="`/images/devices/${selectedNode.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- action buttons -->
|
|
<div class="flex space-x-6 p-2 justify-center">
|
|
|
|
<!-- sent messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?from=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent from this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Sent Msgs</div>
|
|
</a>
|
|
|
|
<!-- received messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?to=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent to this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m9 12.75 3 3m0 0 3-3m-3 3v-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Received Msgs</div>
|
|
</a>
|
|
|
|
<!-- gated messages -->
|
|
<a target="_blank" :href="`/api/v1/text-messages/embed?gateway_id=${selectedNode.node_id}`" class="flex flex-col" title="Messages gated to MQTT by this Node">
|
|
<div class="flex mx-auto mb-1">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="mx-auto text-sm font-medium text-gray-900">Gated Msgs</div>
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<!-- details -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Details</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- id -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">ID</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id }}</div>
|
|
</li>
|
|
|
|
<!-- hex id -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Hex ID</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id_hex }}</div>
|
|
</li>
|
|
|
|
<!-- role -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Role</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.role_name }}</div>
|
|
</li>
|
|
|
|
<!-- hardware -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Hardware</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</div>
|
|
</li>
|
|
|
|
<!-- firmware version -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Firmware</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.firmware_version">{{ selectedNode.firmware_version }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- lora config -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">LoRa Config</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- region -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Region</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.region_name">{{ selectedNode.region_name }} ({{ getRegionFrequencyRange(selectedNode.region_name) }})</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- modem preset -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Modem Preset</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.modem_preset_name">{{ selectedNode.modem_preset_name }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- has default channel -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Has Default Channel</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.has_default_channel != null">{{ selectedNode.has_default_channel ? "Yes" : "No" }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- position -->
|
|
<div>
|
|
<div @click.stop class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Position</div>
|
|
<div class="ml-auto">
|
|
<button @click="showNodePositionHistory(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
|
Show History
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- position -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Lat/Long</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.latitude && selectedNode.longitude">{{ selectedNode.latitude }}, {{ selectedNode.longitude }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- altitude -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Altitude</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.altitude">{{ selectedNode.altitude }}m</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- device metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Device Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="deviceMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- device metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="deviceMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Battery Level</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel Utilization</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Air Util TX</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- battery level -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Battery Level</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.battery_level">
|
|
<span v-if="selectedNode.battery_level > 100">Plugged In</span>
|
|
<span v-else>{{ selectedNode.battery_level }}%</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- voltage -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Voltage</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel utilization -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel Utilization</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- air util tx -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Air Util Tx</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- air util tx -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Uptime</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.uptime_seconds">{{ formatUptimeSeconds(selectedNode.uptime_seconds) }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- environment metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Environment Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="environmentMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- environment metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="environmentMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Temperature</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Humidity</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Pressure</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- temperature -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Temperature</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.temperature">{{ formatTemperature(selectedNode.temperature) }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- relative humidity -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Relative Humidity</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.relative_humidity">{{ Number(selectedNode.relative_humidity).toFixed(0) }}%</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- barometric pressure -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Barometric Pressure</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.barometric_pressure">{{ Number(selectedNode.barometric_pressure).toFixed(1) }}hPa</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- power metrics -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Power Metrics</div>
|
|
<div class="my-auto ml-auto">
|
|
<select v-model="powerMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
|
|
<option value="1d">1 Day</option>
|
|
<option value="3d">3 Days</option>
|
|
<option value="7d">7 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- power metrics chart -->
|
|
<li>
|
|
<div class="px-4 py-2">
|
|
<div class="w-full">
|
|
<canvas id="powerMetricsChart" style="height:150px;"></canvas>
|
|
<div class="flex">
|
|
<div class="mx-auto flex space-x-2">
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 1</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 2</div>
|
|
</div>
|
|
<div class="flex mx-auto">
|
|
<div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
|
|
<div class="my-auto ml-1 text-sm text-gray-500">Channel 3</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 1 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 1</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch1_voltage">{{ Number(selectedNodeLatestPowerMetric.ch1_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch1_current"> / {{ Number(selectedNodeLatestPowerMetric.ch1_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 2 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 2</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch2_voltage">{{ Number(selectedNodeLatestPowerMetric.ch2_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch2_current"> / {{ Number(selectedNodeLatestPowerMetric.ch2_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- channel 3 -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Channel 3</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNodeLatestPowerMetric">
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch3_voltage">{{ Number(selectedNodeLatestPowerMetric.ch3_voltage).toFixed(2) }}V</span>
|
|
<span v-else>???</span>
|
|
<span v-if="selectedNodeLatestPowerMetric?.ch3_current"> / {{ Number(selectedNodeLatestPowerMetric.ch3_current).toFixed(2) }}mA</span>
|
|
</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- mqtt -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2">
|
|
<div class="font-semibold">MQTT</div>
|
|
<div class="text-sm text-gray-600">Topics this node sent packets to</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
<template v-if="selectedNodeMqttMetrics.length > 0">
|
|
<li v-for="mqttMetric of selectedNodeMqttMetrics">
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<p class="truncate text-sm font-medium text-gray-900">{{ mqttMetric.mqtt_topic }}</p>
|
|
<div class="text-sm text-gray-700">Last packet {{ moment(new Date(mqttMetric.last_packet_at)).fromNow() }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
<template v-else>
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<div class="text-sm text-gray-700">No packets seen on MQTT</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- traceroutes -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2">
|
|
<div class="font-semibold">Trace Routes</div>
|
|
<div class="text-sm text-gray-600">Only 5 most recent are shown</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
<template v-if="selectedNodeTraceroutes.length > 0">
|
|
<li @click="showTraceRoute(traceroute)" v-for="traceroute of selectedNodeTraceroutes">
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div>
|
|
<p class="text-sm text-gray-900"><span class="font-medium">{{ findNodeById(traceroute.to)?.long_name || '???' }}</span> to <span class="font-medium">{{ findNodeById(traceroute.from)?.long_name || '???' }}</span></p>
|
|
<div class="text-sm text-gray-700">{{ moment(new Date(traceroute.updated_at)).fromNow() }} - {{ traceroute.route.length }} hops {{ traceroute.channel_id ? `on ${traceroute.channel_id}` : '' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
<template v-else>
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 px-4 py-2">
|
|
<div class="relative flex min-w-0 flex-1 items-center">
|
|
<div class="truncate">
|
|
<div class="text-sm text-gray-700">No traceroutes seen on MQTT</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- other -->
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Other</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<!-- first seen -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">First Seen</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</div>
|
|
</li>
|
|
|
|
<!-- last seen -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Last Seen</div>
|
|
<div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</div>
|
|
</li>
|
|
|
|
<!-- neighbours updated -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Neighbours Updated</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.neighbours_updated_at">{{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- position updated -->
|
|
<li class="flex p-3">
|
|
<div class="text-sm font-medium text-gray-900">Position Updated</div>
|
|
<div class="ml-auto text-sm text-gray-700">
|
|
<span v-if="selectedNode.position_updated_at">{{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}</span>
|
|
<span v-else>???</span>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- share -->
|
|
<div>
|
|
<div class="flex bg-gray-200 p-2 font-semibold">
|
|
<div class="my-auto">Share Link</div>
|
|
<div class="ml-auto">
|
|
<button @click="copyShareLinkForNode(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
|
Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ul role="list" class="flex-1 divide-y divide-gray-200">
|
|
|
|
<li>
|
|
<div class="relative flex items-center">
|
|
<div class="block flex-1 p-2">
|
|
<div class="flex space-x-2">
|
|
<input type="text" readonly class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :value="getShareLinkForNode(selectedNode.node_id)">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- traceroute info sidebar -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="selectedTraceRoute != null" @click="selectedTraceRoute = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="-translate-x-full"
|
|
enter-to-class="translate-x-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-x-0"
|
|
leave-to-class="-translate-x-full">
|
|
<div v-show="selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0">
|
|
<div v-if="selectedTraceRoute != null" class="w-screen max-w-md overflow-hidden">
|
|
<div class="flex h-full flex-col bg-white shadow-xl">
|
|
|
|
<!-- slideover header -->
|
|
<div class="p-2 border-b border-gray-200 shadow">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">Traceroute #{{ selectedTraceRoute.id }}</h2>
|
|
<h3 class="text-sm">{{ moment(new Date(selectedTraceRoute.updated_at)).fromNow() }} - {{ selectedTraceRoute.route.length }} hops {{ selectedTraceRoute.channel_id ? `on ${selectedTraceRoute.channel_id}` : '' }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="selectedTraceRoute = null">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto">
|
|
|
|
<!-- details -->
|
|
<div class="p-2">
|
|
<ul role="list" class="space-y-3">
|
|
|
|
<!-- node that initiated traceroute -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.to)}]`, `text-[${getNodeTextColour(selectedTraceRoute.to)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.to)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.to)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(selectedTraceRoute.to).toString(16) }}</div>
|
|
<div>Started the traceroute</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- middleman nodes -->
|
|
<li :onclick="`goToNode(${route})`" v-for="route of selectedTraceRoute.route" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(route)}]`, `text-[${getNodeTextColour(route)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(route)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(route).toString(16) }}</div>
|
|
<div>Forwarded the packet</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- node that replied to traceroute -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.from})`" v-if="selectedTraceRoute.from" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.from)}]`, `text-[${getNodeTextColour(selectedTraceRoute.from)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.from)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.from)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(selectedTraceRoute.from).toString(16) }}</div>
|
|
<div>Replied to traceroute</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- node that gated traceroute to mqtt -->
|
|
<li :onclick="`goToNode(${selectedTraceRoute.gateway_id})`" v-if="selectedTraceRoute.gateway_id" class="relative flex gap-x-4">
|
|
<div class="absolute left-0 top-0 flex w-12 justify-center h-6">
|
|
<div class="w-px bg-gray-200"></div>
|
|
</div>
|
|
<div class="my-auto relative flex flex-none items-center justify-center">
|
|
<div>
|
|
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColour(selectedTraceRoute.gateway_id)}]` ]">
|
|
<div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
|
|
<div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.gateway_id)?.long_name || '???' }}</div>
|
|
<div>Hex ID: !{{ Number(selectedTraceRoute.gateway_id).toString(16) }}</div>
|
|
<div>Gated the packet to MQTT</div>
|
|
</div>
|
|
</li>
|
|
|
|
</ul>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
<div class="bg-gray-200 p-2 font-semibold">Raw Data</div>
|
|
<div class="text-sm text-gray-700">
|
|
<pre class="bg-gray-100 rounded p-2 overflow-x-auto">{{ JSON.stringify(selectedTraceRoute, null, 4) }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- settings sidebar -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- overlay -->
|
|
<transition
|
|
enter-active-class="transition-opacity duration-300 ease-linear"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-300 ease-linear"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0">
|
|
<div v-show="isShowingSettings" @click="isShowingSettings = !isShowingSettings" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
|
|
</transition>
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="-translate-x-full"
|
|
enter-to-class="translate-x-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-x-0"
|
|
leave-to-class="-translate-x-full">
|
|
<div v-show="isShowingSettings" class="fixed top-0 left-0 bottom-0">
|
|
<div v-if="isShowingSettings" class="w-screen max-w-md overflow-hidden">
|
|
<div class="flex h-full flex-col bg-white shadow-xl">
|
|
|
|
<!-- slideover header -->
|
|
<div class="p-2 border-b border-gray-200 shadow">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">Settings</h2>
|
|
<h3 class="text-sm">Changes are only saved in this browser.</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="isShowingSettings = false">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto divide-y divide-gray-200">
|
|
|
|
<!-- configNodesMaxAgeInSeconds -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Nodes Max Age</label>
|
|
<div class="text-xs text-gray-600 mb-2">Nodes not updated within this time are hidden. Reload to update map.</div>
|
|
<select v-model="configNodesMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option :value="null">Show All</option>
|
|
<option value="900">15 minutes</option>
|
|
<option value="1800">30 minutes</option>
|
|
<option value="3600">1 hour</option>
|
|
<option value="10800">3 hours</option>
|
|
<option value="21600">6 hours</option>
|
|
<option value="43200">12 hours</option>
|
|
<option value="86400">24 hours</option>
|
|
<option value="172800">2 days</option>
|
|
<option value="259200">3 days</option>
|
|
<option value="345600">4 days</option>
|
|
<option value="432000">5 days</option>
|
|
<option value="518400">6 days</option>
|
|
<option value="604800">7 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configNodesDisconnectedAgeInSeconds -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Nodes Disconnected Age</label>
|
|
<div class="text-xs text-gray-600 mb-2">Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.</div>
|
|
<select v-model="configNodesDisconnectedAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option value="900">15 minutes</option>
|
|
<option value="1800">30 minutes</option>
|
|
<option value="2700">45 minutes</option>
|
|
<option value="3600">1 hour</option>
|
|
<option value="7200">2 hours</option>
|
|
<option value="10800">3 hours</option>
|
|
<option value="21600">6 hours</option>
|
|
<option value="43200">12 hours</option>
|
|
<option value="86400">24 hours</option>
|
|
<option value="172800">2 days</option>
|
|
<option value="259200">3 days</option>
|
|
<option value="345600">4 days</option>
|
|
<option value="432000">5 days</option>
|
|
<option value="518400">6 days</option>
|
|
<option value="604800">7 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configNodesOfflineAgeInSeconds -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Nodes Offline Age</label>
|
|
<div class="text-xs text-gray-600 mb-2">Nodes not updated within this time will show as red icons. Reload to update map.</div>
|
|
<select v-model="configNodesOfflineAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option :value="null">Don't show as offline</option>
|
|
<option value="900">15 minutes</option>
|
|
<option value="1800">30 minutes</option>
|
|
<option value="2700">45 minutes</option>
|
|
<option value="3600">1 hour</option>
|
|
<option value="7200">2 hours</option>
|
|
<option value="10800">3 hours</option>
|
|
<option value="21600">6 hours</option>
|
|
<option value="43200">12 hours</option>
|
|
<option value="86400">24 hours</option>
|
|
<option value="172800">2 days</option>
|
|
<option value="259200">3 days</option>
|
|
<option value="345600">4 days</option>
|
|
<option value="432000">5 days</option>
|
|
<option value="518400">6 days</option>
|
|
<option value="604800">7 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configWaypointsMaxAgeInSeconds -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Waypoints Max Age</label>
|
|
<div class="text-xs text-gray-600 mb-2">Waypoints not updated within this time are hidden. Reload to update map.</div>
|
|
<select v-model="configWaypointsMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option :value="null">Show All</option>
|
|
<option value="900">15 minutes</option>
|
|
<option value="1800">30 minutes</option>
|
|
<option value="3600">1 hour</option>
|
|
<option value="10800">3 hours</option>
|
|
<option value="21600">6 hours</option>
|
|
<option value="43200">12 hours</option>
|
|
<option value="86400">24 hours</option>
|
|
<option value="172800">2 days</option>
|
|
<option value="259200">3 days</option>
|
|
<option value="345600">4 days</option>
|
|
<option value="432000">5 days</option>
|
|
<option value="518400">6 days</option>
|
|
<option value="604800">7 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configNeighboursMaxDistanceInMeters -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
|
|
<div class="text-xs text-gray-600 mb-2">Neighbours further than this are hidden. Reload to update map.</div>
|
|
<input type="number" v-model="configNeighboursMaxDistanceInMeters" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
</div>
|
|
|
|
<!-- configZoomLevelGoToNode -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Zoom Level (go to node)</label>
|
|
<div class="text-xs text-gray-600 mb-2">How far to zoom map when navigating to a node.</div>
|
|
<input type="number" v-model="configZoomLevelGoToNode" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
</div>
|
|
|
|
<!-- configTemperatureFormat -->
|
|
<div class="p-2">
|
|
<label class="block text-sm font-medium text-gray-900">Temperature Format</label>
|
|
<div class="text-xs text-gray-600 mb-2">Metrics will be shown in the selected format.</div>
|
|
<select v-model="configTemperatureFormat" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
|
<option value="celsius">Celsius (ºC)</option>
|
|
<option value="fahrenheit">Fahrenheit (ºF)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- configAutoUpdatePositionInUrl -->
|
|
<div class="p-2">
|
|
<div class="flex items-start">
|
|
<div class="flex items-center h-5">
|
|
<input type="checkbox" v-model="configAutoUpdatePositionInUrl" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
|
|
</div>
|
|
<label class="ml-2 text-sm font-medium text-gray-900">Auto Update Position in URL</label>
|
|
</div>
|
|
<div class="text-xs text-gray-600">Sets lat/lng/zoom as query parameters.</div>
|
|
</div>
|
|
|
|
<!-- configEnableMapAnimations -->
|
|
<div class="p-2">
|
|
<div class="flex items-start">
|
|
<div class="flex items-center h-5">
|
|
<input type="checkbox" v-model="configEnableMapAnimations" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
|
|
</div>
|
|
<label class="ml-2 text-sm font-medium text-gray-900">Enable Map Animations</label>
|
|
</div>
|
|
<div class="text-xs text-gray-600">Map will animate flying to nodes.</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- node neighbours modal -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="translate-y-full"
|
|
enter-to-class="translate-y-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-y-0"
|
|
leave-to-class="translate-y-full">
|
|
<div v-show="selectedNodeToShowNeighbours != null" class="fixed left-0 right-0 bottom-0">
|
|
<div v-if="selectedNodeToShowNeighbours != 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="p-2">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h2 class="font-bold">{{ selectedNodeToShowNeighbours.short_name }} Neighbours</h2>
|
|
<h3 v-if="selectedNodeToShowNeighboursType === 'we_heard'" class="text-sm">Nodes heard by {{ selectedNodeToShowNeighbours.short_name }}</h3>
|
|
<h3 v-if="selectedNodeToShowNeighboursType === 'heard_us'" class="text-sm">Nodes that heard {{ selectedNodeToShowNeighbours.short_name }}</h3>
|
|
</div>
|
|
<div class="my-auto ml-3 flex h-7 items-center">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeNeighbours">
|
|
<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">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
<!-- node position history modal -->
|
|
<div class="relative z-sidebar" role="dialog" aria-modal="true">
|
|
|
|
<!-- sidebar -->
|
|
<transition
|
|
enter-active-class="transition duration-300 ease-in-out transform"
|
|
enter-from-class="translate-y-full"
|
|
enter-to-class="translate-y-0"
|
|
leave-active-class="transition duration-300 ease-in-out transform"
|
|
leave-from-class="translate-y-0"
|
|
leave-to-class="translate-y-full">
|
|
<div v-show="selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0">
|
|
<div v-if="selectedNodeToShowPositionHistory != 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>
|
|
<div class="flex p-2">
|
|
<div class="flex my-auto mr-3 space-x-2">
|
|
<a href="javascript:void(0)" @click="isPositionHistoryModalExpanded = !isPositionHistoryModalExpanded" class="rounded-full">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
|
<svg v-if="isPositionHistoryModalExpanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div class="my-auto mr-auto font-bold">{{ selectedNodeToShowPositionHistory.short_name }} Position History</div>
|
|
<div class="flex my-auto ml-3 space-x-2">
|
|
<a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory">
|
|
<div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
|
<svg class="size-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 d="M18 6l-12 12"></path>
|
|
<path d="M6 6l12 12"></path>
|
|
</svg>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div v-if="isPositionHistoryModalExpanded" class="divide-y border-t">
|
|
|
|
<!-- quick range -->
|
|
<div class="flex p-2 space-x-2">
|
|
<button @click="onPositionHistoryQuickRangeClick('1h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">1 Hour</button>
|
|
<button @click="onPositionHistoryQuickRangeClick('24h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">24 Hours</button>
|
|
<button @click="onPositionHistoryQuickRangeClick('7d')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">7 Days</button>
|
|
</div>
|
|
|
|
<!-- manual range -->
|
|
<div class="p-2 space-y-1">
|
|
|
|
<!-- from -->
|
|
<div class="flex items-center">
|
|
<label class="text-sm pr-1 min-w-12 text-right">From:</label>
|
|
<input v-model="positionHistoryDateTimeFrom" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
|
|
</div>
|
|
|
|
<!-- to -->
|
|
<div class="flex items-center">
|
|
<label class="text-sm pr-1 min-w-12 text-right">To:</label>
|
|
<input v-model="positionHistoryDateTimeTo" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
|
|
function getConfigHasSeenInfoModal() {
|
|
return localStorage.getItem("config_has_seen_info_modal") === "true";
|
|
}
|
|
|
|
function setConfigHasSeenInfoModal(value) {
|
|
return localStorage.setItem("config_has_seen_info_modal", value);
|
|
}
|
|
|
|
function getConfigAutoUpdatePositionInUrl() {
|
|
// use user preference, or enable by default
|
|
const value = localStorage.getItem("config_auto_update_position_in_url");
|
|
return value === "true" || value == null;
|
|
}
|
|
|
|
function setConfigAutoUpdatePositionInUrl(value) {
|
|
return localStorage.setItem("config_auto_update_position_in_url", value);
|
|
}
|
|
|
|
function getConfigEnableMapAnimations() {
|
|
|
|
const value = localStorage.getItem("config_enable_map_animations");
|
|
|
|
// enable animations by default
|
|
if(value === null){
|
|
return true;
|
|
}
|
|
|
|
return value === "true";
|
|
|
|
}
|
|
|
|
function setConfigEnableMapAnimations(value) {
|
|
return localStorage.setItem("config_enable_map_animations", value);
|
|
}
|
|
|
|
function getConfigTemperatureFormat() {
|
|
return localStorage.getItem("config_temperature_format") || "celsius";
|
|
}
|
|
|
|
function setConfigTemperatureFormat(format) {
|
|
return localStorage.setItem("config_temperature_format", format);
|
|
}
|
|
|
|
function getConfigMapSelectedTileLayer() {
|
|
return localStorage.getItem("config_map_selected_tile_layer") || "OpenStreetMap";
|
|
}
|
|
|
|
function setConfigMapSelectedTileLayer(layer) {
|
|
return localStorage.setItem("config_map_selected_tile_layer", layer);
|
|
}
|
|
|
|
function getConfigMapEnabledOverlayLayers() {
|
|
|
|
try {
|
|
const value = localStorage.getItem("config_map_enabled_overlay_layers");
|
|
if(value){
|
|
return JSON.parse(value);
|
|
}
|
|
} catch(e) {}
|
|
|
|
// overlays enabled by default
|
|
return ["Legend", "Position History"];
|
|
|
|
}
|
|
|
|
function setConfigMapEnabledOverlayLayers(layers) {
|
|
return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
|
|
}
|
|
|
|
function getConfigNodesMaxAgeInSeconds() {
|
|
const value = localStorage.getItem("config_nodes_max_age_in_seconds");
|
|
return value != null ? parseInt(value) : null;
|
|
}
|
|
|
|
function setConfigNodesMaxAgeInSeconds(value) {
|
|
if(value != null){
|
|
return localStorage.setItem("config_nodes_max_age_in_seconds", value);
|
|
} else {
|
|
return localStorage.removeItem("config_nodes_max_age_in_seconds");
|
|
}
|
|
}
|
|
|
|
function getConfigNodesDisconnectedAgeInSeconds() {
|
|
// default to showing nodes as recently uplinked if heard in the last 30 minutes
|
|
const value = localStorage.getItem("config_nodes_disconnected_age_in_seconds");
|
|
return value != null ? parseInt(value) : 1800;
|
|
}
|
|
|
|
function setConfigNodesDisconnectedAgeInSeconds(value) {
|
|
if(value != null){
|
|
return localStorage.setItem("config_nodes_disconnected_age_in_seconds", value);
|
|
} else {
|
|
return localStorage.removeItem("config_nodes_disconnected_age_in_seconds");
|
|
}
|
|
}
|
|
|
|
function getConfigNodesOfflineAgeInSeconds() {
|
|
const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
|
|
return value != null ? parseInt(value) : null;
|
|
}
|
|
|
|
function setConfigNodesOfflineAgeInSeconds(value) {
|
|
if(value != null){
|
|
return localStorage.setItem("config_nodes_offline_age_in_seconds", value);
|
|
} else {
|
|
return localStorage.removeItem("config_nodes_offline_age_in_seconds");
|
|
}
|
|
}
|
|
|
|
function getConfigWaypointsMaxAgeInSeconds() {
|
|
const value = localStorage.getItem("config_waypoints_max_age_in_seconds");
|
|
return value != null ? parseInt(value) : null;
|
|
}
|
|
|
|
function setConfigWaypointsMaxAgeInSeconds(value) {
|
|
if(value != null){
|
|
return localStorage.setItem("config_waypoints_max_age_in_seconds", value);
|
|
} else {
|
|
return localStorage.removeItem("config_waypoints_max_age_in_seconds");
|
|
}
|
|
}
|
|
|
|
function getConfigNeighboursMaxDistanceInMeters() {
|
|
const value = localStorage.getItem("config_neighbours_max_distance_in_meters");
|
|
return value != null ? parseInt(value) : null;
|
|
}
|
|
|
|
function setConfigNeighboursMaxDistanceInMeters(value) {
|
|
return localStorage.setItem("config_neighbours_max_distance_in_meters", value);
|
|
}
|
|
|
|
function getConfigZoomLevelGoToNode() {
|
|
const value = localStorage.getItem("config_zoom_level_go_to_node");
|
|
const parsedValue = value != null ? parseInt(value) : null;
|
|
return parsedValue || 15;
|
|
}
|
|
|
|
function setConfigZoomLevelGoToNode(value) {
|
|
return localStorage.setItem("config_zoom_level_go_to_node", value);
|
|
}
|
|
|
|
function isMobile() {
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
}
|
|
|
|
</script>
|
|
|
|
<script>
|
|
Vue.createApp({
|
|
data() {
|
|
return {
|
|
|
|
isShowingAnnouncement: this.shouldShowAnnouncement(),
|
|
|
|
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
|
|
configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
|
|
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
|
|
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
|
|
configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
|
|
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
|
|
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
|
|
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
|
|
configTemperatureFormat: window.getConfigTemperatureFormat(),
|
|
|
|
isShowingHardwareModels: false,
|
|
hardwareModelStats: null,
|
|
|
|
isShowingInfoModal: this.shouldShowInfoModal(),
|
|
isShowingMobileSearch: false,
|
|
isShowingSettings: false,
|
|
|
|
nodes: [],
|
|
searchText: "",
|
|
|
|
selectedNode: null,
|
|
selectedNodeDeviceMetrics: [],
|
|
selectedNodeEnvironmentMetrics: [],
|
|
selectedNodePowerMetrics: [],
|
|
selectedNodeMqttMetrics: [],
|
|
selectedNodeTraceroutes: [],
|
|
|
|
deviceMetricsTimeRange: "3d",
|
|
environmentMetricsTimeRange: "3d",
|
|
powerMetricsTimeRange: "3d",
|
|
|
|
isPositionHistoryModalExpanded: true,
|
|
positionHistoryDateTimeFrom: null,
|
|
positionHistoryDateTimeTo: null,
|
|
selectedNodePositionHistory: [],
|
|
selectedNodeToShowPositionHistory: null,
|
|
selectedNodePositionHistoryMarkers: [],
|
|
selectedNodePositionHistoryPolyLines: [],
|
|
|
|
selectedTraceRoute: null,
|
|
|
|
selectedNodeToShowNeighbours: null,
|
|
selectedNodeToShowNeighboursType: null,
|
|
|
|
moment: window.moment,
|
|
|
|
};
|
|
},
|
|
mounted: function() {
|
|
|
|
// load data
|
|
this.loadHardwareModelStats();
|
|
|
|
// handle map click callback from outside of vue
|
|
window._onMapClick = () => {
|
|
this.searchText = "";
|
|
this.isShowingMobileSearch = false;
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onNodeClick = (node) => {
|
|
this.selectedNode = node;
|
|
this.loadNodeDeviceMetrics(node.node_id);
|
|
this.loadNodeEnvironmentMetrics(node.node_id);
|
|
this.loadNodePowerMetrics(node.node_id);
|
|
this.loadNodeMqttMetrics(node.node_id);
|
|
this.loadNodeTraceroutes(node.node_id);
|
|
//this.loadNodePositionHistory(node.node_id);
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onShowNodeNeighboursWeHeardClick = (node) => {
|
|
this.selectedNodeToShowNeighbours = node;
|
|
this.selectedNodeToShowNeighboursType = 'we_heard';
|
|
};
|
|
|
|
// handle node callback from outside of vue
|
|
window._onShowNodeNeighboursHeardUsClick = (node) => {
|
|
this.selectedNodeToShowNeighbours = node;
|
|
this.selectedNodeToShowNeighboursType = 'heard_us';
|
|
};
|
|
|
|
// handle nodes updated callback from outside of vue
|
|
window._onNodesUpdated = (nodes) => {
|
|
this.nodes = nodes;
|
|
};
|
|
|
|
},
|
|
methods: {
|
|
getAnnouncementId: function() {
|
|
// change this when making a new announcement
|
|
return "1";
|
|
},
|
|
shouldShowAnnouncement: function() {
|
|
const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
|
|
return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
|
|
},
|
|
dismissAnnouncement: function() {
|
|
window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
|
|
this.isShowingAnnouncement = false;
|
|
},
|
|
shouldShowInfoModal: function() {
|
|
return !window.getConfigHasSeenInfoModal()
|
|
&& !window.isMobile();
|
|
},
|
|
loadHardwareModelStats: function() {
|
|
window.axios.get('/api/v1/stats/hardware-models').then((response) => {
|
|
this.hardwareModelStats = response.data.hardware_model_stats;
|
|
}).catch((error) => {
|
|
// do nothing
|
|
});
|
|
},
|
|
loadNodeDeviceMetrics: function(nodeId) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
|
|
// determine how long back to load device metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.deviceMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/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
|
|
this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
|
|
this.renderDeviceMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodeDeviceMetrics = [];
|
|
this.renderDeviceMetricCharts();
|
|
});
|
|
},
|
|
loadNodeEnvironmentMetrics: function(nodeId) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
|
|
// determine how long back to load environment metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.environmentMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/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
|
|
this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse();
|
|
this.renderEnvironmentMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodeEnvironmentMetrics = [];
|
|
this.renderEnvironmentMetricCharts();
|
|
});
|
|
},
|
|
loadNodePowerMetrics: function(nodeId) {
|
|
|
|
// calculate unix timestamps in milliseconds for supported time ranges
|
|
const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
|
|
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
|
|
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
|
|
|
|
// determine how long back to load power metrics from
|
|
var timeFrom = threeDaysAgoInMilliseconds;
|
|
switch(this.powerMetricsTimeRange){
|
|
case "1d": {
|
|
timeFrom = oneDayAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "3d": {
|
|
timeFrom = threeDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
case "7d": {
|
|
timeFrom = sevenDaysAgoInMilliseconds;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.axios.get(`/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
|
|
this.selectedNodePowerMetrics = response.data.power_metrics.reverse();
|
|
this.renderPowerMetricCharts();
|
|
}).catch(() => {
|
|
this.selectedNodePowerMetrics = [];
|
|
this.renderPowerMetricCharts();
|
|
});
|
|
},
|
|
loadNodeMqttMetrics: function(nodeId) {
|
|
this.selectedNodeMqttMetrics = [];
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => {
|
|
this.selectedNodeMqttMetrics = response.data.mqtt_metrics;
|
|
}).catch(() => {
|
|
// do nothing
|
|
});
|
|
},
|
|
loadNodeTraceroutes: function(nodeId) {
|
|
this.selectedNodeTraceroutes = [];
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, {
|
|
params: {
|
|
count: 5,
|
|
},
|
|
}).then((response) => {
|
|
this.selectedNodeTraceroutes = response.data.traceroutes;
|
|
}).catch(() => {
|
|
// do nothing
|
|
});
|
|
},
|
|
loadNodePositionHistory: function(nodeId) {
|
|
this.selectedNodePositionHistory = [];
|
|
window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
|
|
params: {
|
|
// parse from datetime-local format, and send as unix timestamp in milliseconds
|
|
time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
|
|
time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
|
|
},
|
|
}).then((response) => {
|
|
this.selectedNodePositionHistory = response.data.position_history;
|
|
if(this.selectedNodeToShowPositionHistory != null){
|
|
clearAllPositionHistory();
|
|
onPositionHistoryUpdated(response.data.position_history);
|
|
}
|
|
|
|
}).catch(() => {
|
|
// do nothing
|
|
});
|
|
},
|
|
renderDeviceMetricCharts: function() {
|
|
try {
|
|
this.updateDeviceMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updateDeviceMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "deviceMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const batteryMetrics = [];
|
|
const channelUtilizationMetrics = [];
|
|
const airUtilTxMetrics = [];
|
|
for(const deviceMetric of this.selectedNodeDeviceMetrics){
|
|
labels.push(moment(deviceMetric.created_at));
|
|
batteryMetrics.push(deviceMetric.battery_level);
|
|
channelUtilizationMetrics.push(deviceMetric.channel_utilization);
|
|
airUtilTxMetrics.push(deviceMetric.air_util_tx);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Battery Level',
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: batteryMetrics,
|
|
},
|
|
{
|
|
label: 'Channel Util',
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
showLine: false, // no lines between points
|
|
fill: false,
|
|
data: channelUtilizationMetrics,
|
|
},
|
|
{
|
|
label: 'Air Util TX',
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
showLine: false, // no lines between points
|
|
fill: false,
|
|
data: airUtilTxMetrics,
|
|
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: 0,
|
|
max: 101, // 101 is "Plugged In", need to include for tooltip to work
|
|
ticks: {
|
|
callback: (label) => `${label}%`,
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}%`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
renderEnvironmentMetricCharts: function() {
|
|
try {
|
|
this.updateEnvironmentMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updateEnvironmentMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "environmentMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const temperatureMetrics = [];
|
|
const relativeHumidityMetrics = [];
|
|
const barometricPressureMetrics = [];
|
|
for(const deviceMetric of this.selectedNodeEnvironmentMetrics){
|
|
labels.push(moment(deviceMetric.created_at));
|
|
temperatureMetrics.push(deviceMetric.temperature);
|
|
relativeHumidityMetrics.push(deviceMetric.relative_humidity);
|
|
barometricPressureMetrics.push(deviceMetric.barometric_pressure);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Temperature',
|
|
suffix: 'ºC',
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: temperatureMetrics,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Humidity',
|
|
suffix: '%',
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: relativeHumidityMetrics,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Pressure',
|
|
suffix: 'hPa',
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: barometricPressureMetrics,
|
|
yAxisID: 'y1',
|
|
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: -20,
|
|
max: 100,
|
|
},
|
|
y1: {
|
|
min: 800,
|
|
max: 1100,
|
|
ticks: {
|
|
stepSize: 10,
|
|
callback: (label) => `${label} hPa`,
|
|
},
|
|
position: 'right',
|
|
grid: {
|
|
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
renderPowerMetricCharts: function() {
|
|
try {
|
|
this.updatePowerMetricsChart();
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
},
|
|
updatePowerMetricsChart: function() {
|
|
|
|
// destroy existing chart
|
|
const chartElementId = "powerMetricsChart";
|
|
const existingChart = window.Chart.getChart(chartElementId);
|
|
if(existingChart != null){
|
|
existingChart.destroy();
|
|
}
|
|
|
|
// get chart element
|
|
const chartElement = window.document.getElementById(chartElementId);
|
|
if(!chartElement){
|
|
return;
|
|
}
|
|
|
|
// create chart data
|
|
const labels = [];
|
|
const channel1VoltageReadings = [];
|
|
const channel2VoltageReadings = [];
|
|
const channel3VoltageReadings = [];
|
|
const channel1CurrentReadings = [];
|
|
const channel2CurrentReadings = [];
|
|
const channel3CurrentReadings = [];
|
|
for(const powerMetric of this.selectedNodePowerMetrics){
|
|
labels.push(moment(powerMetric.created_at));
|
|
channel1VoltageReadings.push(powerMetric.ch1_voltage);
|
|
channel2VoltageReadings.push(powerMetric.ch2_voltage);
|
|
channel3VoltageReadings.push(powerMetric.ch3_voltage);
|
|
channel1CurrentReadings.push(powerMetric.ch1_current);
|
|
channel2CurrentReadings.push(powerMetric.ch2_current);
|
|
channel3CurrentReadings.push(powerMetric.ch3_current);
|
|
}
|
|
|
|
// create chart
|
|
new window.Chart(chartElement, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Ch1 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f6',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel1VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch2 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#22c55e',
|
|
backgroundColor: '#22c55e',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel2VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch3 Voltage',
|
|
suffix: "V",
|
|
borderColor: '#f97316',
|
|
backgroundColor: '#f97316',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel3VoltageReadings,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Ch1 Current',
|
|
suffix: "mA",
|
|
borderColor: '#93c5fd',
|
|
backgroundColor: '#93c5fd',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel1CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
{
|
|
label: 'Ch2 Current',
|
|
suffix: "mA",
|
|
borderColor: '#86efac',
|
|
backgroundColor: '#86efac',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel2CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
{
|
|
label: 'Ch3 Current',
|
|
suffix: "mA",
|
|
borderColor: '#fdba74',
|
|
backgroundColor: '#fdba74',
|
|
pointStyle: false, // no points
|
|
fill: false,
|
|
data: channel3CurrentReadings,
|
|
yAxisID: 'y1',
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
borderWidth: 2,
|
|
spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap
|
|
elements: {
|
|
point: {
|
|
radius: 2,
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
position: 'top',
|
|
type: 'time',
|
|
time: {
|
|
unit: 'day',
|
|
displayFormats: {
|
|
day: 'MMM DD', // Jan 01
|
|
},
|
|
},
|
|
},
|
|
y: {
|
|
min: 0,
|
|
max: 30,
|
|
ticks: {
|
|
callback: (label) => `${label}V`,
|
|
},
|
|
},
|
|
y1: {
|
|
min: -500,
|
|
max: 500,
|
|
ticks: {
|
|
stepSize: 50,
|
|
callback: (label) => `${label}mA`,
|
|
},
|
|
position: 'right',
|
|
grid: {
|
|
drawOnChartArea: false, // only want the grid lines for one axis to show up
|
|
},
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
mode: "index",
|
|
intersect: false,
|
|
callbacks: {
|
|
label: (item) => {
|
|
return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
});
|
|
|
|
},
|
|
showTraceRoute: function(traceroute) {
|
|
this.selectedTraceRoute = traceroute;
|
|
},
|
|
findNodeById: function(id) {
|
|
return window.findNodeById(id);
|
|
},
|
|
findNodeMarkerById: function(id) {
|
|
return window.findNodeMarkerById(id);
|
|
},
|
|
onSearchResultNodeClick: function(node) {
|
|
|
|
// clear search
|
|
this.searchText = "";
|
|
|
|
// hide search
|
|
this.isShowingMobileSearch = false;
|
|
|
|
// go to node
|
|
if(window.goToNode(node.node_id)){
|
|
return;
|
|
}
|
|
|
|
// fallback to showing node details since we can't go to the node
|
|
window.showNodeDetails(node.node_id);
|
|
|
|
},
|
|
dismissInfoModal: function() {
|
|
this.isShowingInfoModal = false;
|
|
window.setConfigHasSeenInfoModal(true);
|
|
},
|
|
getRegionFrequencyRange: function(regionName) {
|
|
return window.getRegionFrequencyRange(regionName);
|
|
},
|
|
showNodePositionHistory: function(nodeId) {
|
|
|
|
// find node
|
|
const node = findNodeById(nodeId);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// update ui
|
|
this.selectedNode = null;
|
|
this.selectedNodeToShowPositionHistory = node;
|
|
this.isPositionHistoryModalExpanded = true;
|
|
|
|
// close node info tooltip as position history shows under it
|
|
window.closeAllTooltips();
|
|
|
|
// reset default time range when opening position history ui
|
|
// YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
|
|
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
|
|
// load position history
|
|
this.loadNodePositionHistory(nodeId);
|
|
|
|
},
|
|
onPositionHistoryQuickRangeClick: function(range) {
|
|
|
|
// update position history time range
|
|
switch(range){
|
|
case "1h": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
case "24h": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
case "7d": {
|
|
this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
|
|
this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// reload position history
|
|
const node = this.selectedNodeToShowPositionHistory;
|
|
if(node){
|
|
this.loadNodePositionHistory(node.node_id);
|
|
}
|
|
|
|
},
|
|
getShareLinkForNode: function(nodeId) {
|
|
return window.location.origin + `/?node_id=${nodeId}`;
|
|
},
|
|
copyShareLinkForNode: function(nodeId) {
|
|
|
|
// make sure copy to clipboard is supported
|
|
if(!navigator.clipboard || !navigator.clipboard.writeText){
|
|
alert("Clipboard not supported. Site must be served via https on iOS.");
|
|
return;
|
|
}
|
|
|
|
// copy share link to clipboard
|
|
const url = this.getShareLinkForNode(nodeId);
|
|
navigator.clipboard.writeText(url);
|
|
|
|
// tell user we copied it
|
|
alert("Link copied to clipboard!");
|
|
|
|
},
|
|
dismissShowingNodeNeighbours: function() {
|
|
window._onHideNodeNeighboursClick();
|
|
this.selectedNodeToShowNeighbours = null;
|
|
},
|
|
dismissShowingNodePositionHistory: function() {
|
|
this.selectedNodePositionHistory = [];
|
|
this.selectedNodeToShowPositionHistory = null;
|
|
this.selectedNodePositionHistoryMarkers = [];
|
|
this.selectedNodePositionHistoryPolyLines = [];
|
|
cleanUpPositionHistory();
|
|
},
|
|
formatUptimeSeconds: function(secondsToFormat) {
|
|
secondsToFormat = Number(secondsToFormat);
|
|
var days = Math.floor(secondsToFormat / (3600 * 24));
|
|
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
|
|
var minutes = Math.floor((secondsToFormat % 3600) / 60);
|
|
var seconds = Math.floor(secondsToFormat % 60);
|
|
var daysPlural = days === 1 ? 'day' : 'days';
|
|
return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
|
|
},
|
|
formatTemperature: function(celsius) {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": {
|
|
return `${Number(celsius).toFixed(0)}ºC`;
|
|
}
|
|
case "fahrenheit": {
|
|
const fahrenheit = this.celsiusToFahrenheit(celsius);
|
|
return `${fahrenheit.toFixed(0)}ºF`;
|
|
}
|
|
}
|
|
},
|
|
convertTemperature: function(celsius) {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": {
|
|
return celsius;
|
|
}
|
|
case "fahrenheit": {
|
|
return this.celsiusToFahrenheit(celsius);
|
|
}
|
|
}
|
|
},
|
|
getTemperatureUnit: function() {
|
|
switch(this.configTemperatureFormat){
|
|
case "celsius": return "ºC";
|
|
case "fahrenheit": return "ºF";
|
|
}
|
|
},
|
|
celsiusToFahrenheit: function(celsius) {
|
|
return (celsius * 9/5) + 32;
|
|
},
|
|
getNodeColour(nodeId) {
|
|
// convert node id to a hex colour
|
|
return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
|
|
},
|
|
getNodeTextColour(nodeId) {
|
|
|
|
// extract rgb components
|
|
const r = (nodeId & 0xFF0000) >> 16;
|
|
const g = (nodeId & 0x00FF00) >> 8;
|
|
const b = nodeId & 0x0000FF;
|
|
|
|
// calculate brightness
|
|
const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;
|
|
|
|
// determine text color based on brightness
|
|
return brightness > 0.5 ? "#000000" : "#FFFFFF";
|
|
|
|
},
|
|
},
|
|
computed: {
|
|
searchedNodes() {
|
|
|
|
// search nodes
|
|
const nodes = this.nodes.filter((node) => {
|
|
const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
|
|
return matchesId || matchesHexId || matchesLongName || matchesShortName;
|
|
});
|
|
|
|
// order alphabetically by long name
|
|
nodes.sort((nodeA, nodeB) => {
|
|
const nodeALongName = nodeA.long_name || "";
|
|
const nodeBLongName = nodeB.long_name || "";
|
|
return nodeALongName.localeCompare(nodeBLongName);
|
|
});
|
|
|
|
// only return the first 500 results to avoid ui lag...
|
|
return nodes.slice(0, 500);
|
|
|
|
},
|
|
selectedNodeLatestPowerMetric() {
|
|
const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
|
|
return latestPowerMetric;
|
|
},
|
|
},
|
|
watch: {
|
|
configNodesMaxAgeInSeconds() {
|
|
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
|
|
},
|
|
configNodesDisconnectedAgeInSeconds() {
|
|
window.setConfigNodesDisconnectedAgeInSeconds(this.configNodesDisconnectedAgeInSeconds);
|
|
},
|
|
configNodesOfflineAgeInSeconds() {
|
|
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
|
|
},
|
|
configWaypointsMaxAgeInSeconds() {
|
|
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
|
|
},
|
|
configNeighboursMaxDistanceInMeters() {
|
|
window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
|
|
},
|
|
configZoomLevelGoToNode() {
|
|
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
|
|
},
|
|
configAutoUpdatePositionInUrl() {
|
|
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
|
|
},
|
|
configEnableMapAnimations() {
|
|
window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
|
|
},
|
|
configTemperatureFormat() {
|
|
window.setConfigTemperatureFormat(this.configTemperatureFormat);
|
|
},
|
|
deviceMetricsTimeRange() {
|
|
this.loadNodeDeviceMetrics(this.selectedNode.node_id);
|
|
},
|
|
environmentMetricsTimeRange() {
|
|
this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
|
|
},
|
|
powerMetricsTimeRange() {
|
|
this.loadNodePowerMetrics(this.selectedNode.node_id);
|
|
},
|
|
},
|
|
}).mount('#app');
|
|
</script>
|
|
|
|
<script>
|
|
|
|
// global state
|
|
var nodes = [];
|
|
var nodeMarkers = {};
|
|
var selectedNodeOutlineCircle = null;
|
|
var waypoints = [];
|
|
|
|
// set map bounds to be a little more than full size to prevent panning off screen
|
|
var bounds = [
|
|
[-100, 70], // top left
|
|
[100, 500], // bottom right
|
|
];
|
|
|
|
// create map positioned over AU and NZ
|
|
var map = L.map('map', {
|
|
maxBounds: bounds,
|
|
}).setView([
|
|
-15,
|
|
150,
|
|
], 2);
|
|
|
|
// remove leaflet link
|
|
map.attributionControl.setPrefix('');
|
|
|
|
var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 22, // increase from 18 to 22
|
|
attribution: 'Tiles © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
|
|
});
|
|
|
|
var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 17, // open topo map doesn't have tiles closer than this
|
|
attribution: 'Tiles © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
});
|
|
|
|
var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
maxZoom: 21, // esri doesn't have tiles closer than this
|
|
attribution: 'Tiles © <a href="https://developers.arcgis.com/documentation/mapping-apis-and-services/deployment/basemap-attribution/">Esri</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>'
|
|
});
|
|
|
|
var googleSatelliteTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', {
|
|
maxZoom: 21,
|
|
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
|
|
attribution: 'Tiles © Google | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>'
|
|
});
|
|
|
|
var googleHybridTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
|
|
maxZoom: 21,
|
|
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
|
|
attribution: 'Tiles © Google | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>'
|
|
});
|
|
|
|
var tileLayers = {
|
|
"OpenStreetMap": openStreetMapTileLayer,
|
|
"OpenTopoMap": openTopoMapTileLayer,
|
|
"Esri Satellite": esriWorldImageryTileLayer,
|
|
"Google Satellite": googleSatelliteTileLayer,
|
|
"Google Hybrid": googleHybridTileLayer,
|
|
};
|
|
|
|
// use tile layer based on config
|
|
const selectedTileLayerName = getConfigMapSelectedTileLayer();
|
|
const selectedTileLayer = tileLayers[selectedTileLayerName] || openStreetMapTileLayer;
|
|
selectedTileLayer.addTo(map);
|
|
|
|
// create layer groups
|
|
var nodesLayerGroup = new L.LayerGroup();
|
|
var neighboursLayerGroup = new L.LayerGroup();
|
|
var nodeNeighboursLayerGroup = new L.LayerGroup();
|
|
var nodesClusteredLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
|
});
|
|
var nodesRouterLayerGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
|
|
});
|
|
var waypointsLayerGroup = new L.LayerGroup();
|
|
var nodePositionHistoryLayerGroup = new L.LayerGroup();
|
|
|
|
// create icons
|
|
var iconMqttConnected = L.divIcon({
|
|
className: 'icon-mqtt-connected',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconMqttDisconnected = L.divIcon({
|
|
className: 'icon-mqtt-disconnected',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconOffline = L.divIcon({
|
|
className: 'icon-offline',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
var iconPositionHistory = L.divIcon({
|
|
className: 'icon-position-history',
|
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
|
});
|
|
|
|
// create legend
|
|
var legendLayerGroup = new L.LayerGroup();
|
|
var legend = L.control({position: 'bottomleft'});
|
|
legend.onAdd = function (map) {
|
|
var div = L.DomUtil.create('div', 'leaflet-control-layers');
|
|
div.style.backgroundColor = 'white';
|
|
div.style.padding = '12px';
|
|
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
|
+ `<div style="display:flex"><div class="icon-mqtt-connected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Connected</div>`
|
|
+ `<div style="display:flex"><div class="icon-mqtt-disconnected" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MQTT Disconnected</div>`
|
|
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`;
|
|
return div;
|
|
};
|
|
|
|
// handle baselayerchange to update tile layer preference
|
|
map.on('baselayerchange', function(event) {
|
|
setConfigMapSelectedTileLayer(event.name);
|
|
});
|
|
|
|
// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
|
|
map.on('overlayadd overlayremove', function(event) {
|
|
if(event.name === "Legend"){
|
|
if(event.type === "overlayadd"){
|
|
map.addControl(legend);
|
|
} else if(event.type === "overlayremove"){
|
|
map.removeControl(legend);
|
|
}
|
|
}
|
|
});
|
|
|
|
// add layers to control ui
|
|
L.control.groupedLayers(tileLayers, {
|
|
"Nodes": {
|
|
"All": nodesLayerGroup,
|
|
"Routers": nodesRouterLayerGroup,
|
|
"Clustered": nodesClusteredLayerGroup,
|
|
"None": new L.LayerGroup(),
|
|
},
|
|
"Overlays": {
|
|
"Legend": legendLayerGroup,
|
|
"Neighbours": neighboursLayerGroup,
|
|
"Waypoints": waypointsLayerGroup,
|
|
"Position History": nodePositionHistoryLayerGroup,
|
|
},
|
|
}, {
|
|
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
|
|
exclusiveGroups: ["Nodes"],
|
|
}).addTo(map);
|
|
|
|
// enable base layers
|
|
nodesClusteredLayerGroup.addTo(map);
|
|
|
|
// enable overlay layers based on config
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(enabledOverlayLayers.includes("Legend")){
|
|
legendLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Neighbours")){
|
|
neighboursLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Waypoints")){
|
|
waypointsLayerGroup.addTo(map);
|
|
}
|
|
if(enabledOverlayLayers.includes("Position History")){
|
|
nodePositionHistoryLayerGroup.addTo(map);
|
|
}
|
|
|
|
// update config when map overlay is added
|
|
map.on('overlayadd', function(event) {
|
|
const layerName = event.name;
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
|
|
if(!enabledOverlayLayers.includes(layerName)){
|
|
enabledOverlayLayers.push(layerName);
|
|
}
|
|
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
|
|
});
|
|
|
|
// update config when map overlay is removed
|
|
map.on('overlayremove', function(event) {
|
|
const layerName = event.name;
|
|
const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) {
|
|
return enabledOverlayLayer !== layerName;
|
|
});
|
|
setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
|
|
});
|
|
|
|
// handle map clicks
|
|
map.on('click', function() {
|
|
|
|
// remove outline when map clicked
|
|
clearNodeOutline();
|
|
|
|
// send callback to vue
|
|
window._onMapClick();
|
|
|
|
});
|
|
|
|
// close all tooltips and popups when clicking map
|
|
map.on("click", function(event) {
|
|
|
|
// do nothing when clicking inside tooltip
|
|
const clickedElement = event.originalEvent.target;
|
|
if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){
|
|
return;
|
|
}
|
|
|
|
closeAllTooltips();
|
|
closeAllPopups();
|
|
|
|
});
|
|
|
|
function isValidLatLng(lat, lng) {
|
|
|
|
if(isNaN(lat) || isNaN(lng)){
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
function findNodeById(id) {
|
|
|
|
// find node by id
|
|
var node = nodes.find((node) => node.node_id.toString() === id.toString());
|
|
if(node){
|
|
return node;
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
function findNodeMarkerById(id) {
|
|
|
|
// find node marker by id
|
|
var nodeMarker = nodeMarkers[id];
|
|
if(nodeMarker){
|
|
return nodeMarker;
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
function goToNode(id, animate, zoom){
|
|
|
|
// find node
|
|
var node = findNodeById(id);
|
|
if(!node){
|
|
alert("Could not find node: " + id);
|
|
return false;
|
|
}
|
|
|
|
// find node marker by id
|
|
var nodeMarker = findNodeMarkerById(id);
|
|
if(!nodeMarker){
|
|
return false;
|
|
}
|
|
|
|
// close all popups and tooltips
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// select node
|
|
showNodeOutline(id);
|
|
|
|
// fly to node marker
|
|
const shouldAnimate = animate != null ? animate : true;
|
|
map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
|
|
animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
|
|
});
|
|
|
|
// open tooltip for node
|
|
map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
|
|
interactive: true, // allow clicking buttons inside tooltip
|
|
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
|
});
|
|
|
|
// successfully went to node
|
|
return true;
|
|
|
|
}
|
|
|
|
function goToRandomNode() {
|
|
if(nodes.length > 0){
|
|
const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
|
|
if(randomNode){
|
|
|
|
// go to node
|
|
if(window.goToNode(randomNode.node_id)){
|
|
return;
|
|
}
|
|
|
|
// fallback to showing node details since we can't go to the node
|
|
window.showNodeDetails(randomNode.node_id);
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearAllNodes() {
|
|
nodesLayerGroup.clearLayers();
|
|
nodesClusteredLayerGroup.clearLayers();
|
|
nodesRouterLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllNeighbours() {
|
|
neighboursLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearAllWaypoints() {
|
|
waypointsLayerGroup.clearLayers();
|
|
}
|
|
|
|
function closeAllPopups() {
|
|
map.eachLayer(function(layer) {
|
|
if(layer.options.pane === "popupPane"){
|
|
layer.removeFrom(map);
|
|
}
|
|
});
|
|
}
|
|
|
|
function closeAllTooltips() {
|
|
map.eachLayer(function(layer) {
|
|
if(layer.options.pane === "tooltipPane"){
|
|
layer.removeFrom(map);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearAllPositionHistory() {
|
|
nodePositionHistoryLayerGroup.clearLayers();
|
|
}
|
|
|
|
function clearNodeOutline() {
|
|
if(selectedNodeOutlineCircle){
|
|
selectedNodeOutlineCircle.removeFrom(map);
|
|
selectedNodeOutlineCircle = null;
|
|
}
|
|
}
|
|
|
|
function showNodeOutline(id) {
|
|
|
|
// remove any existing node circle
|
|
clearNodeOutline();
|
|
|
|
// find node marker by id
|
|
const nodeMarker = nodeMarkers[id];
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// find node by id
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// add position precision circle around node
|
|
if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
|
|
selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
|
|
radius: getPositionPrecisionInMeters(node.position_precision),
|
|
}).addTo(map);
|
|
}
|
|
|
|
}
|
|
|
|
function showNodeDetails(id) {
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// fire callback to vuejs handler
|
|
window._onNodeClick(node);
|
|
|
|
}
|
|
|
|
function getColourForSnr(snr) {
|
|
if(snr >= 0) return "#16a34a"; // good
|
|
if(snr < 0) return "#dc2626"; // bad
|
|
}
|
|
|
|
function cleanUpNodeNeighbours() {
|
|
|
|
// close tooltips and popups
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// setup node neighbours layer
|
|
nodeNeighboursLayerGroup.clearLayers();
|
|
nodeNeighboursLayerGroup.removeFrom(map);
|
|
nodeNeighboursLayerGroup.addTo(map);
|
|
|
|
}
|
|
|
|
function getTerrainProfileImage(node1, node2) {
|
|
|
|
// line colour between nodes
|
|
const lineColour = "0000FF"; // blue
|
|
|
|
// node 1 (left side of image)
|
|
const node1MarkerColour = "0000FF"; // blue
|
|
const node1Latitude = node1.latitude;
|
|
const node1Longitude = node1.longitude;
|
|
const node1ElevationMSL = ""; // node1.altitude ?? "";
|
|
|
|
// node 2 (right side of image)
|
|
const node2MarkerColour = "0000FF"; // blue
|
|
const node2Latitude = node2.latitude;
|
|
const node2Longitude = node2.longitude;
|
|
const node2ElevationMSL = ""; // node2.altitude ?? "";
|
|
|
|
// generate terrain profile image url
|
|
return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({
|
|
src: "meshtastic.liamcottle.net",
|
|
axes: 1, // include grid lines and a scale
|
|
metric: 1, // show metric units
|
|
curvature: 0, // don't include the curvature of the earth in the graphic
|
|
width: 500,
|
|
height: 200,
|
|
pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`,
|
|
pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`,
|
|
}).toString();
|
|
|
|
}
|
|
|
|
function showNodeNeighboursThatWeHeard(id) {
|
|
|
|
cleanUpNodeNeighbours();
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// find node marker
|
|
const nodeMarker = findNodeMarkerById(node.node_id);
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// show overlay for node neighbours
|
|
window._onShowNodeNeighboursWeHeardClick(node);
|
|
|
|
// ensure we have neighbours to show
|
|
const neighbours = node.neighbours ?? [];
|
|
if(neighbours.length === 0){
|
|
return;
|
|
}
|
|
|
|
// add node neighbours
|
|
for(const neighbour of neighbours){
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
// find neighbour node
|
|
const neighbourNode = findNodeById(neighbour.node_id);
|
|
if(!neighbourNode){
|
|
continue;
|
|
}
|
|
|
|
// find neighbour node marker
|
|
const neighbourNodeMarker = 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);
|
|
|
|
// don't show this neighbour connection if further than config allows
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
// add neighbour line to map
|
|
const line = L.polyline([
|
|
neighbourNodeMarker.getLatLng(), // from neighbour
|
|
nodeMarker.getLatLng(), // to us
|
|
], {
|
|
color: getColourForSnr(neighbour.snr),
|
|
opacity: 1,
|
|
}).arrowheads({
|
|
size: '10px',
|
|
fill: true,
|
|
offsets: {
|
|
start: '25px',
|
|
end: '25px',
|
|
},
|
|
}).addTo(nodeNeighboursLayerGroup);
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const terrainImageUrl = getTerrainProfileImage(node, neighbourNode);
|
|
|
|
const tooltip = `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
|
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
|
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
|
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
|
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
|
|
|
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 showNodeNeighboursThatHeardUs(id) {
|
|
|
|
cleanUpNodeNeighbours();
|
|
|
|
// find node
|
|
const node = findNodeById(id);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// find node marker
|
|
const nodeMarker = findNodeMarkerById(node.node_id);
|
|
if(!nodeMarker){
|
|
return;
|
|
}
|
|
|
|
// show overlay for node neighbours
|
|
window._onShowNodeNeighboursHeardUsClick(node);
|
|
|
|
// find all nodes that have us as a neighbour
|
|
const neighbourNodeInfos = [];
|
|
for(const nodeThatMayHaveHeardUs of 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();
|
|
});
|
|
|
|
// 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 = 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
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
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(nodeNeighboursLayerGroup);
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const terrainImageUrl = getTerrainProfileImage(neighbourNode, node);
|
|
|
|
const tooltip = `<b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b> heard <b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${neighbourNode.node_id} heard ${node.node_id}`
|
|
+ `<br/>Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}`
|
|
+ (neighbourNode.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '')
|
|
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
|
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
|
|
|
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 clearMap() {
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
clearAllNodes();
|
|
clearAllNeighbours();
|
|
clearAllWaypoints();
|
|
clearNodeOutline();
|
|
cleanUpNodeNeighbours();
|
|
}
|
|
|
|
// returns true if the element or one of its parents has the class classname
|
|
function elementOrAnyAncestorHasClass(element, className) {
|
|
|
|
// check if element contains class
|
|
if(element.classList && element.classList.contains(className)){
|
|
return true;
|
|
}
|
|
|
|
// check if parent node has the class
|
|
if(element.parentNode){
|
|
return elementOrAnyAncestorHasClass(element.parentNode, className);
|
|
}
|
|
|
|
// couldn't find the class
|
|
return false;
|
|
|
|
}
|
|
|
|
// escape strings for tooltips etc, to prevent html/script injection
|
|
// not used in vuejs, as that auto escapes
|
|
function escapeString(string) {
|
|
return string.replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
// determine if node was recently heard uplinking packets to mqtt
|
|
function hasNodeUplinkedToMqttRecently(node) {
|
|
const now = moment();
|
|
const configNodesDisconnectedAgeInSeconds = getConfigNodesDisconnectedAgeInSeconds();
|
|
const millisecondsSinceNodeLastUplinkedToMqtt = now.diff(moment(node.mqtt_connection_state_updated_at));
|
|
return millisecondsSinceNodeLastUplinkedToMqtt < configNodesDisconnectedAgeInSeconds * 1000;
|
|
}
|
|
|
|
function onNodesUpdated(updatedNodes) {
|
|
|
|
// clear nodes cache
|
|
nodes = [];
|
|
|
|
// get config
|
|
const now = moment();
|
|
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
|
|
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
|
|
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
|
|
|
|
// add nodes
|
|
for(const node of updatedNodes){
|
|
|
|
// skip nodes older than configured node max age
|
|
if(configNodesMaxAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
|
if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// add to cache
|
|
nodes.push(node);
|
|
|
|
// skip nodes without position
|
|
if(!node.latitude || !node.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
node.latitude = node.latitude / 10000000;
|
|
node.longitude = node.longitude / 10000000;
|
|
|
|
// skip nodes with invalid position
|
|
if(!isValidLatLng(node.latitude, node.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
|
var longitude = parseFloat(node.longitude);
|
|
if(longitude <= 100){
|
|
longitude += 360;
|
|
}
|
|
|
|
// icon based on mqtt connection state
|
|
var icon = iconMqttDisconnected;
|
|
|
|
// use offline icon for nodes older than configured node offline age
|
|
if(configNodesOfflineAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
|
|
if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
|
|
icon = iconOffline;
|
|
}
|
|
}
|
|
|
|
// determine if node was recently heard uplinking packets to mqtt
|
|
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
|
|
if(nodeHasUplinkedToMqttRecently){
|
|
icon = iconMqttConnected;
|
|
}
|
|
|
|
// create node marker
|
|
const marker = L.marker([node.latitude, longitude], {
|
|
icon: icon,
|
|
tagName: node.node_id,
|
|
// we want to show online nodes above offline, but without needing to use separate layer groups
|
|
zIndexOffset: nodeHasUplinkedToMqttRecently ? 1000 : -1000,
|
|
}).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// add marker to node layer groups
|
|
marker.addTo(nodesLayerGroup);
|
|
nodesClusteredLayerGroup.addLayer(marker);
|
|
|
|
// add markers for routers and repeaters to routers layer group
|
|
if(node.role_name === "ROUTER"
|
|
|| node.role_name === "ROUTER_CLIENT"
|
|
|| node.role_name === "ROUTER_LATE"
|
|
|| node.role_name === "REPEATER"){
|
|
nodesRouterLayerGroup.addLayer(marker);
|
|
}
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(getTooltipContentForNode(node), {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// show node info tooltip when clicking node marker
|
|
marker.on("click", function(event) {
|
|
|
|
// close all other popups and tooltips
|
|
closeAllTooltips();
|
|
closeAllPopups();
|
|
|
|
// find node
|
|
const node = findNodeById(event.target.options.tagName);
|
|
if(!node){
|
|
return;
|
|
}
|
|
|
|
// show position precision outline
|
|
showNodeOutline(node.node_id);
|
|
|
|
// open tooltip for node
|
|
map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
|
|
interactive: true, // allow clicking buttons inside tooltip
|
|
permanent: true, // don't auto dismiss when clicking buttons inside tooltip
|
|
});
|
|
|
|
});
|
|
|
|
// add to cache
|
|
nodeMarkers[node.node_id] = marker;
|
|
|
|
}
|
|
|
|
for(const node of updatedNodes){
|
|
|
|
// find current node
|
|
const currentNode = findNodeMarkerById(node.node_id);
|
|
if(!currentNode){
|
|
continue;
|
|
}
|
|
|
|
// add node neighbours
|
|
var polylineOffset = 0;
|
|
const neighbours = node.neighbours ?? [];
|
|
for(const neighbour of neighbours){
|
|
|
|
// fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
|
|
if(neighbour.snr === 0){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNode = findNodeById(neighbour.node_id);
|
|
if(!neighbourNode){
|
|
continue;
|
|
}
|
|
|
|
const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
|
|
if(neighbourNodeMarker){
|
|
|
|
// calculate distance in meters between nodes (rounded to 2 decimal places)
|
|
const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);
|
|
|
|
// don't show this neighbour connection if further than config allows
|
|
if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
|
|
continue;
|
|
}
|
|
|
|
// add neighbour line to map
|
|
const line = L.polyline([
|
|
currentNode.getLatLng(),
|
|
neighbourNodeMarker.getLatLng(),
|
|
], {
|
|
color: '#2563eb',
|
|
opacity: 0.75,
|
|
offset: polylineOffset,
|
|
}).addTo(neighboursLayerGroup);
|
|
|
|
// increase offset so next neighbour does not overlay other neighbours from self
|
|
polylineOffset += 2;
|
|
|
|
// default to showing distance in meters
|
|
var distance = `${distanceInMeters} meters`;
|
|
|
|
// scale to distance in kms
|
|
if(distanceInMeters >= 1000){
|
|
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
|
|
distance = `${distanceInKilometers} kilometers`;
|
|
}
|
|
|
|
const terrainImageUrl = getTerrainProfileImage(node, neighbourNode);
|
|
|
|
const tooltip = `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
|
|
+ `<br/>SNR: ${neighbour.snr}dB`
|
|
+ `<br/>Distance: ${distance}`
|
|
+ `<br/><br/>ID: ${node.node_id} heard ${neighbourNode.node_id}`
|
|
+ `<br/>Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}`
|
|
+ (node.neighbours_updated_at ? `<br/>Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '')
|
|
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
|
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
|
|
|
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();
|
|
});
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
window._onNodesUpdated(nodes);
|
|
|
|
}
|
|
|
|
function onWaypointsUpdated(updatedWaypoints) {
|
|
|
|
// clear nodes cache
|
|
waypoints = [];
|
|
|
|
// get config
|
|
const now = moment();
|
|
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
|
|
|
|
// add nodes
|
|
for(const waypoint of updatedWaypoints){
|
|
|
|
// skip waypoints older than configured waypoint max age
|
|
if(configWaypointsMaxAgeInSeconds){
|
|
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
|
|
if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// skip expired waypoints
|
|
if(waypoint.expire < Date.now() / 1000){
|
|
continue;
|
|
}
|
|
|
|
// skip waypoints without position
|
|
if(!waypoint.latitude || !waypoint.longitude){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
waypoint.latitude = waypoint.latitude / 10000000;
|
|
waypoint.longitude = waypoint.longitude / 10000000;
|
|
|
|
// skip waypoints with invalid position
|
|
if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
|
var longitude = parseFloat(waypoint.longitude);
|
|
if(longitude <= 100){
|
|
longitude += 360;
|
|
}
|
|
|
|
// determine emoji to show as marker icon
|
|
const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
|
|
const emojiText = String.fromCodePoint(emoji)
|
|
|
|
var tooltip = getTooltipContentForWaypoint(waypoint);
|
|
|
|
// create waypoint marker
|
|
const marker = L.marker([waypoint.latitude, longitude], {
|
|
icon: L.divIcon({
|
|
className: 'waypoint-label',
|
|
iconSize: [26, 26], // increase from 12px to 26px
|
|
html: emojiText,
|
|
}),
|
|
}).bindPopup(tooltip).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// show tooltip on desktop only
|
|
if(!isMobile()){
|
|
marker.bindTooltip(tooltip, {
|
|
interactive: true,
|
|
});
|
|
}
|
|
|
|
// add marker to waypoints layer groups
|
|
marker.addTo(waypointsLayerGroup);
|
|
|
|
// add to cache
|
|
waypoints.push(waypoint);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function onPositionHistoryUpdated(updatedPositionHistories) {
|
|
|
|
let positionHistoryLinesCords = [];
|
|
|
|
// add nodes
|
|
for(const positionHistory of updatedPositionHistories) {
|
|
|
|
// skip position history without position
|
|
if(!positionHistory.latitude || !positionHistory.longitude){
|
|
continue;
|
|
}
|
|
|
|
// find node this position is for
|
|
const node = findNodeById(positionHistory.node_id);
|
|
if(!node){
|
|
continue;
|
|
}
|
|
|
|
// fix lat long
|
|
positionHistory.latitude = positionHistory.latitude / 10000000;
|
|
positionHistory.longitude = positionHistory.longitude / 10000000;
|
|
|
|
// skip position history with invalid position
|
|
if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
|
|
continue;
|
|
}
|
|
|
|
// wrap longitude for shortest path, everything to left of australia should be shown on the right
|
|
var longitude = parseFloat(positionHistory.longitude);
|
|
if(longitude <= 100){
|
|
longitude += 360;
|
|
}
|
|
|
|
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
|
|
|
|
let tooltip = "";
|
|
if(positionHistory.type === "position"){
|
|
tooltip += `<b>Position</b>`;
|
|
} else if(positionHistory.type === "map_report"){
|
|
tooltip += `<b>Map Report</b>`;
|
|
}
|
|
tooltip += `<br/>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
|
|
tooltip += `<br/>${positionHistory.latitude}, ${positionHistory.longitude}`;
|
|
tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}`;
|
|
|
|
// add gateway info if available
|
|
if(positionHistory.gateway_id){
|
|
const gatewayNode = findNodeById(positionHistory.gateway_id);
|
|
const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
|
|
tooltip += `<br/>Heard by: <a href="javascript:void(0);" onclick="goToNode(${positionHistory.gateway_id})">${gatewayNodeInfo}</a>`;
|
|
}
|
|
|
|
// create position history marker
|
|
const marker = L.marker([positionHistory.latitude, longitude],{
|
|
icon: iconPositionHistory,
|
|
}).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
|
|
// close tooltip on click to prevent tooltip and popup showing at same time
|
|
event.target.closeTooltip();
|
|
});
|
|
|
|
// add marker to position history layer group
|
|
marker.addTo(nodePositionHistoryLayerGroup);
|
|
|
|
}
|
|
|
|
// show lines between position history markers
|
|
L.polyline(positionHistoryLinesCords).addTo(nodePositionHistoryLayerGroup);
|
|
|
|
}
|
|
|
|
function cleanUpPositionHistory() {
|
|
|
|
// close tooltips and popups
|
|
closeAllPopups();
|
|
closeAllTooltips();
|
|
|
|
// setup node neighbours layer
|
|
nodePositionHistoryLayerGroup.clearLayers();
|
|
nodePositionHistoryLayerGroup.removeFrom(map);
|
|
nodePositionHistoryLayerGroup.addTo(map);
|
|
|
|
}
|
|
|
|
function setLoading(loading){
|
|
var reloadButton = document.getElementById("reload-button");
|
|
if(loading){
|
|
reloadButton.classList.add("animate-spin");
|
|
} else {
|
|
reloadButton.classList.remove("animate-spin");
|
|
}
|
|
}
|
|
|
|
async function reload(goToNodeId, zoom) {
|
|
|
|
// show loading
|
|
setLoading(true);
|
|
|
|
// clear previous data
|
|
clearMap();
|
|
|
|
// fetch nodes
|
|
await window.axios.get('/api/v1/nodes').then(async (response) => {
|
|
|
|
// update nodes
|
|
onNodesUpdated(response.data.nodes);
|
|
|
|
// hide loading
|
|
setLoading(false);
|
|
|
|
// go to node id if provided
|
|
if(goToNodeId){
|
|
|
|
// go to node
|
|
if(window.goToNode(goToNodeId, false, zoom)){
|
|
return;
|
|
}
|
|
|
|
// fallback to showing node details since we can't go to the node
|
|
window.showNodeDetails(goToNodeId);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
|
|
await window.axios.get('/api/v1/waypoints').then(async (response) => {
|
|
onWaypointsUpdated(response.data.waypoints);
|
|
});
|
|
|
|
}
|
|
|
|
function getRegionFrequencyRange(regionName) {
|
|
|
|
// determine lora frequency range based on region_name
|
|
// https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
|
|
const regionNameToLoraFrequencyRange = {
|
|
"US": "902-928 MHz",
|
|
"EU_433": "433-434 MHz",
|
|
"EU_868": "869.4-869.65 MHz",
|
|
"CN": "470-510 MHz",
|
|
"JP": "920.8-927.8 MHz",
|
|
"ANZ": "915-928 MHz",
|
|
"RU": "868.7-869.2 MHz",
|
|
"KR": "920-923 MHz",
|
|
"TW": "920-925 MHz",
|
|
"IN": "865-867 MHz",
|
|
"NZ_865": "864-868 MHz",
|
|
"TH": "920-925 MHz",
|
|
"UA_433": "433-434.7 MHz",
|
|
"UA_868": "868-868.6 MHz",
|
|
"MY_433": "433-435 MHz",
|
|
"MY_919": "919-924 MHz",
|
|
"SG_923": "917-925 MHz",
|
|
"LORA_24": "2.4-2.4835 GHz",
|
|
"UNSET": "902-928 MHz",
|
|
}
|
|
|
|
return regionNameToLoraFrequencyRange[regionName] ?? null;
|
|
|
|
}
|
|
|
|
function getPositionPrecisionInMeters(positionPrecision) {
|
|
switch(positionPrecision){
|
|
case 2: return 5976446;
|
|
case 3: return 2988223;
|
|
case 4: return 1494111;
|
|
case 5: return 747055;
|
|
case 6: return 373527;
|
|
case 7: return 186763;
|
|
case 8: return 93381;
|
|
case 9: return 46690;
|
|
case 10: return 23345;
|
|
case 11: return 11672; // Android LOW_PRECISION
|
|
case 12: return 5836;
|
|
case 13: return 2918;
|
|
case 14: return 1459;
|
|
case 15: return 729;
|
|
case 16: return 364; // Android MED_PRECISION
|
|
case 17: return 182;
|
|
case 18: return 91;
|
|
case 19: return 45;
|
|
case 20: return 22;
|
|
case 21: return 11;
|
|
case 22: return 5;
|
|
case 23: return 2;
|
|
case 24: return 1;
|
|
case 32: return 0; // Android HIGH_PRECISION
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatPositionPrecision(positionPrecision) {
|
|
|
|
// get position precision in meters
|
|
const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
|
|
if(positionPrecisionInMeters == null){
|
|
return "?";
|
|
}
|
|
|
|
// format kilometers
|
|
if(positionPrecisionInMeters > 1000){
|
|
const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
|
|
return `±${positionPrecisionInKilometers}km`;
|
|
}
|
|
|
|
// format meters
|
|
return `±${positionPrecisionInMeters}m`;
|
|
|
|
}
|
|
|
|
function getTooltipContentForNode(node) {
|
|
|
|
// determine if node was recently heard uplinking packets to mqtt
|
|
const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
|
|
var mqttStatus = `<span class="text-blue-700">Disconnected</span>`;
|
|
if(node.mqtt_connection_state_updated_at){
|
|
var mqttStatusUpdatedAt = moment(new Date(node.mqtt_connection_state_updated_at)).fromNow();
|
|
if(nodeHasUplinkedToMqttRecently){
|
|
mqttStatus = `<span><span class="text-green-700">Connected</span> (${mqttStatusUpdatedAt})</span>`;
|
|
} else {
|
|
mqttStatus = `<span><span class="text-blue-700">Disconnected</span> (${mqttStatusUpdatedAt})</span>`;
|
|
}
|
|
}
|
|
|
|
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
|
|
|
|
var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
|
|
`<b>${escapeString(node.long_name)}</b>` +
|
|
`<br/>Short Name: ${escapeString(node.short_name)}` +
|
|
`<br/>MQTT: ${mqttStatus}` +
|
|
(node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
|
|
(node.position_precision != null && node.position_precision !== 32 ? `<br/>Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
|
|
`<br/><br/>Role: ${node.role_name}` +
|
|
`<br/>Hardware: ${node.hardware_model_name}` +
|
|
(node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
|
|
(node.region_name != null ? `<br/>LoRa Region: ${node.region_name} (${loraFrequencyRange})` : '') +
|
|
(node.modem_preset_name != null ? `<br/>Modem Preset: ${node.modem_preset_name}` : '') +
|
|
(node.has_default_channel != null ? `<br/>Has Default Channel: ${node.has_default_channel ? "Yes" : "No"}` : '');
|
|
|
|
if(node.battery_level){
|
|
if(node.battery_level > 100){
|
|
tooltip += `<br/>Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
|
|
} else {
|
|
tooltip += `<br/>Battery: ${node.battery_level}%`;
|
|
}
|
|
}
|
|
|
|
if(node.voltage){
|
|
tooltip += `<br/>Voltage: ${Number(node.voltage).toFixed(2)}V`;
|
|
}
|
|
|
|
if(node.channel_utilization){
|
|
tooltip += `<br/>Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
|
|
}
|
|
|
|
if(node.air_util_tx){
|
|
tooltip += `<br/>Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
|
|
}
|
|
|
|
// ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
|
|
if(node.altitude && node.altitude < 42949000){
|
|
tooltip += `<br/>Altitude: ${node.altitude}m`;
|
|
}
|
|
|
|
// bottom info
|
|
tooltip += `<br/><br/>ID: ${node.node_id}`;
|
|
tooltip += `<br/>Hex ID: ${node.node_id_hex}`;
|
|
tooltip += `<br/>Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
|
|
tooltip += (node.neighbours_updated_at ? `<br/>Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
|
|
tooltip += (node.position_updated_at ? `<br/>Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');
|
|
|
|
// show details button
|
|
tooltip += `<br/><br/><button onclick="showNodeDetails(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Full Details</button>`;
|
|
tooltip += `<br/><button onclick="showNodeNeighboursThatHeardUs(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button>`;
|
|
tooltip += `<br/><button onclick="showNodeNeighboursThatWeHeard(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200">Show Neighbours (We Heard)</button>`;
|
|
tooltip += `</div>`;
|
|
|
|
return tooltip;
|
|
|
|
}
|
|
|
|
function getTooltipContentForWaypoint(waypoint) {
|
|
|
|
// get from node name
|
|
var fromNode = findNodeById(waypoint.from);
|
|
|
|
var tooltip = `<b>${escapeString(waypoint.name)}</b>` +
|
|
(waypoint.description ? `<br/>${escapeString(waypoint.description)}` : '') +
|
|
`<br/><br/>Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
|
|
`<br/>Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` +
|
|
`<br/><br/>From ID: ${waypoint.from}` +
|
|
`<br/>From Hex ID: !${Number(waypoint.from).toString(16)}`;
|
|
|
|
// show node name this waypoint is from, if possible
|
|
if(fromNode != null){
|
|
tooltip += `<br/>From Node: <a href="#" onclick="goToNode(${waypoint.from})">${escapeString(fromNode.long_name) || 'Unnamed Node'}</a>`;
|
|
} else {
|
|
tooltip += `<br/>From Node: ???`;
|
|
}
|
|
|
|
// bottom info
|
|
tooltip += `<br/><br/>ID: ${waypoint.waypoint_id}`;
|
|
tooltip += `<br/>Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;
|
|
|
|
return tooltip;
|
|
|
|
}
|
|
|
|
window._onHideNodeNeighboursClick = function() {
|
|
cleanUpNodeNeighbours();
|
|
};
|
|
|
|
// parse url params
|
|
var queryParams = new URLSearchParams(location.search);
|
|
var queryNodeId = queryParams.get('node_id');
|
|
var queryLat = queryParams.get('lat');
|
|
var queryLng = queryParams.get('lng');
|
|
var queryZoom = queryParams.get('zoom');
|
|
|
|
// go to lat/lng if provided
|
|
if(queryLat && queryLng){
|
|
const zoomLevel = queryZoom || getConfigZoomLevelGoToNode();
|
|
map.flyTo([queryLat, queryLng], zoomLevel, {
|
|
animate: false,
|
|
});
|
|
}
|
|
|
|
// auto update url when lat/lng/zoom changes
|
|
map.on("moveend zoomend", function() {
|
|
|
|
// check if user enabled auto updating position in url
|
|
const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl();
|
|
if(!autoUpdatePositionInUrl){
|
|
return;
|
|
}
|
|
|
|
// get map info
|
|
const latLng = map.getCenter();
|
|
const zoom = map.getZoom();
|
|
|
|
// construct new url
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("lat", latLng.lat);
|
|
url.searchParams.set("lng", latLng.lng);
|
|
url.searchParams.set("zoom", zoom);
|
|
|
|
// update current url
|
|
if(window.history.replaceState){
|
|
window.history.replaceState(null, null, url.toString());
|
|
}
|
|
|
|
});
|
|
|
|
// reload and go to provided node id
|
|
reload(queryNodeId, queryZoom);
|
|
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|