meshtastic-map/src/public/index.html

4243 lines
204 KiB
HTML
Raw Normal View History

2024-03-12 18:31:17 +13:00
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
2025-03-05 00:09:07 +01:00
<title>STHLM-MESH MAP</title>
2024-03-13 17:29:16 +13:00
<meta name="title" content="Meshtastic Map">
2024-03-12 18:31:17 +13:00
<meta name="description" content="An interactive map of all Meshtastic nodes.">
<link rel="icon" type="image/png" href="/icon.png"/>
2024-03-12 18:31:17 +13:00
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
2025-03-05 00:09:07 +01:00
<meta property="og:url" content="https://map.sthlm-mesh.se">
<meta property="og:title" content="STHLM-MESH MAP">
2024-03-23 16:21:14 +13:00
<meta property="og:description" content="An interactive map of all Meshtastic nodes.">
2024-03-12 18:31:17 +13:00
<!-- tailwind css -->
<script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
2024-03-12 18:31:17 +13:00
<!-- 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>
2024-05-16 20:43:24 +12:00
<!-- 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"/>
2024-03-12 18:31:17 +13:00
<!-- leaflet groupedlayercontrol -->
2024-05-16 20:43:24 +12:00
<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"/>
2024-03-12 18:31:17 +13:00
<!-- moment -->
<script src="assets/js/moment@2.29.1/moment.min.js"></script>
2024-03-12 18:31:17 +13:00
<!-- 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>
2024-09-02 03:26:18 +12:00
<script src="assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js"></script>
2024-03-12 18:31:17 +13:00
<style>
/* used to prevent ui flicker before vuejs loads */
[v-cloak] {
display: none;
}
.icon-longfast {
background-color: #2563eb;
border-radius: 25px;
border: 1px solid white;
}
.icon-mediumfast {
background-color: #4C83FE;
border-radius: 25px;
border: 1px solid white;
}
.icon-mqtt-connected {
background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
2024-03-12 18:31:17 +13:00
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;
}
2024-03-18 22:52:28 +13:00
.waypoint-label {
font-size: 26px;
background-color: transparent;
}
2024-03-12 18:31:17 +13:00
.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;
}
2024-03-17 03:38:06 +13:00
.z-search {
2024-03-26 04:05:37 +13:00
z-index: 1001;
2024-03-12 18:31:17 +13:00
}
2024-03-17 03:38:06 +13:00
.z-sidebar {
2024-03-26 04:05:37 +13:00
z-index: 1002;
2024-03-17 03:38:06 +13:00
}
2024-03-12 18:31:17 +13:00
</style>
</head>
<body class="h-full bg-gray-200">
<div id="app" v-cloak>
2024-03-12 18:31:17 +13:00
<div class="flex flex-col h-full w-full overflow-hidden">
<div class="flex flex-col h-full">
<!-- header -->
2025-03-05 00:09:07 +01:00
<div class="flex p-3 h-16" style="background-color: 30a552;">
2024-03-26 04:05:37 +13:00
<!-- 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 -->
2025-03-09 09:27:43 +01:00
<div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-2 ml-2">
<img class="w-8 h-8 rounded" src="icon.png"/>
2024-03-12 18:31:17 +13:00
</div>
2024-03-26 04:05:37 +13:00
<!-- app info -->
<div v-if="!isShowingMobileSearch" class="my-auto leading-tight">
2025-03-09 09:27:43 +01:00
<a href="https://sthlm-mesh.se"><div class="font-bold" style="color: #ffffff; font-size: 1.25rem;">STHLM-MESH</div></a>
</div>
2024-03-26 04:05:37 +13:00
<!-- search bar -->
<div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !isShowingMobileSearch }">
2024-03-17 03:38:06 +13:00
<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">
2024-09-04 12:32:17 +12:00
<div @click="onSearchResultNodeClick(node)" class="flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
<div>
2024-09-07 21:57:23 +12:00
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]">
2024-09-04 12:32:17 +12:00
<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>
2024-03-17 03:38:06 +13:00
</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>
2024-03-17 03:38:06 +13:00
</template>
<template v-else>
<div class="p-2">
No results found...
</div>
</template>
</div>
</div>
2024-03-26 04:05:37 +13:00
<!-- 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">
2024-03-23 12:59:17 +13:00
<div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
2024-03-29 23:16:09 +13:00
<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" />
2024-03-23 12:59:17 +13:00
</svg>
</div>
<div class="hidden sm:block">
2024-03-29 23:16:09 +13:00
<span class="tooltip-text">About</span>
2024-03-23 12:59:17 +13:00
</div>
</a>
2024-03-26 04:05:37 +13:00
<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>
2024-03-26 04:05:37 +13:00
2024-03-12 18:31:17 +13:00
</div>
<!-- map -->
<div id="map" style="width:100%;height:100%;"></div>
2024-03-12 18:31:17 +13:00
</div>
2024-03-12 18:31:17 +13:00
</div>
2024-03-29 23:16:09 +13:00
<!-- 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">
2024-03-29 23:27:02 +13:00
<div v-show="isShowingInfoModal" @click="dismissInfoModal" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
2024-03-29 23:16:09 +13:00
</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">
2024-03-29 23:40:21 +13:00
<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">
2024-03-29 23:16:09 +13:00
<div class="relative flex">
<!-- close button -->
<div class="absolute top-0 right-0">
<div class="h-7">
2024-03-29 23:27:02 +13:00
<a href="javascript:void(0)" class="rounded-full" @click="dismissInfoModal">
2024-03-29 23:16:09 +13:00
<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>
2025-03-05 00:10:30 +01:00
<h2>Forked by <a class="link" target="_blank" href="http://github.com/Roslund/">Roslund</a></h2>
2024-03-29 23:16:09 +13:00
</div>
2025-03-05 00:10:30 +01:00
<!-- Beskrivning -->
2024-03-29 23:16:09 +13:00
<div>
2025-03-05 00:10:30 +01:00
<div class="font-bold mb-2">Beskrivning</div>
<div class="space-y-2">
2025-03-05 00:14:39 +01:00
<div class="bg-gray-100 rounded p-2 border border-gray-200">
2025-03-05 00:10:30 +01:00
<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>
2025-03-05 00:14:39 +01:00
</div>
2024-03-29 23:16:09 +13:00
</div>
2025-03-05 00:10:30 +01:00
<div class="font-bold mb-2">Beskrivning</div>
2024-03-29 23:16:09 +13:00
<div class="space-y-2">
<div class="bg-gray-100 rounded p-2 border border-gray-200">
2025-03-05 00:10:30 +01:00
<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>
2024-08-24 14:58:39 +12:00
</div>
<div class="bg-gray-100 rounded p-2 border border-gray-200">
2025-03-05 00:10:30 +01:00
<div class="font-semibold">Inställningar:</div>
2024-08-24 14:58:39 +12:00
<ul class="list-disc list-inside">
2025-03-05 00:10:30 +01:00
<li>Address: mqtt.sthlm-mesh.se</li>
2024-08-24 14:58:39 +12:00
<li>Encryption Enabled: Yes</li>
<li>JSON Output: No</li>
<li>TLS Enabled: No</li>
</ul>
2024-03-29 23:16:09 +13:00
</div>
2025-03-05 00:14:39 +01:00
</div>
</div>
2024-03-29 23:16:09 +13:00
<!-- 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">
2024-03-29 23:27:02 +13:00
<a href="javascript:void(0)" @click="dismissInfoModal">
2024-03-29 23:16:09 +13:00
<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 -->
2024-03-13 23:06:51 +13:00
<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">
2024-03-14 19:52:06 +13:00
<div class="flex h-full flex-col bg-white shadow-xl">
2024-03-13 23:06:51 +13:00
<!-- 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>
2024-03-12 18:31:17 +13:00
</div>
</div>
2024-03-13 23:06:51 +13:00
</div>
2024-03-12 18:31:17 +13:00
2024-03-13 23:06:51 +13:00
<!-- 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';">
2024-03-13 23:06:51 +13:00
</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>
2024-03-13 23:06:51 +13:00
</div>
</a>
</div>
</li>
</ul>
2024-03-12 18:31:17 +13:00
</div>
</div>
</div>
2024-03-13 23:06:51 +13:00
</transition>
2024-03-12 18:31:17 +13:00
</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">
2024-03-14 19:56:19 +13:00
<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">
2024-08-28 02:17:05 +12:00
<div class="my-auto mr-2">
2024-09-07 21:57:23 +12:00
<div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]">
2024-09-04 12:32:17 +12:00
<div class="mx-auto my-auto drop-shadow-sm">{{ selectedNode.short_name }}</div>
</div>
</div>
2024-08-28 02:17:05 +12:00
<div class="my-auto mr-auto">
<h2 class="font-bold">Node Info</h2>
<h3 class="text-sm">{{ selectedNode.long_name }}</h3>
</div>
2024-08-28 02:02:28 +12:00
<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>
2024-03-14 19:56:19 +13:00
<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>
2024-03-14 19:56:19 +13:00
<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';">
2024-03-14 19:56:19 +13:00
</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>
2024-03-14 19:56:19 +13:00
<!-- details -->
<div>
<div class="bg-gray-200 p-2 font-semibold">Details</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
2024-09-02 12:37:27 +12:00
<!-- 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>
2024-09-02 12:18:29 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 12:18:29 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 12:18:29 +12:00
<!-- 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>
2024-04-02 11:23:18 +13:00
</div>
</li>
2024-03-14 19:56:19 +13:00
</ul>
</div>
2024-04-07 20:24:15 +12:00
<!-- position -->
<div>
2024-08-04 19:12:51 +02:00
<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
2024-08-04 19:12:51 +02:00
</button>
</div>
</div>
2024-04-07 20:24:15 +12:00
<ul role="list" class="flex-1 divide-y divide-gray-200">
2024-09-02 12:26:53 +12:00
<!-- 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>
2024-04-07 20:24:15 +12:00
</div>
</li>
2024-09-02 12:26:53 +12:00
<!-- 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>
2024-04-07 20:24:15 +12:00
</div>
</li>
</ul>
</div>
<!-- device metrics -->
2024-03-14 19:56:19 +13:00
<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>
2025-03-09 09:40:42 +01:00
<option value="30d">30 Days</option>
</select>
</div>
</div>
2024-03-14 19:56:19 +13:00
<ul role="list" class="flex-1 divide-y divide-gray-200">
2024-09-02 03:26:18 +12:00
<!-- device metrics chart -->
2024-03-14 19:56:19 +13:00
<li>
2024-09-02 03:26:18 +12:00
<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>
2024-03-14 19:56:19 +13:00
</div>
</div>
</div>
</div>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 03:26:18 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 03:26:18 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 03:26:18 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 03:26:18 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</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>
2025-03-09 09:40:42 +01:00
<option value="30d">30 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>
2025-06-26 22:33:37 +02:00
<div class="flex mx-auto">
<div class="my-auto w-2 h-2 bg-pink-400 rounded-full"></div>
<div class="my-auto ml-1 text-sm text-gray-500">IAQ</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>
2025-03-09 09:40:42 +01:00
<option value="30d">30 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>
2024-03-19 02:33:11 +13:00
<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>
2024-03-19 02:33:11 +13:00
<!-- 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>
2024-03-19 02:33:11 +13:00
</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>
2024-03-14 19:56:19 +13:00
<!-- other -->
<div>
<div class="bg-gray-200 p-2 font-semibold">Other</div>
<ul role="list" class="flex-1 divide-y divide-gray-200">
2024-09-02 04:14:26 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 04:14:26 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</li>
2024-09-02 04:14:26 +12:00
<!-- 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>
2024-09-02 04:14:26 +12:00
<!-- 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>
2024-03-14 19:56:19 +13:00
</ul>
</div>
2024-03-14 20:28:50 +13:00
<!-- 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>
2024-03-14 20:28:50 +13:00
<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)">
2024-03-14 20:28:50 +13:00
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
2024-08-27 21:34:28 +12:00
<!-- traceroute info sidebar -->
2024-03-19 02:33:11 +13:00
<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>
2024-03-19 02:33:11 +13:00
</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">
2024-09-08 13:34:06 +12:00
<ul role="list" class="space-y-3">
2024-03-19 02:33:11 +13:00
<!-- node that initiated traceroute -->
<li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
2024-09-08 13:34:06 +12:00
<div class="absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3">
2024-03-19 02:33:11 +13:00
<div class="w-px bg-gray-200"></div>
</div>
2024-09-08 13:34:06 +12:00
<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>
2024-03-19 02:33:11 +13:00
</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>
2024-03-19 02:33:11 +13:00
<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">
2024-09-08 13:34:06 +12:00
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
2024-03-19 02:33:11 +13:00
<div class="w-px bg-gray-200"></div>
</div>
2024-09-08 13:34:06 +12:00
<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>
2024-03-19 02:33:11 +13:00
</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">
2024-09-08 13:34:06 +12:00
<div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
<div class="w-px bg-gray-200"></div>
</div>
2024-09-08 13:34:06 +12:00
<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">
2024-09-08 13:34:06 +12:00
<div class="absolute left-0 top-0 flex w-12 justify-center h-6">
2024-03-19 02:33:11 +13:00
<div class="w-px bg-gray-200"></div>
</div>
2024-09-08 13:34:06 +12:00
<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>
2024-03-19 02:33:11 +13:00
</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>
2024-04-02 21:14:55 +13:00
</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>
2024-03-19 02:33:11 +13:00
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
2024-08-27 21:34:28 +12:00
<!-- 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">
2024-03-26 03:11:44 +13:00
<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>
2024-04-05 12:48:49 +13:00
<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>
2025-08-10 15:17:04 +02:00
<!-- configTraceroutesMaxAgeInSeconds -->
<div class="p-2">
<label class="block text-sm font-medium text-gray-900">Traceroutes Max Age</label>
<div class="text-xs text-gray-600 mb-2">Traceroute edges older than this time are hidden. Reload to update map.</div>
<select v-model="configTraceroutesMaxAgeInSeconds" 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">
2024-03-26 03:11:44 +13:00
<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">
2024-03-26 03:11:44 +13:00
<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>
2024-04-04 20:55:41 +13:00
<!-- 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">
2024-04-07 12:41:28 +12:00
<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>
2024-08-04 19:12:51 +02:00
<!-- 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">
2024-08-04 19:12:51 +02:00
<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">
2024-08-04 19:12:51 +02:00
<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>
2024-08-04 19:12:51 +02:00
</div>
</div>
</div>
</div>
</transition>
</div>
2024-03-12 18:31:17 +13:00
</div>
<script>
2024-03-29 23:27:02 +13:00
function getConfigHasSeenInfoModal() {
2024-04-04 20:55:41 +13:00
return localStorage.getItem("config_has_seen_info_modal") === "true";
2024-03-29 23:27:02 +13:00
}
function setConfigHasSeenInfoModal(value) {
return localStorage.setItem("config_has_seen_info_modal", value);
}
function getConfigAutoUpdatePositionInUrl() {
2024-08-24 15:02:45 +12:00
// 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);
}
2024-04-04 20:55:41 +13:00
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
2024-08-04 19:12:51 +02:00
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");
}
}
2025-08-10 15:17:04 +02:00
function getConfigTraceroutesMaxAgeInSeconds() {
const value = localStorage.getItem("config_traceroutes_max_age_in_seconds");
// default to 3 days if unset, to limit payloads
return value != null ? parseInt(value) : 259200;
}
function setConfigTraceroutesMaxAgeInSeconds(value) {
if(value != null){
return localStorage.setItem("config_traceroutes_max_age_in_seconds", value);
} else {
return localStorage.removeItem("config_traceroutes_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);
}
2024-03-29 23:55:37 +13:00
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
</script>
<script>
Vue.createApp({
data() {
return {
2024-08-24 15:12:54 +12:00
isShowingAnnouncement: this.shouldShowAnnouncement(),
configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
2025-08-10 15:17:04 +02:00
configTraceroutesMaxAgeInSeconds: window.getConfigTraceroutesMaxAgeInSeconds(),
configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
2024-04-04 20:55:41 +13:00
configEnableMapAnimations: window.getConfigEnableMapAnimations(),
configTemperatureFormat: window.getConfigTemperatureFormat(),
isShowingHardwareModels: false,
hardwareModelStats: null,
2024-03-29 23:57:37 +13:00
isShowingInfoModal: this.shouldShowInfoModal(),
2024-03-26 04:05:37 +13:00
isShowingMobileSearch: false,
isShowingSettings: false,
2024-03-17 03:38:06 +13:00
nodes: [],
searchText: "",
selectedNode: null,
selectedNodeDeviceMetrics: [],
selectedNodeEnvironmentMetrics: [],
selectedNodePowerMetrics: [],
selectedNodeMqttMetrics: [],
2024-03-19 02:33:11 +13:00
selectedNodeTraceroutes: [],
2025-03-09 09:40:42 +01:00
deviceMetricsTimeRange: "7d",
environmentMetricsTimeRange: "7d",
powerMetricsTimeRange: "7d",
isPositionHistoryModalExpanded: true,
positionHistoryDateTimeFrom: null,
positionHistoryDateTimeTo: null,
2024-08-04 19:12:51 +02:00
selectedNodePositionHistory: [],
selectedNodeToShowPositionHistory: null,
selectedNodePositionHistoryMarkers: [],
selectedNodePositionHistoryPolyLines: [],
2024-03-19 02:33:11 +13:00
selectedTraceRoute: null,
2025-08-10 15:17:04 +02:00
tracerouteEdges: [],
selectedNodeToShowNeighbours: null,
selectedNodeToShowNeighboursType: null,
moment: window.moment,
};
},
mounted: function() {
// load data
this.loadHardwareModelStats();
2024-03-17 03:38:06 +13:00
// handle map click callback from outside of vue
window._onMapClick = () => {
this.searchText = "";
2024-03-26 04:05:37 +13:00
this.isShowingMobileSearch = false;
2024-03-17 03:38:06 +13:00
};
// 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);
2024-03-19 02:33:11 +13:00
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';
};
2024-03-17 03:38:06 +13:00
// handle nodes updated callback from outside of vue
window._onNodesUpdated = (nodes) => {
this.nodes = nodes;
};
},
methods: {
2024-08-24 15:12:54 +12:00
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;
},
2024-03-29 23:57:37 +13:00
shouldShowInfoModal: function() {
return !window.getConfigHasSeenInfoModal()
&& !window.isMobile();
},
loadHardwareModelStats: function() {
2024-03-13 21:04:27 +13:00
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);
2024-09-02 03:26:18 +12:00
const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// 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;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
break;
}
}
window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
params: {
time_from: timeFrom,
},
}).then((response) => {
2024-03-14 03:59:09 +13:00
// 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);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// 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;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
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);
2025-03-09 09:40:42 +01:00
const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10);
// 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;
}
2025-03-09 09:40:42 +01:00
case "30d": {
timeFrom = thirtyDaysAgoInMilliseconds;
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
});
},
2024-03-19 02:33:11 +13:00
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
});
},
2024-08-04 19:12:51 +02:00
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) => {
2024-08-04 19:12:51 +02:00
this.selectedNodePositionHistory = response.data.position_history;
if(this.selectedNodeToShowPositionHistory != null){
2024-08-04 19:12:51 +02:00
clearAllPositionHistory();
onPositionHistoryUpdated(response.data.position_history);
}
}).catch(() => {
// do nothing
});
},
renderDeviceMetricCharts: function() {
2024-09-02 03:26:18 +12:00
try {
this.updateDeviceMetricsChart();
} catch(e) {
console.log(e);
}
},
2024-09-02 03:26:18 +12:00
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
2024-09-02 03:26:18 +12:00
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: {
2024-09-02 03:26:18 +12:00
labels: labels,
datasets: [
{
label: 'Battery Level',
borderColor: '#3b82f6',
backgroundColor: '#3b82f6',
pointStyle: false, // no points
fill: false,
data: batteryMetrics,
},
2024-09-02 03:26:18 +12:00
{
label: 'Channel Util',
borderColor: '#22c55e',
backgroundColor: '#22c55e',
showLine: false, // no lines between points
fill: false,
data: channelUtilizationMetrics,
},
2024-09-02 03:26:18 +12:00
{
label: 'Air Util TX',
borderColor: '#f97316',
backgroundColor: '#f97316',
showLine: false, // no lines between points
fill: false,
data: airUtilTxMetrics,
2024-09-02 03:26:18 +12:00
},
],
},
options: {
responsive: true,
2024-09-02 03:26:18 +12:00
borderWidth: 2,
elements: {
point: {
2024-09-02 03:26:18 +12:00
radius: 2,
},
},
scales: {
x: {
2024-09-02 03:29:46 +12:00
position: 'top',
2024-09-02 03:26:18 +12:00
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'MMM DD', // Jan 01
},
},
},
y: {
2024-09-02 03:26:18 +12:00
min: 0,
max: 101, // 101 is "Plugged In", need to include for tooltip to work
2024-09-02 13:01:43 +12:00
ticks: {
callback: (label) => `${label}%`,
},
},
},
plugins: {
legend: {
2024-09-02 03:26:18 +12:00
display: false,
},
tooltip: {
2024-09-02 14:00:02 +12:00
mode: "index",
intersect: false,
callbacks: {
2024-09-02 03:26:18 +12:00
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 = [];
2025-06-26 22:33:37 +02:00
const iaqMetrics = [];
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);
2025-06-26 22:33:37 +02:00
iaqMetrics.push(deviceMetric.iaq);
}
// 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',
},
2025-06-26 22:33:37 +02:00
{
label: 'IAQ',
suffix: 'IAQ',
borderColor: '#f472b6',
backgroundColor: '#f472b6',
pointStyle: false, // no points
fill: false,
data: iaqMetrics,
yAxisID: 'yIAQ',
},
],
},
options: {
responsive: true,
borderWidth: 2,
spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 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: {
2024-09-11 12:34:55 +12:00
min: 800,
max: 1100,
ticks: {
2024-09-11 12:34:55 +12:00
stepSize: 10,
callback: (label) => `${label} hPa`,
},
position: 'right',
grid: {
drawOnChartArea: false, // only want the grid lines for one axis to show up
},
},
2025-06-26 22:33:37 +02:00
yIAQ: {
type: 'linear',
display: false,
},
},
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,
suggestedMax: 6,
ticks: {
callback: (label) => `${label}V`,
},
},
y1: {
suggestedMin: -50,
suggestedMax: 50,
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}`;
},
},
},
},
}
});
},
2024-03-19 02:33:11 +13:00
showTraceRoute: function(traceroute) {
this.selectedTraceRoute = traceroute;
},
findNodeById: function(id) {
return window.findNodeById(id);
},
findNodeMarkerById: function(id) {
return window.findNodeMarkerById(id);
},
2024-03-26 04:05:37 +13:00
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);
2024-03-26 04:05:37 +13:00
},
2024-03-29 23:27:02 +13:00
dismissInfoModal: function() {
this.isShowingInfoModal = false;
window.setConfigHasSeenInfoModal(true);
},
2024-04-02 11:31:46 +13:00
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);
}
2024-08-04 19:12:51 +02:00
},
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);
2024-08-28 02:02:28 +12:00
// tell user we copied it
alert("Link copied to clipboard!");
},
dismissShowingNodeNeighbours: function() {
window._onHideNodeNeighboursClick();
this.selectedNodeToShowNeighbours = null;
},
2024-08-04 19:12:51 +02:00
dismissShowingNodePositionHistory: function() {
this.selectedNodePositionHistory = [];
this.selectedNodeToShowPositionHistory = null;
this.selectedNodePositionHistoryMarkers = [];
this.selectedNodePositionHistoryPolyLines = [];
cleanUpPositionHistory();
2024-08-04 19:12:51 +02:00
},
2024-06-09 02:08:53 +12:00
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');
},
2024-09-07 21:57:23 +12:00
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";
},
},
2024-03-17 03:38:06 +13:00
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);
2024-03-17 03:38:06 +13:00
},
selectedNodeLatestPowerMetric() {
const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
return latestPowerMetric;
},
2024-03-17 03:38:06 +13:00
},
watch: {
configNodesMaxAgeInSeconds() {
window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
},
configNodesDisconnectedAgeInSeconds() {
window.setConfigNodesDisconnectedAgeInSeconds(this.configNodesDisconnectedAgeInSeconds);
},
configNodesOfflineAgeInSeconds() {
window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
},
configWaypointsMaxAgeInSeconds() {
window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
},
2025-08-10 15:17:04 +02:00
configTraceroutesMaxAgeInSeconds() {
window.setConfigTraceroutesMaxAgeInSeconds(this.configTraceroutesMaxAgeInSeconds);
},
configNeighboursMaxDistanceInMeters() {
window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
},
configZoomLevelGoToNode() {
window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
},
configAutoUpdatePositionInUrl() {
window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
},
2024-04-04 20:55:41 +13:00
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>
2024-03-12 18:31:17 +13:00
<script>
// global state
var nodes = [];
var nodeMarkers = {};
var selectedNodeOutlineCircle = null;
2024-03-18 22:52:28 +13:00
var waypoints = [];
2025-08-10 15:17:04 +02:00
var tracerouteEdgesCache = [];
2024-03-12 18:31:17 +13:00
// 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
];
2025-03-05 00:10:56 +01:00
// create map positioned over Stockholm
2024-03-12 18:31:17 +13:00
var map = L.map('map', {
maxBounds: bounds,
}).setView([
2025-03-05 00:10:56 +01:00
59.3,
378.1,
], 10);
2024-03-12 18:31:17 +13:00
// remove leaflet link
map.attributionControl.setPrefix('');
2024-04-17 22:10:04 +12:00
var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22, // increase from 18 to 22
attribution: 'Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>',
});
2024-03-12 18:31:17 +13:00
2024-11-21 11:09:14 +13:00
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 &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
});
2024-04-17 22:10:04 +12:00
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 &copy; <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>'
});
2024-04-17 22:20:22 +12:00
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 &copy; Google | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>'
});
2024-04-17 22:18:28 +12:00
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 &copy; Google | Data from <a target="_blank" href="https://meshtastic.org/docs/software/integrations/mqtt/">Meshtastic</a>'
});
var tileLayers = {
"OpenStreetMap": openStreetMapTileLayer,
2024-11-21 11:09:14 +13:00
"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);
2024-03-12 18:31:17 +13:00
// create layer groups
var nodesLayerGroup = new L.LayerGroup();
2024-03-13 15:33:35 +13:00
var neighboursLayerGroup = new L.LayerGroup();
2025-06-27 11:15:52 +02:00
var backboneNeighboursLayerGroup = new L.LayerGroup();
var nodeNeighboursLayerGroup = new L.LayerGroup();
2024-03-15 15:16:47 +13:00
var nodesClusteredLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
2024-05-19 11:50:52 +12:00
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
2024-03-15 15:16:47 +13:00
});
var nodesRouterLayerGroup = L.markerClusterGroup({
showCoverageOnHover: false,
disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
});
2025-06-27 11:15:52 +02:00
var nodesBackboneLayerGroup = new L.LayerGroup();
2024-03-18 22:52:28 +13:00
var waypointsLayerGroup = new L.LayerGroup();
2024-08-04 19:12:51 +02:00
var nodePositionHistoryLayerGroup = new L.LayerGroup();
2025-08-10 15:17:04 +02:00
var traceroutesLayerGroup = new L.LayerGroup();
2024-03-12 18:31:17 +13:00
// create icons
var iconMqttConnected = L.divIcon({
className: 'icon-mqtt-connected',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconLongFast = L.divIcon({
className: 'icon-longfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconMediumFast = L.divIcon({
className: 'icon-mediumfast',
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
var iconMqttDisconnected = L.divIcon({
className: 'icon-mqtt-disconnected',
2024-03-16 18:02:48 +13:00
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
});
2024-03-12 18:31:17 +13:00
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
});
2024-03-12 18:31:17 +13:00
// create legend
var legendLayerGroup = new L.LayerGroup();
2024-03-12 18:31:17 +13:00
var legend = L.control({position: 'bottomleft'});
legend.onAdd = function (map) {
var div = L.DomUtil.create('div', 'leaflet-control-layers');
2024-03-12 18:31:17 +13:00
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-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Medium Fast</div>`
+ `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Long Fast</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>`;
2024-03-12 18:31:17 +13:00
return div;
};
// handle baselayerchange to update tile layer preference
map.on('baselayerchange', function(event) {
setConfigMapSelectedTileLayer(event.name);
});
2024-03-12 18:31:17 +13:00
// 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);
}
}
});
2024-03-12 18:31:17 +13:00
// add layers to control ui
L.control.groupedLayers(tileLayers, {
"Nodes": {
"All": nodesLayerGroup,
"Routers": nodesRouterLayerGroup,
2025-06-27 11:15:52 +02:00
"Backbone": nodesBackboneLayerGroup,
"Clustered": nodesClusteredLayerGroup,
"None": new L.LayerGroup(),
},
"Overlays": {
"Legend": legendLayerGroup,
"Neighbours": neighboursLayerGroup,
2025-06-27 11:15:52 +02:00
"Backbone Connection": backboneNeighboursLayerGroup,
"Waypoints": waypointsLayerGroup,
2024-08-04 19:12:51 +02:00
"Position History": nodePositionHistoryLayerGroup,
2025-08-10 15:17:04 +02:00
"Traceroutes": traceroutesLayerGroup,
},
}, {
// make the "Nodes" group exclusive (use radio inputs instead of checkbox)
exclusiveGroups: ["Nodes"],
}).addTo(map);
2024-03-12 18:31:17 +13:00
// enable base layers
nodesLayerGroup.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);
}
2025-06-27 11:15:52 +02:00
if(enabledOverlayLayers.includes("Backbone Connection")){
backboneNeighboursLayerGroup.addTo(map);
}
if(enabledOverlayLayers.includes("Waypoints")){
waypointsLayerGroup.addTo(map);
}
2024-08-04 19:12:51 +02:00
if(enabledOverlayLayers.includes("Position History")){
nodePositionHistoryLayerGroup.addTo(map);
}
2025-08-10 15:17:04 +02:00
if(enabledOverlayLayers.includes("Traceroutes")){
traceroutesLayerGroup.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);
});
2024-03-12 18:31:17 +13:00
2024-03-17 03:38:06 +13:00
// handle map clicks
2024-03-12 18:31:17 +13:00
map.on('click', function() {
2024-03-17 03:38:06 +13:00
// remove outline when map clicked
2024-03-12 18:31:17 +13:00
clearNodeOutline();
2024-03-17 03:38:06 +13:00
// send callback to vue
window._onMapClick();
2024-03-12 18:31:17 +13:00
});
// 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();
});
2024-03-12 18:31:17 +13:00
function isValidLatLng(lat, lng) {
if(isNaN(lat) || isNaN(lng)){
return false;
}
return true;
}
2024-03-13 14:03:00 +13:00
function findNodeById(id) {
// find node by id
var node = nodes.find((node) => node.node_id.toString() === id.toString());
if(node){
return node;
}
return null;
}
2024-03-12 18:31:17 +13:00
function findNodeMarkerById(id) {
// find node marker by id
var nodeMarker = nodeMarkers[id];
if(nodeMarker){
return nodeMarker;
}
return null;
}
function goToNode(id, animate, zoom){
2024-03-12 18:31:17 +13:00
2024-03-15 18:19:18 +13:00
// find node
var node = findNodeById(id);
if(!node){
alert("Could not find node: " + id);
return false;
2024-03-15 18:19:18 +13:00
}
2024-03-12 18:31:17 +13:00
// find node marker by id
var nodeMarker = findNodeMarkerById(id);
if(!nodeMarker){
return false;
2024-03-12 18:31:17 +13:00
}
// 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(), {
2024-04-04 20:55:41 +13:00
animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
2024-03-12 18:31:17 +13:00
});
2024-03-15 18:19:18 +13:00
// 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
});
2024-03-12 18:31:17 +13:00
// successfully went to node
return true;
2024-03-12 18:31:17 +13:00
}
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);
2024-03-12 18:31:17 +13:00
}
}
}
function clearAllNodes() {
nodesLayerGroup.clearLayers();
2024-03-18 10:11:48 +13:00
nodesClusteredLayerGroup.clearLayers();
2024-06-06 22:42:34 +12:00
nodesRouterLayerGroup.clearLayers();
2025-06-27 11:15:52 +02:00
nodesBackboneLayerGroup.clearLayers();
2024-03-12 18:31:17 +13:00
}
2024-03-13 15:33:35 +13:00
function clearAllNeighbours() {
neighboursLayerGroup.clearLayers();
2025-06-27 11:15:52 +02:00
backboneNeighboursLayerGroup.clearLayers();
2024-03-12 18:31:17 +13:00
}
2024-03-18 22:52:28 +13:00
function clearAllWaypoints() {
waypointsLayerGroup.clearLayers();
}
2025-08-10 15:17:04 +02:00
function clearAllTraceroutes() {
traceroutesLayerGroup.clearLayers();
}
2024-03-12 18:31:17 +13:00
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);
}
});
}
2024-08-04 19:12:51 +02:00
function clearAllPositionHistory() {
nodePositionHistoryLayerGroup.clearLayers();
}
2024-03-12 18:31:17 +13:00
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];
2024-03-12 18:31:17 +13:00
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);
}
2024-03-12 18:31:17 +13:00
}
function showNodeDetails(id) {
// find node
const node = findNodeById(id);
if(!node){
return;
}
// fire callback to vuejs handler
window._onNodeClick(node);
}
function getColourForSnr(snr) {
2025-06-26 22:35:34 +02:00
if(snr >= -5) return "#16a34a"; // good
if(snr > -15) return "#fff200"; // meh
if(snr <= -15) 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);
2025-08-10 15:17:04 +02:00
// Overlay ALL traceroute edges that terminate at this node (edge.to == node.node_id)
for (const edge of tracerouteEdgesCache) {
if (String(edge.to) !== String(node.node_id)) continue;
const fromMarker = findNodeMarkerById(edge.from);
if (!fromMarker) continue;
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
const trTooltip = (() => {
const fromNode = findNodeById(edge.from);
const toNode = findNodeById(node.node_id);
const distanceInMeters = fromMarker.getLatLng().distanceTo(nodeMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
2025-08-10 15:17:04 +02:00
return `<b>Traceroute hop</b>`
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
2025-08-10 15:17:04 +02:00
+ `<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>`;
})();
L.polyline([
fromMarker.getLatLng(),
nodeMarker.getLatLng(),
], {
color: trColour,
opacity: 0.9,
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
.addTo(nodeNeighboursLayerGroup)
.bindTooltip(trTooltip, { sticky: true, opacity: 1, interactive: true })
.bindPopup(trTooltip);
}
// 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`
2025-03-18 22:33:33 +01:00
+ `<br/>Distance: ${distance}`
2025-03-16 11:27:48 +01:00
+ (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,
});
}
}
2025-08-10 15:17:04 +02:00
// Overlay ALL traceroute edges that originate from this node (edge.from == node.node_id)
for (const edge of tracerouteEdgesCache) {
if (String(edge.from) !== String(node.node_id)) continue;
const toMarker = findNodeMarkerById(edge.to);
if (!toMarker) continue;
const snrDb = (typeof edge.snr === 'number') ? (edge.snr === -128 ? null : (Number(edge.snr) / 4)) : null;
const trColour = snrDb != null ? getColourForSnr(snrDb) : '#6b7280';
const trTooltip2 = (() => {
const fromNode = findNodeById(node.node_id);
const toNode = findNodeById(edge.to);
const distanceInMeters = nodeMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
2025-08-10 15:17:04 +02:00
return `<b>Traceroute hop</b>`
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
2025-08-10 15:17:04 +02:00
+ `<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>`;
})();
L.polyline([
nodeMarker.getLatLng(),
toMarker.getLatLng(),
], {
color: trColour,
opacity: 0.9,
}).arrowheads({ size: '10px', fill: true, offsets: { start: '25px', end: '25px' } })
.addTo(nodeNeighboursLayerGroup)
.bindTooltip(trTooltip2, { sticky: true, opacity: 1, interactive: true })
.bindPopup(trTooltip2);
}
// 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`
2025-03-18 22:33:33 +01:00
+ `<br/>Distance: ${distance}`
+ (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();
});
}
}
2024-03-12 18:31:17 +13:00
function clearMap() {
closeAllPopups();
closeAllTooltips();
clearAllNodes();
2024-03-13 15:33:35 +13:00
clearAllNeighbours();
2024-03-18 22:52:28 +13:00
clearAllWaypoints();
2025-08-10 15:17:04 +02:00
clearAllTraceroutes();
2024-03-12 18:31:17 +13:00
clearNodeOutline();
cleanUpNodeNeighbours();
2024-03-12 18:31:17 +13:00
}
// 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, "&lt;").replace(/>/g, "&gt;");
}
// 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;
}
2024-03-12 18:31:17 +13:00
function onNodesUpdated(updatedNodes) {
// clear nodes cache
nodes = [];
// get config
2024-09-08 00:09:50 +12:00
const now = moment();
const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();
2024-03-12 18:31:17 +13:00
// add nodes
2024-09-08 00:09:50 +12:00
for(const node of updatedNodes){
2024-03-12 18:31:17 +13:00
// 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;
}
2024-03-12 18:31:17 +13:00
// fix lat long
node.latitude = node.latitude / 10000000;
node.longitude = node.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip nodes with invalid position
if(!isValidLatLng(node.latitude, node.longitude)){
continue;
}
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// 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 channel preset
var icon = iconLongFast;
if (node.channel_id == "MediumFast") {
icon = iconMediumFast;
}
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// 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;
}
2024-09-08 00:09:50 +12:00
}
2024-09-08 00:09:50 +12:00
// determine if node was recently heard uplinking packets to mqtt
//const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
//if(nodeHasUplinkedToMqttRecently){
// icon = iconMqttConnected;
//}
2025-07-23 08:07:33 +02:00
// To not have overlapping nodes.
// Should probbably check if there is allready an other node in the same position before applying jitter.
var jitter = 0
if (node.position_precision != 32) {
jitter = 0.0005 * (Math.random() - 0.5);
}
2024-03-15 15:16:47 +13:00
2024-09-08 00:09:50 +12:00
// create node marker
2025-07-23 08:07:33 +02:00
const marker = L.marker([node.latitude + jitter, longitude + jitter], {
2024-09-08 00:09:50 +12:00
icon: icon,
tagName: node.node_id,
// we want to show online nodes above offline, but without needing to use separate layer groups
2025-08-15 21:21:11 +02:00
//zIndexOffset: nodeHasUplinkedToMqttRecently ? 1000 : -1000,
2024-09-08 00:09:50 +12:00
}).on('click', function(event) {
// close tooltip on click to prevent tooltip and popup showing at same time
event.target.closeTooltip();
});
2024-09-08 00:09:50 +12:00
// add marker to node layer groups
marker.addTo(nodesLayerGroup);
nodesClusteredLayerGroup.addLayer(marker);
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// 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"
2024-09-08 00:09:50 +12:00
|| node.role_name === "REPEATER"){
nodesRouterLayerGroup.addLayer(marker);
}
2024-03-15 15:31:50 +13:00
2025-06-27 11:15:52 +02:00
// add markers for backbone to layer group
if(node.is_backbone) {
nodesBackboneLayerGroup.addLayer(marker);
}
2024-09-08 00:09:50 +12:00
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(getTooltipContentForNode(node), {
interactive: true,
});
}
2024-09-08 00:09:50 +12:00
// show node info tooltip when clicking node marker
marker.on("click", function(event) {
2024-09-08 00:09:50 +12:00
// close all other popups and tooltips
closeAllTooltips();
closeAllPopups();
2024-09-08 00:09:50 +12:00
// find node
const node = findNodeById(event.target.options.tagName);
if(!node){
return;
}
2024-09-08 00:09:50 +12:00
// show position precision outline
showNodeOutline(node.node_id);
2024-09-08 00:09:50 +12:00
// 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
});
2024-09-08 00:09:50 +12:00
});
2024-03-12 18:31:17 +13:00
2024-09-08 00:09:50 +12:00
// add to cache
nodeMarkers[node.node_id] = marker;
2024-03-12 18:31:17 +13:00
}
2024-09-08 00:09:50 +12:00
for(const node of updatedNodes){
2024-03-13 14:03:00 +13:00
// find current node
const currentNode = findNodeMarkerById(node.node_id);
if(!currentNode){
continue;
}
2024-03-13 15:33:35 +13:00
// add node neighbours
2024-03-23 23:36:29 +13:00
const neighbours = node.neighbours ?? [];
2024-03-13 14:03:00 +13:00
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;
}
// Check our neighour also has us as a neighbour.
const matchingNode = updatedNodes.find(n => n.node_id == neighbour.node_id);
const symmetrical = matchingNode?.neighbours?.some(n => String(n.node_id) === String(node.node_id)) ?? false;
// add neighbour line to map
2024-03-13 14:03:00 +13:00
const line = L.polyline([
currentNode.getLatLng(),
neighbourNodeMarker.getLatLng(),
], {
color: '#2563eb',
opacity: 0.75,
// if we have a symmetrical connection, offset the the line so they don't overlapp
offset: symmetrical ? 3 : 0,
2024-03-13 15:33:35 +13:00
}).addTo(neighboursLayerGroup);
2024-03-13 14:03:00 +13:00
2025-06-27 11:15:52 +02:00
// additional line for backbone neighbours
const backboneNeighbourLine = L.polyline([
currentNode.getLatLng(),
neighbourNodeMarker.getLatLng(),
], {
color: getColourForSnr(neighbour.snr),
opacity: 0.75,
offset: symmetrical ? 3 : 0,
}).arrowheads({
size: '10px',
fill: true,
offsets: {
start: '25px',
end: '25px',
},
})
// If both nodes are backbone nodes add to layer group
if(node.is_backbone && updatedNodes.find(n => n.node_id == neighbour.node_id)?.is_backbone) {
backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
}
2024-03-15 10:19:30 +13:00
// default to showing distance in meters
var distance = `${distanceInMeters} meters`;
// scale to distance in kms
if(distanceInMeters >= 1000){
const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
2024-03-15 10:26:07 +13:00
distance = `${distanceInKilometers} kilometers`;
2024-03-15 10:19:30 +13:00
}
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>`
2024-03-13 14:03:00 +13:00
+ `<br/>SNR: ${neighbour.snr}dB`
2025-03-18 22:33:33 +01:00
+ `<br/>Distance: ${distance}`
2024-03-23 23:36:29 +13:00
+ (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>`;
2024-03-13 14:03:00 +13:00
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();
});
2025-06-27 11:15:52 +02:00
backboneNeighbourLine.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();
});
2024-03-13 14:03:00 +13:00
}
}
}
2024-03-17 03:38:06 +13:00
window._onNodesUpdated(nodes);
2024-03-12 18:31:17 +13:00
}
2024-03-18 22:52:28 +13:00
function onWaypointsUpdated(updatedWaypoints) {
// clear nodes cache
waypoints = [];
2024-09-08 00:09:50 +12:00
// get config
const now = moment();
const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();
2024-03-18 22:52:28 +13:00
// add nodes
2024-09-08 00:09:50 +12:00
for(const waypoint of updatedWaypoints){
2024-03-18 22:52:28 +13:00
// skip waypoints older than configured waypoint max age
if(configWaypointsMaxAgeInSeconds){
const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
continue;
}
}
2024-03-18 22:52:28 +13:00
// skip expired waypoints
if(waypoint.expire < Date.now() / 1000){
continue;
}
2024-09-08 00:09:50 +12:00
// skip waypoints without position
2024-03-18 22:52:28 +13:00
if(!waypoint.latitude || !waypoint.longitude){
continue;
}
// fix lat long
waypoint.latitude = waypoint.latitude / 10000000;
waypoint.longitude = waypoint.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip waypoints with invalid position
if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
continue;
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// 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;
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// 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();
});
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// show tooltip on desktop only
if(!isMobile()){
marker.bindTooltip(tooltip, {
interactive: true,
2024-03-18 22:52:28 +13:00
});
2024-09-08 00:09:50 +12:00
}
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// add marker to waypoints layer groups
marker.addTo(waypointsLayerGroup);
2024-03-18 22:52:28 +13:00
2024-09-08 00:09:50 +12:00
// add to cache
waypoints.push(waypoint);
2024-03-18 22:52:28 +13:00
}
}
2025-08-10 15:17:04 +02:00
function onTracerouteEdgesUpdated(edges) {
traceroutesLayerGroup.clearLayers();
tracerouteEdgesCache = edges;
for (const edge of edges) {
// Convert SNR for traceroutes: snr/4 dB; -128 means unknown
const snrDb = (typeof edge.snr === 'number')
? (edge.snr === -128 ? null : (Number(edge.snr) / 4))
: null;
const fromNode = findNodeById(edge.from);
const toNode = findNodeById(edge.to);
if (!fromNode || !toNode) continue;
const fromMarker = findNodeMarkerById(edge.from);
const toMarker = findNodeMarkerById(edge.to);
if (!fromMarker || !toMarker) continue;
const distanceInMeters = fromMarker.getLatLng().distanceTo(toMarker.getLatLng()).toFixed(2);
let distance = `${distanceInMeters} meters`;
if (distanceInMeters >= 1000) {
const km = (distanceInMeters / 1000).toFixed(2);
distance = `${km} kilometers`;
}
const colour = '#f97316';
const terrainImageUrl = getTerrainProfileImage(fromNode, toNode);
// This is backwards. It's because the traceroute packet is sent from the target node.
const targetNode = edge.traceroute_from ? findNodeById(edge.traceroute_from) : null;
const initiatorNode = edge.traceroute_to ? findNodeById(edge.traceroute_to) : null;
const tooltip = `<b>Traceroute hop</b>`
2025-08-10 15:17:04 +02:00
+ `<br/>from <b>[${escapeString(fromNode.short_name)}] ${escapeString(fromNode.long_name)}</b>`
+ ` to <b>[${escapeString(toNode.short_name)}] ${escapeString(toNode.long_name)}</b>`
+ `<br/>SNR: ${snrDb != null ? snrDb + 'dB' : '?'}`
+ `<br/>Distance: ${distance}`
+ (initiatorNode ? `<br/>Traceroute from: <b>[${escapeString(initiatorNode.short_name)}] ${escapeString(initiatorNode.long_name)}</b>` : '')
+ (targetNode ? `<br/>Traceroute to: <b>[${escapeString(targetNode.short_name)}] ${escapeString(targetNode.long_name)}</b>` : '')
2025-08-10 15:17:04 +02:00
+ (edge.updated_at ? `<br/>Updated: ${moment(new Date(edge.updated_at)).fromNow()}` : '')
+ (edge.channel_id ? `<br/>Channel: ${edge.channel_id}` : '')
+ `<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>`;
const line = L.polyline([
fromMarker.getLatLng(),
toMarker.getLatLng(),
], {
color: colour,
opacity: 0.9,
}).addTo(traceroutesLayerGroup);
line.bindTooltip(tooltip, {
sticky: true,
opacity: 1,
interactive: true,
}).bindPopup(tooltip)
.on('click', function(event) {
event.target.closeTooltip();
});
}
}
2024-08-04 19:12:51 +02:00
function onPositionHistoryUpdated(updatedPositionHistories) {
2024-08-04 19:12:51 +02:00
let positionHistoryLinesCords = [];
// add nodes
2024-09-08 00:09:50 +12:00
for(const positionHistory of updatedPositionHistories) {
2024-08-04 19:12:51 +02:00
// skip position history without position
2024-08-04 19:12:51 +02:00
if(!positionHistory.latitude || !positionHistory.longitude){
continue;
}
2024-08-29 00:19:23 +12:00
// find node this position is for
const node = findNodeById(positionHistory.node_id);
if(!node){
continue;
}
2024-08-04 19:12:51 +02:00
// fix lat long
positionHistory.latitude = positionHistory.latitude / 10000000;
positionHistory.longitude = positionHistory.longitude / 10000000;
2024-09-08 00:09:50 +12:00
// skip position history with invalid position
if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
continue;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// 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;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
positionHistoryLinesCords.push([positionHistory.latitude, longitude]);
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
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>`;
}
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// 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();
});
2024-08-04 19:12:51 +02:00
2024-09-08 00:09:50 +12:00
// add marker to position history layer group
marker.addTo(nodePositionHistoryLayerGroup);
2024-08-04 19:12:51 +02:00
}
// show lines between position history markers
L.polyline(positionHistoryLinesCords, {
color: "#a855f7",
opacity: 1,
}).addTo(nodePositionHistoryLayerGroup);
2024-08-04 19:12:51 +02:00
}
function cleanUpPositionHistory() {
// close tooltips and popups
closeAllPopups();
closeAllTooltips();
// setup node neighbours layer
nodePositionHistoryLayerGroup.clearLayers();
nodePositionHistoryLayerGroup.removeFrom(map);
nodePositionHistoryLayerGroup.addTo(map);
}
2024-03-12 18:31:17 +13:00
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) {
2024-03-12 18:31:17 +13:00
// show loading
setLoading(true);
2024-03-18 22:52:28 +13:00
// clear previous data
clearMap();
2024-03-12 18:31:17 +13:00
// fetch nodes
2024-09-07 23:45:53 +12:00
await window.axios.get('/api/v1/nodes').then(async (response) => {
2024-03-12 18:31:17 +13:00
// update nodes
2024-09-07 23:45:53 +12:00
onNodesUpdated(response.data.nodes);
2024-03-12 18:31:17 +13:00
// 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);
2024-03-12 18:31:17 +13:00
}
});
2024-03-18 23:16:54 +13:00
// fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
2024-09-07 23:45:53 +12:00
await window.axios.get('/api/v1/waypoints').then(async (response) => {
onWaypointsUpdated(response.data.waypoints);
2024-03-18 22:52:28 +13:00
});
2025-08-10 15:17:04 +02:00
// fetch traceroute edges
const traceroutesMaxAgeSec = getConfigTraceroutesMaxAgeInSeconds();
const timeFrom = traceroutesMaxAgeSec ? (Date.now() - traceroutesMaxAgeSec * 1000) : undefined;
const params = new URLSearchParams();
if (timeFrom) params.set('time_from', timeFrom);
await window.axios.get(`/api/v1/traceroutes?${params.toString()}`).then(async (response) => {
onTracerouteEdgesUpdated(response.data.traceroute_edges ?? []);
}).catch(() => {
onTracerouteEdgesUpdated([]);
});
2024-03-12 18:31:17 +13:00
}
2024-04-02 11:31:46 +13:00
function getRegionFrequencyRange(regionName) {
2024-04-01 03:20:15 +13:00
// 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",
2024-04-01 03:20:15 +13:00
}
return regionNameToLoraFrequencyRange[regionName] ?? null;
2024-04-02 11:31:46 +13:00
}
2024-08-26 18:14:16 +12:00
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);
2024-08-26 18:28:26 +12:00
if(positionPrecisionInMeters == null){
2024-08-26 18:14:16 +12:00
return "?";
}
// format kilometers
if(positionPrecisionInMeters > 1000){
const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
return `±${positionPrecisionInKilometers}km`;
}
// format meters
return `±${positionPrecisionInMeters}m`;
}
2024-04-02 11:31:46 +13:00
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>`;
}
2024-04-02 11:31:46 +13:00
}
var loraFrequencyRange = getRegionFrequencyRange(node.region_name);
2024-04-01 03:20:15 +13:00
2024-03-15 18:19:18 +13:00
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)}` +
2024-08-20 19:07:58 +12:00
`<br/>MQTT: ${mqttStatus}` +
2024-04-02 11:38:23 +13:00
(node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
2024-08-26 18:14:16 +12:00
(node.position_precision != null && node.position_precision !== 32 ? `<br/>Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
2024-03-15 18:19:18 +13:00
`<br/><br/>Role: ${node.role_name}` +
2024-04-01 01:52:45 +13:00
`<br/>Hardware: ${node.hardware_model_name}` +
2025-07-23 08:05:29 +02:00
(node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
`<br/>OK to MQTT: ${node.ok_to_mqtt}`;
2024-03-15 18:19:18 +13:00
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)}%`;
}
2024-04-07 20:13:32 +12:00
// 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`;
}
2024-03-15 18:19:18 +13:00
// 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>`;
2024-07-06 00:34:07 +12:00
tooltip += `</div>`;
2024-03-15 18:19:18 +13:00
return tooltip;
}
2024-03-18 22:52:28 +13:00
function getTooltipContentForWaypoint(waypoint) {
2024-03-18 23:16:54 +13:00
// get from node name
var fromNode = findNodeById(waypoint.from);
var tooltip = `<b>${escapeString(waypoint.name)}</b>` +
(waypoint.description ? `<br/>${escapeString(waypoint.description)}` : '') +
2024-03-18 22:52:28 +13:00
`<br/><br/>Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
2024-03-18 23:16:54 +13:00
`<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>`;
2024-03-18 23:16:54 +13:00
} else {
tooltip += `<br/>From Node: ???`;
}
2024-03-18 22:52:28 +13:00
// 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();
};
2024-03-12 18:31:17 +13:00
// 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,
});
}
2024-03-12 18:31:17 +13:00
// 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());
}
});
2024-03-12 18:31:17 +13:00
// reload and go to provided node id
reload(queryNodeId, queryZoom);
2024-03-12 18:31:17 +13:00
</script>
2024-03-13 17:21:18 +13:00
2025-03-16 11:02:27 +01:00
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-2RD5193D15"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-2RD5193D15');
</script>
2024-03-12 18:31:17 +13:00
</body>
</html>