<html>
<head>

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <title>STHLM-MESH MAP</title>
    <meta name="title" content="Meshtastic Map">
    <meta name="description" content="An interactive map of all Meshtastic nodes.">
    <link rel="icon" type="image/png" href="/icon.png"/>

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website">
    <meta property="og:url" content="https://map.sthlm-mesh.se">
    <meta property="og:title" content="STHLM-MESH MAP">
    <meta property="og:description" content="An interactive map of all Meshtastic nodes.">

    <!-- tailwind css -->
    <script src="assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>

    <!-- leaflet map -->
    <link rel="stylesheet" href="assets/js/leaflet@1.9.3/dist/leaflet.css" />
    <script src="assets/js/leaflet@1.9.3/dist/leaflet.js"></script>

    <!-- leaflet plugins -->
    <script src="assets/js/leaflet-plugins/leaflet.polylineoffset.js"></script>
    <script src="assets/js/leaflet-plugins/leaflet.geometryutil.js"></script>
    <script src="assets/js/leaflet-plugins/leaflet-arrowheads.js"></script>

    <!-- leaflet markercluster -->
    <script src="assets/js/leaflet-plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
    <link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.css"/>
    <link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.markercluster/MarkerCluster.Default.css"/>

    <!-- leaflet groupedlayercontrol -->
    <script src="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.js"></script>
    <link rel="stylesheet" href="assets/js/leaflet-plugins/leaflet.groupedlayercontrol/leaflet.groupedlayercontrol.css"/>

    <!-- moment -->
    <script src="assets/js/moment@2.29.1/moment.min.js"></script>

    <!-- vuejs -->
    <script src="assets/js/vue@3.4.26/dist/vue.global.js"></script>

    <!-- axios -->
    <script src="assets/js/axios@1.6.8/dist/axios.min.js"></script>

    <!-- chart js -->
    <script src="assets/js/chart.js@4.4.2/dist/chart.umd.js"></script>
    <script src="assets/js/chartjs-adapter-moment/chartjs-adapter-moment.js"></script>

    <style>

        /* used to prevent ui flicker before vuejs loads */
        [v-cloak] {
            display: none;
        }

        .icon-longfast {
            background-color: #8c929e;
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-mediumfast {
            background-color: #326be7; 
            border-radius: 25px;
            border: 1px solid white;
        }
        .icon-mqtt-connected {
            background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-mqtt-disconnected {
            background-color: #2563eb;
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-offline {
            background-color: #dc2626;
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-position-history {
            background-color: #a855f7;
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-traceroute-start {
            background-color: #16a34a; /* green */
            border-radius: 25px;
            border: 1px solid white;
        }

        .icon-traceroute-end {
            background-color: #dc2626; /* red */
            border-radius: 25px;
            border: 1px solid white;
        }

        .waypoint-label {
            font-size: 26px;
            background-color: transparent;
        }

        .link {
            color: #2563eb;
        }

        .link:hover {
            text-decoration: underline;
        }

        .tooltip {
            position: relative;
            display: inline-block;
        }

        .tooltip .tooltip-text {
            visibility: hidden;
            width: 80px;
            background-color: black;
            color: #fff;
            text-align: center;
            padding: 4px 0;
            border-radius: 6px;
            position: absolute;
            z-index: 10000;
            top: 100%;
            left: 50%;
            margin-top: 8px;
            margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */
        }

        .tooltip .tooltip-text::after {
            content: " ";
            position: absolute;
            bottom: 100%;  /* At the top of the tooltip */
            left: 50%;
            margin-left: -5px;
            border-width: 5px;
            border-style: solid;
            border-color: transparent transparent black transparent;
        }

        .tooltip:hover .tooltip-text {
            visibility: visible;
        }

        .z-search {
            z-index: 1001;
        }

        .z-sidebar {
            z-index: 1002;
        }

    </style>

</head>
<body class="h-full bg-gray-200">
<div id="app" v-cloak>

    <div class="flex flex-col h-full w-full overflow-hidden">
        <div class="flex flex-col h-full">

            <!-- announcement -->
            <div v-if="isShowingAnnouncement" class="flex bg-yellow-300 p-2 border-gray-300 border-b">

                <!-- info -->
                <div class="my-auto leading-tight">
                    <div class="font-bold">Viktigt!</div>
                    <div class="text-sm">
                        <span>I Stockholm används LoRa preset <b>Medium Range - Fast</b>. För mer info <a href="https://sthlm-mesh.se/blog/2025/övergång-till-mediumfast-den-27-september/" type="button" class="link">klicka här</a>.</span>
                    </div>
                </div>

                <!-- action buttons -->
                <div class="flex my-auto ml-auto mr-0 sm:mr-2">
                    <a @click="dismissAnnouncement" href="javascript:void(0)" class="rounded-full">
                        <div class="bg-white hover:bg-gray-100 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="M6 18 18 6M6 6l12 12" />
                            </svg>
                        </div>
                    </a>
                </div>

            </div>

            <!-- header -->
            <div class="flex p-3 h-16" style="background-color: 30a552;">

                <!-- close mobile search button -->
                <div v-if="isShowingMobileSearch" class="my-auto">
                    <a @click="isShowingMobileSearch = false" href="javascript:void(0)" class="rounded-full">
                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                                <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
                            </svg>
                        </div>
                    </a>
                </div>

                <!-- icon -->
                <div v-if="!isShowingMobileSearch" class="hidden sm:block my-auto mr-2 ml-2">
                    <img class="w-8 h-8 rounded" src="icon.png"/>
                </div>

                <!-- app info -->
                <div v-if="!isShowingMobileSearch" class="my-auto leading-tight">
                    <a href="https://sthlm-mesh.se"><div class="font-bold" style="color: #ffffff; font-size: 1.25rem;">STHLM-MESH</div></a>
                </div>

                <!-- search bar -->
                <div class="mx-3 flex-1 relative" :class="{ 'hidden lg:block': !isShowingMobileSearch }">
                    <input v-model="searchText" type="text" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :placeholder="`Search ${nodes.length} nodes...`">
                    <div v-if="searchText !== ''" class="absolute z-search bg-white w-full border border-gray-200 rounded-lg shadow-md mt-1 overflow-y-scroll max-h-80 divide-y divide-gray-200">

                        <template v-if="searchedNodes.length > 0">
                            <div @click="onSearchResultNodeClick(node)" class="flex space-x-2 p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
                                <div>
                                    <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(node.node_id)}]`, `text-[${getNodeTextColour(node.node_id)}]` ]">
                                        <div class="mx-auto my-auto drop-shadow-sm">{{ node.short_name }}</div>
                                    </div>
                                </div>
                                <div>
                                    <div class="text-gray-900" :class="{ 'text-red-500': node.latitude == null || node.longitude == null }">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
                                    <div class="flex space-x-1 text-sm text-gray-700">
                                        <div>{{ node.node_id_hex }} / {{ node.node_id }}</div>
                                    </div>
                                </div>
                            </div>
                            <div v-if="searchedNodes.length === 500" class="text-gray-500 text-sm px-2 py-1">
                                Only the first 500 results are shown.
                            </div>
                        </template>

                        <template v-else>
                            <div class="p-2">
                                No results found...
                            </div>
                        </template>

                    </div>
                </div>

                <!-- header action buttons -->
                <div v-if="!isShowingMobileSearch" class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
                    <a @click="isShowingInfoModal = true" href="javascript:void(0)" class="tooltip rounded-full">
                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                                <path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">About</span>
                        </div>
                    </a>
                    <a @click="isShowingMobileSearch = true" href="javascript:void(0)" class="tooltip rounded-full block lg:hidden">
                        <div id="search-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                <path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path>
                                <path d="M21 21l-6 -6"></path>
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">Search</span>
                        </div>
                    </a>
                    <a @click="isShowingSettings = true" href="javascript:void(0)" class="tooltip rounded-full">
                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                                <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
                                <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">Settings</span>
                        </div>
                    </a>
                    <a href="#" class="tooltip rounded-full" onclick="reload()">
                        <div id="reload-button" class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                <path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
                                <path d="M20 4v5h-5"></path>
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">Reload</span>
                        </div>
                    </a>
                </div>

            </div>

            <!-- map -->
            <div id="map" style="width:100%;height:100%;"></div>

        </div>
    </div>

    <!-- info modal -->
    <div class="relative z-sidebar">

        <!-- overlay -->
        <transition
                enter-active-class="transition-opacity duration-300 ease-linear"
                enter-from-class="opacity-0"
                enter-to-class="opacity-100"
                leave-active-class="transition-opacity duration-300 ease-linear"
                leave-from-class="opacity-100"
                leave-to-class="opacity-0">
            <div v-show="isShowingInfoModal" @click="dismissInfoModal" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
        </transition>

        <!-- modal -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="translate-y-full"
                enter-to-class="translate-y-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-y-0"
                leave-to-class="translate-y-full">
            <div @click="dismissInfoModal" v-show="isShowingInfoModal" class="fixed left-0 right-0 top-0 bottom-0 lg:pointer-events-none">
                <div class="flex w-full h-full overflow-y-auto p-4">
                    <div @click.stop class="mx-auto my-auto w-full max-w-2xl flex-col bg-white shadow-xl rounded-xl p-2 lg:pointer-events-auto">
                        <div class="relative flex">

                            <!-- close button -->
                            <div class="absolute top-0 right-0">
                                <div class="h-7">
                                    <a href="javascript:void(0)" class="rounded-full" @click="dismissInfoModal">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>

                            <!-- content -->
                            <div class="flex flex-col w-full py-2 space-y-2">

                                <!-- app info -->
                                <div class="w-full mx-auto text-center">
                                    <img src="./icon.png" class="mx-auto w-16 h-16 rounded mb-1"/>
                                    <h1 class="font-bold">Meshtastic Map</h1>
                                    <h2>Created by <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a></h2>
                                    <h2>Forked by <a class="link" target="_blank" href="http://github.com/Roslund/">Roslund</a></h2>
                                </div>

                                <!-- Beskrivning -->
                                <div>
                                    <div class="font-bold mb-2">Beskrivning</div>
                                    <div class="space-y-2">
                                        <div class="bg-gray-100 rounded p-2 border border-gray-200">
                                            <div>Detta är en karta som enbart fokuserar på Stockholm.</div>
                                            <div>Den är baserad på Liam Cottle's open source projekt Meshtastic Map, men har flertalet ändringar och nya funktioner som gör att vi bättre kan analysera Meshen i Stockholm.</div>
                                        </div>
                                    </div>
                                    <br>
                                    <div class="font-bold mb-2">Frågor och svar</div>
                                    <div class="space-y-2">
                                        <div class="bg-gray-100 rounded p-2 border border-gray-200">
                                            <div class="font-semibold">Hur får jag min nod att synas på kartan?</div>
                                            <div>Din nod behöver anting ha en GPS, ha en fast position inställd, eller att din telefon delar sin position.</div> 
                                            <div>Utöver detta måste platsdelning vara påslåget under kanalinställningarna för MediumFast kanalen (vanligtvis kanal 0).</div>
                                        </div>
                                        <div class="bg-gray-100 rounded p-2 border border-gray-200">
                                            <div class="font-semibold">Min nod är på fel plats på kartan</div>
                                            <div>Detta är troligtvis för att din nod inte delar <b>exakt position</b>. Som standard är positionsprecisionen inställd på &plusmn; 3 km, vilket betyder att noden kan befinna sig inom en cirkel med radien 3 kilometer.</div>
                                            <div>Du kan ändra positions precisionen i kanalinställningarna. För mer info om positions precisionen, <a href="https://sthlm-mesh.se/docs/position/#position-precision">klicka här</a>.</div>
                                        </div>
                                        <div class="bg-gray-100 rounded p-2 border border-gray-200">
                                            <div class="font-semibold">Hur kan jag ansluta min nod till MQTT servern?</div>
                                            <div>Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla. Endast ett fåtal noder är uppkopplade till MQTT för att kunna analysera trafiken som faktiskt går över LoRa.</div>
                                            <div>De noder som är kopplade mot MQTT servern bör:</div> 
                                            <ul class="list-disc list-inside">
                                                <li>Vara på en unik geografisk plats, då vi vill se hur trafiken fördelas</li>
                                                <li>Ha en stabil fast koppling till internet (via Ethernet eller WiFi)</li>
                                                <li>Ha hög tillgänglighet</li>
                                                <li>Ha direktkontakt med flertalet andra noder</li>
                                            </ul>
                                            <div>Tror du att din nod kan bidra, kontakta @Roslund på Discord.</div>
                                        </div>
                                    </div>
                                </div>
                                <!-- legal -->
                                <div>
                                    <div class="font-bold mb-2">Legal</div>
                                    <div class="bg-gray-100 rounded p-2 border border-gray-200">
                                        <div>This project is not affiliated with or endorsed by the <a class="link" target="_blank" href="https://meshtastic.org">Meshtastic</a> project.</div>
                                        <div>The Meshtastic logo is the trademark of Meshtastic LLC.</div>
                                        <div>Map tiles provided by <a class="link" target="_blank" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a></div>
                                    </div>
                                </div>

                                <!-- dismiss button -->
                                <div class="mx-auto">
                                    <a href="javascript:void(0)" @click="dismissInfoModal">
                                        <div class="bg-gray-200 hover:bg-gray-300 px-6 py-2 rounded-md shadow">
                                            Dismiss
                                        </div>
                                    </a>
                                </div>

                            </div>

                        </div>
                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- hardware models sidebar -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- overlay -->
        <transition
                enter-active-class="transition-opacity duration-300 ease-linear"
                enter-from-class="opacity-0"
                enter-to-class="opacity-100"
                leave-active-class="transition-opacity duration-300 ease-linear"
                leave-from-class="opacity-100"
                leave-to-class="opacity-0">
            <div v-show="isShowingHardwareModels" @click="isShowingHardwareModels = !isShowingHardwareModels" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
        </transition>

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="-translate-x-full"
                enter-to-class="translate-x-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-x-0"
                leave-to-class="-translate-x-full">
            <div v-show="isShowingHardwareModels" class="fixed top-0 left-0 bottom-0">
                <div class="w-screen max-w-md overflow-hidden">
                    <div class="flex h-full flex-col bg-white shadow-xl">

                        <!-- slideover header -->
                        <div class="p-2 border-b border-gray-200 shadow">
                            <div class="flex items-start justify-between">
                                <div>
                                    <h2 class="font-bold">Meshtastic Devices</h2>
                                    <h3 class="text-sm">Ordered by most popular</h3>
                                </div>
                                <div class="my-auto ml-3 flex h-7 items-center">
                                    <a href="javascript:void(0)" class="rounded-full" @click="isShowingHardwareModels = !isShowingHardwareModels">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                        </div>

                        <!-- list of hardware models -->
                        <ul role="list" class="flex-1 divide-y divide-gray-200 overflow-y-auto">
                            <li v-for="hardwareModel of hardwareModelStats">
                                <div class="group relative flex items-center">
                                    <a href="#" class="block flex-1 px-4 py-2">
                                        <div class="absolute inset-0 group-hover:bg-gray-100" aria-hidden="true"></div>
                                        <div class="relative flex min-w-0 flex-1 items-center">
                                            <span class="relative inline-block flex-shrink-0 mr-4">
                                                <img class="h-20 w-20 rounded object-contain" :src="`/images/devices/${hardwareModel.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
                                            </span>
                                            <div class="truncate">
                                                <p class="truncate text-sm font-medium text-gray-900">{{ hardwareModel.hardware_model_name }}</p>
                                                <p class="truncate text-sm text-gray-500">{{ hardwareModel.count }} nodes on the map</p>
                                            </div>
                                        </div>
                                    </a>
                                </div>
                            </li>
                        </ul>

                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- node info sidebar -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- overlay -->
        <transition
                enter-active-class="transition-opacity duration-300 ease-linear"
                enter-from-class="opacity-0"
                enter-to-class="opacity-100"
                leave-active-class="transition-opacity duration-300 ease-linear"
                leave-from-class="opacity-100"
                leave-to-class="opacity-0">
            <div v-show="selectedNode != null" @click="selectedNode = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
        </transition>

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="-translate-x-full"
                enter-to-class="translate-x-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-x-0"
                leave-to-class="-translate-x-full">
            <div v-show="selectedNode != null" class="fixed top-0 left-0 bottom-0">
                <div v-if="selectedNode != null" class="w-screen max-w-md overflow-hidden">
                    <div class="flex h-full flex-col bg-white shadow-xl">

                        <!-- slideover header -->
                        <div class="p-2 border-b border-gray-200 shadow">
                            <div class="flex">
                                <div class="my-auto mr-2">
                                    <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedNode.node_id)}]`, `text-[${getNodeTextColour(selectedNode.node_id)}]` ]">
                                        <div class="mx-auto my-auto drop-shadow-sm">{{ selectedNode.short_name }}</div>
                                    </div>
                                </div>
                                <div class="my-auto mr-auto">
                                    <h2 class="font-bold">Node Info</h2>
                                    <h3 class="text-sm">{{ selectedNode.long_name }}</h3>
                                </div>
                                <div class="my-auto ml-2 flex h-7 items-center space-x-2">
                                    <a href="javascript:void(0)" class="rounded-full" @click="copyShareLinkForNode(selectedNode.node_id)">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6">
                                                <path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path>
                                            </svg>
                                        </div>
                                    </a>
                                    <a href="javascript:void(0)" class="rounded-full" @click="selectedNode = null">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                        </div>

                        <div class="overflow-y-auto">

                            <!-- no position banner -->
                            <div v-if="findNodeMarkerById(selectedNode.node_id) == null" class="flex bg-orange-500 text-white p-2">
                                <div class="my-auto mr-2">
                                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
                                        <path fill-rule="evenodd" d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd" />
                                    </svg>
                                </div>
                                <div class="my-auto">This node has not reported a position.</div>
                            </div>

                            <div class="flex flex-col my-2">
                                <div class="mx-auto">
                                    <img class="h-48 w-48 rounded object-contain" :src="`/images/devices/${selectedNode.hardware_model_name}.png`" alt="" onerror="if(this.src != '/images/no_image.png') this.src = '/images/no_image.png';">
                                </div>
                            </div>

                            <!-- action buttons -->
                            <div class="flex space-x-6 p-2 justify-center">

                                <!-- sent messages -->
                                <a target="_blank" :href="`/api/v1/text-messages/embed?from=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent from this Node">
                                    <div class="flex mx-auto mb-1">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
                                            </svg>
                                        </div>
                                    </div>
                                    <div class="mx-auto text-sm font-medium text-gray-900">Sent Msgs</div>
                                </a>

                                <!-- received messages -->
                                <a target="_blank" :href="`/api/v1/text-messages/embed?to=${selectedNode.node_id}`" class="flex flex-col" title="Messages sent to this Node">
                                    <div class="flex mx-auto mb-1">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="m9 12.75 3 3m0 0 3-3m-3 3v-7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
                                            </svg>
                                        </div>
                                    </div>
                                    <div class="mx-auto text-sm font-medium text-gray-900">Received Msgs</div>
                                </a>

                                <!-- gated messages -->
                                <a target="_blank" :href="`/api/v1/text-messages/embed?gateway_id=${selectedNode.node_id}`" class="flex flex-col" title="Messages gated to MQTT by this Node">
                                    <div class="flex mx-auto mb-1">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
                                            </svg>
                                        </div>
                                    </div>
                                    <div class="mx-auto text-sm font-medium text-gray-900">Gated Msgs</div>
                                </a>

                            </div>

                            <!-- details -->
                            <div>
                                <div class="bg-gray-200 p-2 font-semibold">Details</div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">

                                    <!-- id -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">ID</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id }}</div>
                                    </li>

                                    <!-- hex id -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Hex ID</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ selectedNode.node_id_hex }}</div>
                                    </li>

                                    <!-- role -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Role</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ selectedNode.role_name }}</div>
                                    </li>

                                    <!-- hardware -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Hardware</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</div>
                                    </li>

                                    <!-- firmware version -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Firmware</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.firmware_version">{{ selectedNode.firmware_version }}</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                            <!-- position -->
                            <div>
                                <div @click.stop class="flex bg-gray-200 p-2 font-semibold">
                                    <div class="my-auto">Position</div>
                                    <div class="ml-auto">
                                        <button @click="showNodePositionHistory(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
                                            Show History
                                        </button>
                                    </div>
                                </div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">

                                    <!-- position -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Lat/Long</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.latitude && selectedNode.longitude">{{ selectedNode.latitude }}, {{ selectedNode.longitude }}</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- altitude -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Altitude</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.altitude">{{ selectedNode.altitude }}m</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                            <!-- device metrics -->
                            <div>
                                <div class="flex bg-gray-200 p-2 font-semibold">
                                    <div class="my-auto">Device Metrics</div>
                                    <div class="my-auto ml-auto">
                                        <select v-model="deviceMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
                                            <option value="1d">1 Day</option>
                                            <option value="3d">3 Days</option>
                                            <option value="7d">7 Days</option>
                                            <option value="30d">30 Days</option>
                                        </select>
                                    </div>
                                </div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">

                                    <!-- device metrics chart -->
                                    <li>
                                        <div class="px-4 py-2">
                                            <div class="w-full">
                                                <canvas id="deviceMetricsChart" style="height:150px;"></canvas>
                                                <div class="flex">
                                                    <div class="mx-auto flex space-x-2">
                                                        <div class="flex mx-auto">
                                                            <div class="my-auto w-2 h-2 bg-blue-500 rounded-full"></div>
                                                            <div class="my-auto ml-1 text-sm text-gray-500">Battery Level</div>
                                                        </div>
                                                        <div class="flex mx-auto">
                                                            <div class="my-auto w-2 h-2 bg-green-500 rounded-full"></div>
                                                            <div class="my-auto ml-1 text-sm text-gray-500">Channel Utilization</div>
                                                        </div>
                                                        <div class="flex mx-auto">
                                                            <div class="my-auto w-2 h-2 bg-orange-500 rounded-full"></div>
                                                            <div class="my-auto ml-1 text-sm text-gray-500">Air Util TX</div>
                                                        </div>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <!-- battery level -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Battery Level</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.battery_level">
                                                <span v-if="selectedNode.battery_level > 100">Plugged In</span>
                                                <span v-else>{{ selectedNode.battery_level }}%</span>
                                            </span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- voltage -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Voltage</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- channel utilization -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Channel Utilization</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- air util tx -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Air Util Tx</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- air util tx -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Uptime</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.uptime_seconds">{{ formatUptimeSeconds(selectedNode.uptime_seconds) }}</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                            <!-- environment metrics -->
                            <div>
                                <div class="flex bg-gray-200 p-2 font-semibold">
                                    <div class="my-auto">Environment Metrics</div>
                                    <div class="my-auto ml-auto">
                                        <select v-model="environmentMetricsTimeRange" class="block w-full rounded-md border-0 py-0.5 pl-2 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6">
                                            <option value="1d">1 Day</option>
                                            <option value="3d">3 Days</option>
                                            <option value="7d">7 Days</option>
                                            <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>
                                                        <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>
                                            <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>
                                    <div class="text-sm text-gray-600">Topics this node sent packets to</div>
                                </div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">
                                    <template v-if="selectedNodeMqttMetrics.length > 0">
                                        <li v-for="mqttMetric of selectedNodeMqttMetrics">
                                            <div class="relative flex items-center">
                                                <div class="block flex-1 px-4 py-2">
                                                    <div class="relative flex min-w-0 flex-1 items-center">
                                                        <div class="truncate">
                                                            <p class="truncate text-sm font-medium text-gray-900">{{ mqttMetric.mqtt_topic }}</p>
                                                            <div class="text-sm text-gray-700">Last packet {{ moment(new Date(mqttMetric.last_packet_at)).fromNow() }}</div>
                                                        </div>
                                                    </div>
                                                </div>
                                            </div>
                                        </li>
                                    </template>
                                    <template v-else>
                                        <li>
                                            <div class="relative flex items-center">
                                                <div class="block flex-1 px-4 py-2">
                                                    <div class="relative flex min-w-0 flex-1 items-center">
                                                        <div class="truncate">
                                                            <div class="text-sm text-gray-700">No packets seen on MQTT</div>
                                                        </div>
                                                    </div>
                                                </div>
                                            </div>
                                        </li>
                                    </template>
                                </ul>
                            </div>

                            <!-- traceroutes -->
                            <div>
                                <div class="bg-gray-200 p-2">
                                    <div class="font-semibold">Trace Routes</div>
                                    <div class="text-sm text-gray-600">Only 5 most recent are shown</div>
                                </div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">
                                    <template v-if="selectedNodeTraceroutes.length > 0">
                                        <li @click="showTraceRoute(traceroute)" v-for="traceroute of selectedNodeTraceroutes">
                                            <div class="relative flex items-center">
                                                <div class="block flex-1 px-4 py-2">
                                                    <div class="relative flex min-w-0 flex-1 items-center">
                                                        <div>
                                                            <p class="text-sm text-gray-900"><span class="font-medium">{{ findNodeById(traceroute.to)?.long_name || '???' }}</span> to <span class="font-medium">{{ findNodeById(traceroute.from)?.long_name || '???' }}</span></p>
                                                            <div class="text-sm text-gray-700">{{ moment(new Date(traceroute.updated_at)).fromNow() }} - {{ traceroute.route.length }} hops {{ traceroute.channel_id ? `on ${traceroute.channel_id}` : '' }}</div>
                                                        </div>
                                                    </div>
                                                </div>
                                            </div>
                                        </li>
                                    </template>
                                    <template v-else>
                                        <li>
                                            <div class="relative flex items-center">
                                                <div class="block flex-1 px-4 py-2">
                                                    <div class="relative flex min-w-0 flex-1 items-center">
                                                        <div class="truncate">
                                                            <div class="text-sm text-gray-700">No traceroutes seen on MQTT</div>
                                                        </div>
                                                    </div>
                                                </div>
                                            </div>
                                        </li>
                                    </template>
                                </ul>
                            </div>

                            <!-- other -->
                            <div>
                                <div class="bg-gray-200 p-2 font-semibold">Other</div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">

                                    <!-- first seen -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">First Seen</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</div>
                                    </li>

                                    <!-- last seen -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Last Seen</div>
                                        <div class="ml-auto text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</div>
                                    </li>

                                    <!-- neighbours updated -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Neighbours Updated</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.neighbours_updated_at">{{ moment(new Date(selectedNode.neighbours_updated_at)).fromNow() }}</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                    <!-- position updated -->
                                    <li class="flex p-3">
                                        <div class="text-sm font-medium text-gray-900">Position Updated</div>
                                        <div class="ml-auto text-sm text-gray-700">
                                            <span v-if="selectedNode.position_updated_at">{{ moment(new Date(selectedNode.position_updated_at)).fromNow() }}</span>
                                            <span v-else>???</span>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                            <!-- share -->
                            <div>
                                <div class="flex bg-gray-200 p-2 font-semibold">
                                    <div class="my-auto">Share Link</div>
                                    <div class="ml-auto">
                                        <button @click="copyShareLinkForNode(selectedNode.node_id)" type="button" class="rounded bg-white px-2 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
                                            Copy
                                        </button>
                                    </div>
                                </div>
                                <ul role="list" class="flex-1 divide-y divide-gray-200">

                                    <li>
                                        <div class="relative flex items-center">
                                            <div class="block flex-1 p-2">
                                                <div class="flex space-x-2">
                                                    <input type="text" readonly class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" :value="getShareLinkForNode(selectedNode.node_id)">
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                        </div>

                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- traceroute info sidebar -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- overlay -->
        <transition
                enter-active-class="transition-opacity duration-300 ease-linear"
                enter-from-class="opacity-0"
                enter-to-class="opacity-100"
                leave-active-class="transition-opacity duration-300 ease-linear"
                leave-from-class="opacity-100"
                leave-to-class="opacity-0">
            <div v-show="selectedTraceRoute != null" @click="selectedTraceRoute = null" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
        </transition>

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="-translate-x-full"
                enter-to-class="translate-x-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-x-0"
                leave-to-class="-translate-x-full">
            <div v-show="selectedTraceRoute != null" class="fixed top-0 left-0 bottom-0">
                <div v-if="selectedTraceRoute != null" class="w-screen max-w-md overflow-hidden">
                    <div class="flex h-full flex-col bg-white shadow-xl">

                        <!-- slideover header -->
                        <div class="p-2 border-b border-gray-200 shadow">
                            <div class="flex items-start justify-between">
                                <div>
                                    <h2 class="font-bold">Traceroute #{{ selectedTraceRoute.id }}</h2>
                                    <h3 class="text-sm">{{ moment(new Date(selectedTraceRoute.updated_at)).fromNow() }} - {{ selectedTraceRoute.route.length }} hops {{ selectedTraceRoute.channel_id ? `on ${selectedTraceRoute.channel_id}` : '' }}</h3>
                                </div>
                                <div class="my-auto ml-3 flex h-7 items-center">
                                    <a href="javascript:void(0)" class="rounded-full" @click="selectedTraceRoute = null">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                        </div>

                        <div class="overflow-y-auto">

                            <!-- details -->
                            <div class="p-2">
                                <ul role="list" class="space-y-3">

                                    <!-- node that initiated traceroute -->
                                    <li :onclick="`goToNode(${selectedTraceRoute.to})`" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-12 justify-center top-3 -bottom-3">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex flex-none items-center justify-center">
                                            <div>
                                                <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.to)}]`, `text-[${getNodeTextColour(selectedTraceRoute.to)}]` ]">
                                                    <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.to)?.short_name ?? "?" }}</div>
                                                </div>
                                            </div>
                                        </div>
                                        <div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
                                            <div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.to)?.long_name || '???' }}</div>
                                            <div>Hex ID: !{{ Number(selectedTraceRoute.to).toString(16) }}</div>
                                            <div>Started the traceroute</div>
                                        </div>
                                    </li>

                                    <!-- middleman nodes -->
                                    <li :onclick="`goToNode(${route})`" v-for="route of selectedTraceRoute.route" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex flex-none items-center justify-center">
                                            <div>
                                                <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(route)}]`, `text-[${getNodeTextColour(route)}]` ]">
                                                    <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(route)?.short_name ?? "?" }}</div>
                                                </div>
                                            </div>
                                        </div>
                                        <div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
                                            <div class="font-medium text-gray-900">{{ findNodeById(route)?.long_name || '???' }}</div>
                                            <div>Hex ID: !{{ Number(route).toString(16) }}</div>
                                            <div>Forwarded the packet</div>
                                        </div>
                                    </li>

                                    <!-- node that replied to traceroute -->
                                    <li :onclick="`goToNode(${selectedTraceRoute.from})`" v-if="selectedTraceRoute.from" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-12 justify-center -bottom-3">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex flex-none items-center justify-center">
                                            <div>
                                                <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.from)}]`, `text-[${getNodeTextColour(selectedTraceRoute.from)}]` ]">
                                                    <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.from)?.short_name ?? "?" }}</div>
                                                </div>
                                            </div>
                                        </div>
                                        <div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
                                            <div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.from)?.long_name || '???' }}</div>
                                            <div>Hex ID: !{{ Number(selectedTraceRoute.from).toString(16) }}</div>
                                            <div>Replied to traceroute</div>
                                        </div>
                                    </li>

                                    <!-- node that gated traceroute to mqtt -->
                                    <li :onclick="`goToNode(${selectedTraceRoute.gateway_id})`" v-if="selectedTraceRoute.gateway_id" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-12 justify-center h-6">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex flex-none items-center justify-center">
                                            <div>
                                                <div class="flex rounded-full h-12 w-12 text-white shadow" :class="[ `bg-[${getNodeColour(selectedTraceRoute.gateway_id)}]`, `text-[${getNodeTextColour(selectedTraceRoute.gateway_id)}]` ]">
                                                    <div class="mx-auto my-auto drop-shadow-sm">{{ findNodeById(selectedTraceRoute.gateway_id)?.short_name ?? "?" }}</div>
                                                </div>
                                            </div>
                                        </div>
                                        <div class="flex-auto py-0.5 text-sm leading-5 text-gray-500">
                                            <div class="font-medium text-gray-900">{{ findNodeById(selectedTraceRoute.gateway_id)?.long_name || '???' }}</div>
                                            <div>Hex ID: !{{ Number(selectedTraceRoute.gateway_id).toString(16) }}</div>
                                            <div>Gated the packet to MQTT</div>
                                        </div>
                                    </li>

                                </ul>

                            </div>

                            <div>
                                <div class="bg-gray-200 p-2 font-semibold">Raw Data</div>
                                <div class="text-sm text-gray-700">
                                    <pre class="bg-gray-100 rounded p-2 overflow-x-auto">{{ JSON.stringify(selectedTraceRoute, null, 4) }}</pre>
                                </div>
                            </div>

                        </div>

                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- settings sidebar -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- overlay -->
        <transition
                enter-active-class="transition-opacity duration-300 ease-linear"
                enter-from-class="opacity-0"
                enter-to-class="opacity-100"
                leave-active-class="transition-opacity duration-300 ease-linear"
                leave-from-class="opacity-100"
                leave-to-class="opacity-0">
            <div v-show="isShowingSettings" @click="isShowingSettings = !isShowingSettings" class="fixed inset-0 bg-gray-900 bg-opacity-75"></div>
        </transition>

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="-translate-x-full"
                enter-to-class="translate-x-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-x-0"
                leave-to-class="-translate-x-full">
            <div v-show="isShowingSettings" class="fixed top-0 left-0 bottom-0">
                <div v-if="isShowingSettings" class="w-screen max-w-md overflow-hidden">
                    <div class="flex h-full flex-col bg-white shadow-xl">

                        <!-- slideover header -->
                        <div class="p-2 border-b border-gray-200 shadow">
                            <div class="flex items-start justify-between">
                                <div>
                                    <h2 class="font-bold">Settings</h2>
                                    <h3 class="text-sm">Changes are only saved in this browser.</h3>
                                </div>
                                <div class="my-auto ml-3 flex h-7 items-center">
                                    <a href="javascript:void(0)" class="rounded-full" @click="isShowingSettings = false">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                        </div>

                        <div class="overflow-y-auto divide-y divide-gray-200">

                            <!-- configNodesMaxAgeInSeconds -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Nodes Max Age</label>
                                <div class="text-xs text-gray-600 mb-2">Nodes not updated within this time are hidden. Reload to update map.</div>
                                <select v-model="configNodesMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                                    <option :value="null">Show All</option>
                                    <option value="900">15 minutes</option>
                                    <option value="1800">30 minutes</option>
                                    <option value="3600">1 hour</option>
                                    <option value="10800">3 hours</option>
                                    <option value="21600">6 hours</option>
                                    <option value="43200">12 hours</option>
                                    <option value="86400">24 hours</option>
                                    <option value="172800">2 days</option>
                                    <option value="259200">3 days</option>
                                    <option value="345600">4 days</option>
                                    <option value="432000">5 days</option>
                                    <option value="518400">6 days</option>
                                    <option value="604800">7 days</option>
                                </select>
                            </div>

                            <!-- configNodesDisconnectedAgeInSeconds -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Nodes Disconnected Age</label>
                                <div class="text-xs text-gray-600 mb-2">Nodes that have not uplinked to MQTT in this time will show as blue icons. Reload to update map.</div>
                                <select v-model="configNodesDisconnectedAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                                    <option value="900">15 minutes</option>
                                    <option value="1800">30 minutes</option>
                                    <option value="2700">45 minutes</option>
                                    <option value="3600">1 hour</option>
                                    <option value="7200">2 hours</option>
                                    <option value="10800">3 hours</option>
                                    <option value="21600">6 hours</option>
                                    <option value="43200">12 hours</option>
                                    <option value="86400">24 hours</option>
                                    <option value="172800">2 days</option>
                                    <option value="259200">3 days</option>
                                    <option value="345600">4 days</option>
                                    <option value="432000">5 days</option>
                                    <option value="518400">6 days</option>
                                    <option value="604800">7 days</option>
                                </select>
                            </div>

                            <!-- configNodesOfflineAgeInSeconds -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Nodes Offline Age</label>
                                <div class="text-xs text-gray-600 mb-2">Nodes not updated within this time will show as red icons. Reload to update map.</div>
                                <select v-model="configNodesOfflineAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                                    <option :value="null">Don't show as offline</option>
                                    <option value="900">15 minutes</option>
                                    <option value="1800">30 minutes</option>
                                    <option value="2700">45 minutes</option>
                                    <option value="3600">1 hour</option>
                                    <option value="7200">2 hours</option>
                                    <option value="10800">3 hours</option>
                                    <option value="21600">6 hours</option>
                                    <option value="43200">12 hours</option>
                                    <option value="86400">24 hours</option>
                                    <option value="172800">2 days</option>
                                    <option value="259200">3 days</option>
                                    <option value="345600">4 days</option>
                                    <option value="432000">5 days</option>
                                    <option value="518400">6 days</option>
                                    <option value="604800">7 days</option>
                                </select>
                            </div>

                            <!-- configWaypointsMaxAgeInSeconds -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Waypoints Max Age</label>
                                <div class="text-xs text-gray-600 mb-2">Waypoints not updated within this time are hidden. Reload to update map.</div>
                                <select v-model="configWaypointsMaxAgeInSeconds" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                                    <option :value="null">Show All</option>
                                    <option value="900">15 minutes</option>
                                    <option value="1800">30 minutes</option>
                                    <option value="3600">1 hour</option>
                                    <option value="10800">3 hours</option>
                                    <option value="21600">6 hours</option>
                                    <option value="43200">12 hours</option>
                                    <option value="86400">24 hours</option>
                                    <option value="172800">2 days</option>
                                    <option value="259200">3 days</option>
                                    <option value="345600">4 days</option>
                                    <option value="432000">5 days</option>
                                    <option value="518400">6 days</option>
                                    <option value="604800">7 days</option>
                                </select>
                            </div>

                            <!-- 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">
                                <label class="block text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
                                <div class="text-xs text-gray-600 mb-2">Neighbours further than this are hidden. Reload to update map.</div>
                                <input type="number" v-model="configNeighboursMaxDistanceInMeters" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                            </div>

                            <!-- configZoomLevelGoToNode -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Zoom Level (go to node)</label>
                                <div class="text-xs text-gray-600 mb-2">How far to zoom map when navigating to a node.</div>
                                <input type="number" v-model="configZoomLevelGoToNode" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                            </div>

                            <!-- configTemperatureFormat -->
                            <div class="p-2">
                                <label class="block text-sm font-medium text-gray-900">Temperature Format</label>
                                <div class="text-xs text-gray-600 mb-2">Metrics will be shown in the selected format.</div>
                                <select v-model="configTemperatureFormat" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
                                    <option value="celsius">Celsius (°C)</option>
                                    <option value="fahrenheit">Fahrenheit (°F)</option>
                                </select>
                            </div>

                            <!-- configAutoUpdatePositionInUrl -->
                            <div class="p-2">
                                <div class="flex items-start">
                                    <div class="flex items-center h-5">
                                        <input type="checkbox" v-model="configAutoUpdatePositionInUrl" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
                                    </div>
                                    <label class="ml-2 text-sm font-medium text-gray-900">Auto Update Position in URL</label>
                                </div>
                                <div class="text-xs text-gray-600">Sets lat/lng/zoom as query parameters.</div>
                            </div>

                            <!-- configEnableMapAnimations -->
                            <div class="p-2">
                                <div class="flex items-start">
                                    <div class="flex items-center h-5">
                                        <input type="checkbox" v-model="configEnableMapAnimations" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300" required>
                                    </div>
                                    <label class="ml-2 text-sm font-medium text-gray-900">Enable Map Animations</label>
                                </div>
                                <div class="text-xs text-gray-600">Map will animate flying to nodes.</div>
                            </div>

                        </div>

                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- node neighbours modal -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="translate-y-full"
                enter-to-class="translate-y-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-y-0"
                leave-to-class="translate-y-full">
            <div v-show="selectedNodeToShowNeighbours != null" class="fixed left-0 right-0 bottom-0">
                <div v-if="selectedNodeToShowNeighbours != null" class="mx-auto w-screen max-w-md p-4">
                    <div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
                        <div class="p-2">
                            <div class="flex items-start justify-between">
                                <div>
                                    <h2 class="font-bold">{{ selectedNodeToShowNeighbours.short_name }} Neighbours</h2>
                                    <h3 v-if="selectedNodeToShowNeighboursType === 'we_heard'" class="text-sm">Nodes heard by {{ selectedNodeToShowNeighbours.short_name }}</h3>
                                    <h3 v-if="selectedNodeToShowNeighboursType === 'heard_us'" class="text-sm">Nodes that heard {{ selectedNodeToShowNeighbours.short_name }}</h3>
                                </div>
                                <div class="my-auto ml-3 flex h-7 items-center">
                                    <a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodeNeighbours">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                                            <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </transition>

    </div>

    <!-- node position history modal -->
    <div class="relative z-sidebar" role="dialog" aria-modal="true">

        <!-- sidebar -->
        <transition
                enter-active-class="transition duration-300 ease-in-out transform"
                enter-from-class="translate-y-full"
                enter-to-class="translate-y-0"
                leave-active-class="transition duration-300 ease-in-out transform"
                leave-from-class="translate-y-0"
                leave-to-class="translate-y-full">
            <div v-show="selectedNodeToShowPositionHistory != null" class="fixed left-0 right-0 bottom-0">
                <div v-if="selectedNodeToShowPositionHistory != null" class="mx-auto w-screen max-w-md p-4">
                    <div class="flex h-full flex-col bg-white shadow-xl rounded-xl border">
                        <div>
                            <div class="flex p-2">
                                <div class="flex my-auto mr-3 space-x-2">
                                    <a href="javascript:void(0)" @click="isPositionHistoryModalExpanded = !isPositionHistoryModalExpanded" class="rounded-full">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
                                            <svg v-if="isPositionHistoryModalExpanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
                                            </svg>
                                            <svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                                <div class="my-auto mr-auto font-bold">{{ selectedNodeToShowPositionHistory.short_name }} Position History</div>
                                <div class="flex my-auto ml-3 space-x-2">
                                    <a href="javascript:void(0)" class="rounded-full" @click="dismissShowingNodePositionHistory">
                                        <div class="bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
                                            <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
                                                <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
                                                <path d="M18 6l-12 12"></path>
                                                <path d="M6 6l12 12"></path>
                                            </svg>
                                        </div>
                                    </a>
                                </div>
                            </div>
                            <div v-if="isPositionHistoryModalExpanded" class="divide-y border-t">

                                <!-- quick range -->
                                <div class="flex p-2 space-x-2">
                                    <button @click="onPositionHistoryQuickRangeClick('1h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">1 Hour</button>
                                    <button @click="onPositionHistoryQuickRangeClick('24h')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">24 Hours</button>
                                    <button @click="onPositionHistoryQuickRangeClick('7d')" type="button" class="w-full bg-gray-100 rounded border shadow px-2 py-1 text-sm hover:bg-gray-200 text-center">7 Days</button>
                                </div>

                                <!-- manual range -->
                                <div class="p-2 space-y-1">

                                    <!-- from -->
                                    <div class="flex items-center">
                                        <label class="text-sm pr-1 min-w-12 text-right">From:</label>
                                        <input v-model="positionHistoryDateTimeFrom" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
                                    </div>

                                    <!-- to -->
                                    <div class="flex items-center">
                                        <label class="text-sm pr-1 min-w-12 text-right">To:</label>
                                        <input v-model="positionHistoryDateTimeTo" @change="loadNodePositionHistory(selectedNodeToShowPositionHistory.node_id)" type="datetime-local" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1">
                                    </div>

                                </div>

                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </transition>

    </div>

</div>

<script>

    function getConfigHasSeenInfoModal() {
        return localStorage.getItem("config_has_seen_info_modal") === "true";
    }

    function setConfigHasSeenInfoModal(value) {
        return localStorage.setItem("config_has_seen_info_modal", value);
    }

    function getConfigAutoUpdatePositionInUrl() {
        // use user preference, or enable by default
        const value = localStorage.getItem("config_auto_update_position_in_url");
        return value === "true" || value == null;
    }

    function setConfigAutoUpdatePositionInUrl(value) {
        return localStorage.setItem("config_auto_update_position_in_url", value);
    }

    function getConfigEnableMapAnimations() {

        const value = localStorage.getItem("config_enable_map_animations");

        // enable animations by default
        if(value === null){
            return true;
        }

        return value === "true";

    }

    function setConfigEnableMapAnimations(value) {
        return localStorage.setItem("config_enable_map_animations", value);
    }

    function getConfigTemperatureFormat() {
        return localStorage.getItem("config_temperature_format") || "celsius";
    }

    function setConfigTemperatureFormat(format) {
        return localStorage.setItem("config_temperature_format", format);
    }

    function getConfigMapSelectedTileLayer() {
        return localStorage.getItem("config_map_selected_tile_layer") || "OpenStreetMap";
    }

    function setConfigMapSelectedTileLayer(layer) {
        return localStorage.setItem("config_map_selected_tile_layer", layer);
    }

    function getConfigMapEnabledOverlayLayers() {

        try {
            const value = localStorage.getItem("config_map_enabled_overlay_layers");
            if(value){
                return JSON.parse(value);
            }
        } catch(e) {}

        // overlays enabled by default
        return ["Legend", "Position History", "Traceroutes"];

    }

    function setConfigMapEnabledOverlayLayers(layers) {
        return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers));
    }

    function getConfigNodesMaxAgeInSeconds() {
        const value = localStorage.getItem("config_nodes_max_age_in_seconds");
        return value != null ? parseInt(value) : null;
    }

    function setConfigNodesMaxAgeInSeconds(value) {
        if(value != null){
            return localStorage.setItem("config_nodes_max_age_in_seconds", value);
        } else {
            return localStorage.removeItem("config_nodes_max_age_in_seconds");
        }
    }

    function getConfigNodesDisconnectedAgeInSeconds() {
        // default to showing nodes as recently uplinked if heard in the last 30 minutes
        const value = localStorage.getItem("config_nodes_disconnected_age_in_seconds");
        return value != null ? parseInt(value) : 1800;
    }

    function setConfigNodesDisconnectedAgeInSeconds(value) {
        if(value != null){
            return localStorage.setItem("config_nodes_disconnected_age_in_seconds", value);
        } else {
            return localStorage.removeItem("config_nodes_disconnected_age_in_seconds");
        }
    }

    function getConfigNodesOfflineAgeInSeconds() {
        const value = localStorage.getItem("config_nodes_offline_age_in_seconds");
        return value != null ? parseInt(value) : null;
    }

    function setConfigNodesOfflineAgeInSeconds(value) {
        if(value != null){
            return localStorage.setItem("config_nodes_offline_age_in_seconds", value);
        } else {
            return localStorage.removeItem("config_nodes_offline_age_in_seconds");
        }
    }

    function getConfigWaypointsMaxAgeInSeconds() {
        const value = localStorage.getItem("config_waypoints_max_age_in_seconds");
        return value != null ? parseInt(value) : null;
    }

    function setConfigWaypointsMaxAgeInSeconds(value) {
        if(value != null){
            return localStorage.setItem("config_waypoints_max_age_in_seconds", value);
        } else {
            return localStorage.removeItem("config_waypoints_max_age_in_seconds");
        }
    }

    function 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);
    }

    function isMobile() {
        return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    }

</script>

<script>
    Vue.createApp({
        data() {
            return {

                isShowingAnnouncement: this.shouldShowAnnouncement(),

                configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(),
                configNodesDisconnectedAgeInSeconds: window.getConfigNodesDisconnectedAgeInSeconds(),
                configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(),
                configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(),
                configTraceroutesMaxAgeInSeconds: window.getConfigTraceroutesMaxAgeInSeconds(),
                configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),
                configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(),
                configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(),
                configEnableMapAnimations: window.getConfigEnableMapAnimations(),
                configTemperatureFormat: window.getConfigTemperatureFormat(),

                isShowingHardwareModels: false,
                hardwareModelStats: null,

                isShowingInfoModal: this.shouldShowInfoModal(),
                isShowingMobileSearch: false,
                isShowingSettings: false,

                nodes: [],
                searchText: "",

                selectedNode: null,
                selectedNodeDeviceMetrics: [],
                selectedNodeEnvironmentMetrics: [],
                selectedNodePowerMetrics: [],
                selectedNodeMqttMetrics: [],
                selectedNodeTraceroutes: [],

                deviceMetricsTimeRange: "7d",
                environmentMetricsTimeRange: "7d",
                powerMetricsTimeRange: "7d",

                isPositionHistoryModalExpanded: true,
                positionHistoryDateTimeFrom: null,
                positionHistoryDateTimeTo: null,
                selectedNodePositionHistory: [],
                selectedNodeToShowPositionHistory: null,
                selectedNodePositionHistoryMarkers: [],
                selectedNodePositionHistoryPolyLines: [],

                selectedTraceRoute: null,
                tracerouteEdges: [],

                selectedNodeToShowNeighbours: null,
                selectedNodeToShowNeighboursType: null,

                moment: window.moment,

            };
        },
        mounted: function() {

            // load data
            this.loadHardwareModelStats();

            // handle map click callback from outside of vue
            window._onMapClick = () => {
                this.searchText = "";
                this.isShowingMobileSearch = false;
            };

            // handle node callback from outside of vue
            window._onNodeClick = (node) => {
                this.selectedNode = node;
                this.loadNodeDeviceMetrics(node.node_id);
                this.loadNodeEnvironmentMetrics(node.node_id);
                this.loadNodePowerMetrics(node.node_id);
                this.loadNodeMqttMetrics(node.node_id);
                this.loadNodeTraceroutes(node.node_id);
                //this.loadNodePositionHistory(node.node_id);
            };

            // handle node callback from outside of vue
            window._onShowNodeNeighboursWeHeardClick = (node) => {
                this.selectedNodeToShowNeighbours = node;
                this.selectedNodeToShowNeighboursType = 'we_heard';
            };

            // handle node callback from outside of vue
            window._onShowNodeNeighboursHeardUsClick = (node) => {
                this.selectedNodeToShowNeighbours = node;
                this.selectedNodeToShowNeighboursType = 'heard_us';
            };

            // handle nodes updated callback from outside of vue
            window._onNodesUpdated = (nodes) => {
                this.nodes = nodes;
            };

        },
        methods: {
            getAnnouncementId: function() {
                // change this when making a new announcement
                return "1";
            },
            shouldShowAnnouncement: function() {
                const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id");
                return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId();
            },
            dismissAnnouncement: function() {
                window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId());
                this.isShowingAnnouncement = false;
            },
            shouldShowInfoModal: function() {
                return !window.getConfigHasSeenInfoModal()
                    && !window.isMobile();
            },
            loadHardwareModelStats: function() {
                window.axios.get('/api/v1/stats/hardware-models').then((response) => {
                    this.hardwareModelStats = response.data.hardware_model_stats;
                }).catch((error) => {
                    // do nothing
                });
            },
            loadNodeDeviceMetrics: function(nodeId) {

                // calculate unix timestamps in milliseconds for supported time ranges
                const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
                const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
                const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
                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;
                    }
                    case "30d": {
                        timeFrom = thirtyDaysAgoInMilliseconds;
                        break;
                    }
                }

                window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
                    params: {
                        time_from: timeFrom,
                    },
                }).then((response) => {
                    // reverse response, as it's newest to oldest, but we want oldest to newest
                    this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse();
                    this.renderDeviceMetricCharts();
                }).catch(() => {
                    this.selectedNodeDeviceMetrics = [];
                    this.renderDeviceMetricCharts();
                });
            },
            loadNodeEnvironmentMetrics: function(nodeId) {

                // calculate unix timestamps in milliseconds for supported time ranges
                const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000);
                const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000);
                const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000);
                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;
                    }
                    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);
                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;
                    }
                    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
                });
            },
            loadNodeTraceroutes: function(nodeId) {
                this.selectedNodeTraceroutes = [];
                window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, {
                    params: {
                        count: 5,
                    },
                }).then((response) => {
                    this.selectedNodeTraceroutes = response.data.traceroutes;
                }).catch(() => {
                    // do nothing
                });
            },
            loadNodePositionHistory: function(nodeId) {
                this.selectedNodePositionHistory = [];
                window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, {
                    params: {
                        // parse from datetime-local format, and send as unix timestamp in milliseconds
                        time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"),
                        time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"),
                    },
                }).then((response) => {
                    this.selectedNodePositionHistory = response.data.position_history;
                    if(this.selectedNodeToShowPositionHistory != null){
                        clearAllPositionHistory();
                        onPositionHistoryUpdated(response.data.position_history);
                    }

                }).catch(() => {
                    // do nothing
                });
            },
            renderDeviceMetricCharts: function() {
                try {
                    this.updateDeviceMetricsChart();
                } catch(e) {
                    console.log(e);
                }
            },
            updateDeviceMetricsChart: function() {

                // destroy existing chart
                const chartElementId = "deviceMetricsChart";
                const existingChart = window.Chart.getChart(chartElementId);
                if(existingChart != null){
                    existingChart.destroy();
                }

                // get chart element
                const chartElement = window.document.getElementById(chartElementId);
                if(!chartElement){
                    return;
                }

                // create chart data
                const labels = [];
                const batteryMetrics = [];
                const channelUtilizationMetrics = [];
                const airUtilTxMetrics = [];
                for(const deviceMetric of this.selectedNodeDeviceMetrics){
                    labels.push(moment(deviceMetric.created_at));
                    batteryMetrics.push(deviceMetric.battery_level);
                    channelUtilizationMetrics.push(deviceMetric.channel_utilization);
                    airUtilTxMetrics.push(deviceMetric.air_util_tx);
                }

                // create chart
                new window.Chart(chartElement, {
                    type: 'line',
                    data: {
                        labels: labels,
                        datasets: [
                            {
                                label: 'Battery Level',
                                borderColor: '#3b82f6',
                                backgroundColor: '#3b82f6',
                                pointStyle: false, // no points
                                fill: false,
                                data: batteryMetrics,
                            },
                            {
                                label: 'Channel Util',
                                borderColor: '#22c55e',
                                backgroundColor: '#22c55e',
                                showLine: false, // no lines between points
                                fill: false,
                                data: channelUtilizationMetrics,
                            },
                            {
                                label: 'Air Util TX',
                                borderColor: '#f97316',
                                backgroundColor: '#f97316',
                                showLine: false, // no lines between points
                                fill: false,
                                data: airUtilTxMetrics,

                            },
                        ],
                    },
                    options: {
                        responsive: true,
                        borderWidth: 2,
                        elements: {
                            point: {
                                radius: 2,
                            },
                        },
                        scales: {
                            x: {
                                position: 'top',
                                type: 'time',
                                time: {
                                    unit: 'day',
                                    displayFormats: {
                                        day: 'MMM DD', // Jan 01
                                    },
                                },
                            },
                            y: {
                                min: 0,
                                max: 101, // 101 is "Plugged In", need to include for tooltip to work
                                ticks: {
                                    callback: (label) => `${label}%`,
                                },
                            },
                        },
                        plugins: {
                            legend: {
                                display: false,
                            },
                            tooltip: {
                                mode: "index",
                                intersect: false,
                                callbacks: {
                                    label: (item) => {
                                        return `${item.dataset.label}: ${item.formattedValue}%`;
                                    },
                                },
                            },
                        },
                    }
                });

            },
            renderEnvironmentMetricCharts: function() {
                try {
                    this.updateEnvironmentMetricsChart();
                } catch(e) {
                    console.log(e);
                }
            },
            updateEnvironmentMetricsChart: function() {

                // destroy existing chart
                const chartElementId = "environmentMetricsChart";
                const existingChart = window.Chart.getChart(chartElementId);
                if(existingChart != null){
                    existingChart.destroy();
                }

                // get chart element
                const chartElement = window.document.getElementById(chartElementId);
                if(!chartElement){
                    return;
                }

                // create chart data
                const labels = [];
                const temperatureMetrics = [];
                const relativeHumidityMetrics = [];
                const barometricPressureMetrics = [];
                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);
                    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',

                            },
                            {
                                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: {
                                min: 800,
                                max: 1100,
                                ticks: {
                                    stepSize: 10,
                                    callback: (label) => `${label} hPa`,
                                },
                                position: 'right',
                                grid: {
                                    drawOnChartArea: false, // only want the grid lines for one axis to show up
                                },
                            },
                            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}`;
                                    },
                                },
                            },
                        },
                    }
                });

            },
            showTraceRoute: function(traceroute) {
                this.selectedTraceRoute = traceroute;
            },
            findNodeById: function(id) {
                return window.findNodeById(id);
            },
            findNodeMarkerById: function(id) {
                return window.findNodeMarkerById(id);
            },
            onSearchResultNodeClick: function(node) {

                // clear search
                this.searchText = "";

                // hide search
                this.isShowingMobileSearch = false;

                // go to node
                if(window.goToNode(node.node_id)){
                    return;
                }

                // fallback to showing node details since we can't go to the node
                window.showNodeDetails(node.node_id);

            },
            dismissInfoModal: function() {
                this.isShowingInfoModal = false;
                window.setConfigHasSeenInfoModal(true);
            },
            getRegionFrequencyRange: function(regionName) {
                return window.getRegionFrequencyRange(regionName);
            },
            showNodePositionHistory: function(nodeId) {

                // find node
                const node = findNodeById(nodeId);
                if(!node){
                    return;
                }

                // update ui
                this.selectedNode = null;
                this.selectedNodeToShowPositionHistory = node;
                this.isPositionHistoryModalExpanded = true;

                // close node info tooltip as position history shows under it
                window.closeAllTooltips();

                // reset default time range when opening position history ui
                // YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type
                this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
                this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');

                // load position history
                this.loadNodePositionHistory(nodeId);

            },
            onPositionHistoryQuickRangeClick: function(range) {

                // update position history time range
                switch(range){
                    case "1h": {
                        this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm');
                        this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
                        break;
                    }
                    case "24h": {
                        this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm');
                        this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
                        break;
                    }
                    case "7d": {
                        this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm');
                        this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm');
                        break;
                    }
                }

                // reload position history
                const node = this.selectedNodeToShowPositionHistory;
                if(node){
                    this.loadNodePositionHistory(node.node_id);
                }

            },
            getShareLinkForNode: function(nodeId) {
                return window.location.origin + `/?node_id=${nodeId}`;
            },
            copyShareLinkForNode: function(nodeId) {

                // make sure copy to clipboard is supported
                if(!navigator.clipboard || !navigator.clipboard.writeText){
                    alert("Clipboard not supported. Site must be served via https on iOS.");
                    return;
                }

                // copy share link to clipboard
                const url = this.getShareLinkForNode(nodeId);
                navigator.clipboard.writeText(url);

                // tell user we copied it
                alert("Link copied to clipboard!");

            },
            dismissShowingNodeNeighbours: function() {
                window._onHideNodeNeighboursClick();
                this.selectedNodeToShowNeighbours = null;
            },
            dismissShowingNodePositionHistory: function() {
                this.selectedNodePositionHistory = [];
                this.selectedNodeToShowPositionHistory = null;
                this.selectedNodePositionHistoryMarkers = [];
                this.selectedNodePositionHistoryPolyLines = [];
                cleanUpPositionHistory();
            },
            formatUptimeSeconds: function(secondsToFormat) {
                secondsToFormat = Number(secondsToFormat);
                var days = Math.floor(secondsToFormat / (3600 * 24));
                var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
                var minutes = Math.floor((secondsToFormat % 3600) / 60);
                var seconds = Math.floor(secondsToFormat % 60);
                var daysPlural = days === 1 ? 'day' : 'days';
                return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`;
            },
            formatTemperature: function(celsius) {
                switch(this.configTemperatureFormat){
                    case "celsius": {
                        return `${Number(celsius).toFixed(0)}°C`;
                    }
                    case "fahrenheit": {
                        const fahrenheit = this.celsiusToFahrenheit(celsius);
                        return `${fahrenheit.toFixed(0)}°F`;
                    }
                }
            },
            convertTemperature: function(celsius) {
                switch(this.configTemperatureFormat){
                    case "celsius": {
                        return celsius;
                    }
                    case "fahrenheit": {
                        return this.celsiusToFahrenheit(celsius);
                    }
                }
            },
            getTemperatureUnit: function() {
                switch(this.configTemperatureFormat){
                    case "celsius": return "°C";
                    case "fahrenheit": return "°F";
                }
            },
            celsiusToFahrenheit: function(celsius) {
                return (celsius * 9/5) + 32;
            },
            getNodeColour(nodeId) {
                // convert node id to a hex colour
                return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0');
            },
            getNodeTextColour(nodeId) {

                // extract rgb components
                const r = (nodeId & 0xFF0000) >> 16;
                const g = (nodeId & 0x00FF00) >> 8;
                const b = nodeId & 0x0000FF;

                // calculate brightness
                const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255;

                // determine text color based on brightness
                return brightness > 0.5 ? "#000000" : "#FFFFFF";

            },
        },
        computed: {
            searchedNodes() {

                // search nodes
                const nodes = this.nodes.filter((node) => {
                    const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase());
                    const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase());
                    const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
                    const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase());
                    return matchesId || matchesHexId || matchesLongName || matchesShortName;
                });

                // order alphabetically by long name
                nodes.sort((nodeA, nodeB) => {
                    const nodeALongName = nodeA.long_name || "";
                    const nodeBLongName = nodeB.long_name || "";
                    return nodeALongName.localeCompare(nodeBLongName);
                });

                // only return the first 500 results to avoid ui lag...
                return nodes.slice(0, 500);

            },
            selectedNodeLatestPowerMetric() {
                const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1);
                return latestPowerMetric;
            },
        },
        watch: {
            configNodesMaxAgeInSeconds() {
                window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds);
            },
            configNodesDisconnectedAgeInSeconds() {
                window.setConfigNodesDisconnectedAgeInSeconds(this.configNodesDisconnectedAgeInSeconds);
            },
            configNodesOfflineAgeInSeconds() {
                window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds);
            },
            configWaypointsMaxAgeInSeconds() {
                window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds);
            },
            configTraceroutesMaxAgeInSeconds() {
                window.setConfigTraceroutesMaxAgeInSeconds(this.configTraceroutesMaxAgeInSeconds);
            },
            configNeighboursMaxDistanceInMeters() {
                window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
            },
            configZoomLevelGoToNode() {
                window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode);
            },
            configAutoUpdatePositionInUrl() {
                window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl);
            },
            configEnableMapAnimations() {
                window.setConfigEnableMapAnimations(this.configEnableMapAnimations);
            },
            configTemperatureFormat() {
                window.setConfigTemperatureFormat(this.configTemperatureFormat);
            },
            deviceMetricsTimeRange() {
                this.loadNodeDeviceMetrics(this.selectedNode.node_id);
            },
            environmentMetricsTimeRange() {
                this.loadNodeEnvironmentMetrics(this.selectedNode.node_id);
            },
            powerMetricsTimeRange() {
                this.loadNodePowerMetrics(this.selectedNode.node_id);
            },
        },
    }).mount('#app');
</script>

<script>

    // global state
    var nodes = [];
    var nodeMarkers = {};
    var selectedNodeOutlineCircle = null;
    var waypoints = [];
    var tracerouteEdgesCache = [];

    // set map bounds to be a little more than full size to prevent panning off screen
    var bounds = [
        [-100, 70], // top left
        [100, 500], // bottom right
    ];

    // create map positioned over Stockholm
    var map = L.map('map', {
        maxBounds: bounds,
    }).setView([
        59.3,
        378.1,
    ], 10);

    // remove leaflet link
    map.attributionControl.setPrefix('');

    var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        maxZoom: 22, // increase from 18 to 22
        attribution: 'Tiles &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>',
    });

    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>',
    });

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

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

    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,
        "OpenTopoMap": openTopoMapTileLayer,
        "Esri Satellite": esriWorldImageryTileLayer,
        "Google Satellite": googleSatelliteTileLayer,
        "Google Hybrid": googleHybridTileLayer,
    };

    // use tile layer based on config
    const selectedTileLayerName = getConfigMapSelectedTileLayer();
    const selectedTileLayer = tileLayers[selectedTileLayerName] || openStreetMapTileLayer;
    selectedTileLayer.addTo(map);

    // create layer groups
    var nodesLayerGroup = new L.LayerGroup();
    var neighboursLayerGroup = new L.LayerGroup();
    var backboneNeighboursLayerGroup = new L.LayerGroup();
    var nodeNeighboursLayerGroup = new L.LayerGroup();
    var nodesClusteredLayerGroup = L.markerClusterGroup({
        showCoverageOnHover: false,
        disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
    });
    var nodesRouterLayerGroup = L.markerClusterGroup({
        showCoverageOnHover: false,
        disableClusteringAtZoom: 10, // zoom level where node clustering is disabled
    });
    var nodesBackboneLayerGroup = new L.LayerGroup();
    var nodesMediumFastLayerGroup = new L.LayerGroup();
    var nodesLongFastLayerGroup = new L.LayerGroup();
    var waypointsLayerGroup = new L.LayerGroup();
    var nodePositionHistoryLayerGroup = new L.LayerGroup();
    var traceroutesLayerGroup = new L.LayerGroup();

    // create icons
    var iconMqttConnected = L.divIcon({
        className: 'icon-mqtt-connected',
        iconSize: [16, 16], // increase from 12px to 16px to make hover easier
    });

    var 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',
        iconSize: [16, 16], // increase from 12px to 16px to make hover easier
    });

    var iconOffline = L.divIcon({
        className: 'icon-offline',
        iconSize: [16, 16], // increase from 12px to 16px to make hover easier
    });

    var iconPositionHistory = L.divIcon({
        className: 'icon-position-history',
        iconSize: [16, 16], // increase from 12px to 16px to make hover easier
    });

    var iconTracerouteStart = L.divIcon({
        className: 'icon-traceroute-start',
        iconSize: [16, 16],
    });

    var iconTracerouteEnd = L.divIcon({
        className: 'icon-traceroute-end',
        iconSize: [16, 16],
    });

    // create legend
    var legendLayerGroup = new L.LayerGroup();
    var legend = L.control({position: 'bottomleft'});
    legend.onAdd = function (map) {
        var div = L.DomUtil.create('div', 'leaflet-control-layers');
        div.style.backgroundColor = 'white';
        div.style.padding = '12px';
        div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
            + `<div style="display:flex"><div class="icon-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MediumFast</div>`
            + `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> LongFast</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>`
            + `<div style="display:flex"><svg width="16" height="3" style="margin-right:4px;margin-top:auto;margin-bottom:auto;"><line x1="0" y1="1.5" x2="16" y2="1.5" stroke="#f97316" stroke-width="4"/></svg> Traceroute</div>`;
        return div;
    };

    // handle baselayerchange to update tile layer preference
    map.on('baselayerchange', function(event) {
        setConfigMapSelectedTileLayer(event.name);
    });

    // handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup)
    map.on('overlayadd overlayremove', function(event) {
        if(event.name === "Legend"){
            if(event.type === "overlayadd"){
                map.addControl(legend);
            } else if(event.type === "overlayremove"){
                map.removeControl(legend);
            }
        }
    });

    // add layers to control ui
    L.control.groupedLayers(tileLayers, {
        "Nodes": {
            "All": nodesLayerGroup,
            "Routers": nodesRouterLayerGroup,
            "Backbone": nodesBackboneLayerGroup,
            "MediumFast": nodesMediumFastLayerGroup,
            "LongFast": nodesLongFastLayerGroup,
            "Clustered": nodesClusteredLayerGroup,
            "None": new L.LayerGroup(),
        },
        "Overlays": {
            "Legend": legendLayerGroup,
            "Neighbours": neighboursLayerGroup,
            "Backbone Connection": backboneNeighboursLayerGroup,
            "Waypoints": waypointsLayerGroup,
            "Position History": nodePositionHistoryLayerGroup,
            "Traceroutes": traceroutesLayerGroup,
        },
    }, {
        // make the "Nodes" group exclusive (use radio inputs instead of checkbox)
        exclusiveGroups: ["Nodes"],
    }).addTo(map);

    // 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);
    }
    if(enabledOverlayLayers.includes("Backbone Connection")){
        backboneNeighboursLayerGroup.addTo(map);
    }
    if(enabledOverlayLayers.includes("Waypoints")){
        waypointsLayerGroup.addTo(map);
    }
    if(enabledOverlayLayers.includes("Position History")){
        nodePositionHistoryLayerGroup.addTo(map);
    }
    if(enabledOverlayLayers.includes("Traceroutes")){
        traceroutesLayerGroup.addTo(map);
    }

    map.on('overlayadd', function(event) {
        // update config when map overlay is added
        const layerName = event.name;
        const enabledOverlayLayers = getConfigMapEnabledOverlayLayers();
        if(!enabledOverlayLayers.includes(layerName)){
            enabledOverlayLayers.push(layerName);
        }
        setConfigMapEnabledOverlayLayers(enabledOverlayLayers);

        // clear traceroutes layer when traceroutes overlay is added
        if (layerName === "Traceroutes") {
            traceroutesLayerGroup.clearLayers();
        }
    });

    // update config when map overlay is removed
    map.on('overlayremove', function(event) {
        const layerName = event.name;
        const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) {
            return enabledOverlayLayer !== layerName;
        });
        setConfigMapEnabledOverlayLayers(enabledOverlayLayers);
    });

    // handle map clicks
    map.on('click', function() {

        // remove outline when map clicked
        clearNodeOutline();

        // send callback to vue
        window._onMapClick();

    });

    // close all tooltips and popups when clicking map
    map.on("click", function(event) {

        // do nothing when clicking inside tooltip
        const clickedElement = event.originalEvent.target;
        if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){
            return;
        }

        closeAllTooltips();
        closeAllPopups();

    });

    function isValidLatLng(lat, lng) {

        if(isNaN(lat) || isNaN(lng)){
            return false;
        }

        return true;

    }

    function findNodeById(id) {

        // find node by id
        var node = nodes.find((node) => node.node_id.toString() === id.toString());
        if(node){
            return node;
        }

        return null;

    }

    function findNodeMarkerById(id) {

        // find node marker by id
        var nodeMarker = nodeMarkers[id];
        if(nodeMarker){
            return nodeMarker;
        }

        return null;

    }

    function goToNode(id, animate, zoom){

        // find node
        var node = findNodeById(id);
        if(!node){
            alert("Could not find node: " + id);
            return false;
        }

        // find node marker by id
        var nodeMarker = findNodeMarkerById(id);
        if(!nodeMarker){
            return false;
        }

        // close all popups and tooltips
        closeAllPopups();
        closeAllTooltips();

        // select node
        showNodeOutline(id);

        // fly to node marker
        const shouldAnimate = animate != null ? animate : true;
        map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), {
            animate: getConfigEnableMapAnimations() ? shouldAnimate : false,
        });

        // open tooltip for node
        map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), {
            interactive: true, // allow clicking buttons inside tooltip
            permanent: true, // don't auto dismiss when clicking buttons inside tooltip
        });

        // successfully went to node
        return true;

    }

    function goToRandomNode() {
        if(nodes.length > 0){
            const randomNode = nodes[Math.floor(Math.random() * nodes.length)];
            if(randomNode){

                // go to node
                if(window.goToNode(randomNode.node_id)){
                    return;
                }

                // fallback to showing node details since we can't go to the node
                window.showNodeDetails(randomNode.node_id);

            }
        }
    }

    function clearAllNodes() {
        nodesLayerGroup.clearLayers();
        nodesClusteredLayerGroup.clearLayers();
        nodesRouterLayerGroup.clearLayers();
        nodesBackboneLayerGroup.clearLayers();
        nodesMediumFastLayerGroup.clearLayers();
        nodesLongFastLayerGroup.clearLayers();
    }

    function clearAllNeighbours() {
        neighboursLayerGroup.clearLayers();
        backboneNeighboursLayerGroup.clearLayers();
    }

    function clearAllWaypoints() {
        waypointsLayerGroup.clearLayers();
    }

    function clearAllTraceroutes() {
        traceroutesLayerGroup.clearLayers();
    }

    function closeAllPopups() {
        map.eachLayer(function(layer) {
            if(layer.options.pane === "popupPane"){
                layer.removeFrom(map);
            }
        });
    }

    function closeAllTooltips() {
        map.eachLayer(function(layer) {
            if(layer.options.pane === "tooltipPane"){
                layer.removeFrom(map);
            }
        });
    }

    function clearAllPositionHistory() {
        nodePositionHistoryLayerGroup.clearLayers();
    }

    function clearNodeOutline() {
        if(selectedNodeOutlineCircle){
            selectedNodeOutlineCircle.removeFrom(map);
            selectedNodeOutlineCircle = null;
        }
    }

    function showNodeOutline(id) {

        // remove any existing node circle
        clearNodeOutline();

        // find node marker by id
        const nodeMarker = nodeMarkers[id];
        if(!nodeMarker){
            return;
        }

        // find node by id
        const node = findNodeById(id);
        if(!node){
            return;
        }

        // add position precision circle around node
        if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){
            selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
                radius: getPositionPrecisionInMeters(node.position_precision),
            }).addTo(map);
        }

    }

    function showNodeDetails(id) {

        // find node
        const node = findNodeById(id);
        if(!node){
            return;
        }

        // fire callback to vuejs handler
        window._onNodeClick(node);

    }

    function getColourForSnr(snr) {
        if(snr >= -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: 1,
            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);

        // 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;
                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>` : '')
                    + (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>`;
            })();

            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`
                + `<br/>Distance: ${distance}`
                + (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,
                });
            }

        }

        // 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;
                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>` : '')
                    + (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>`;
            })();

            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`
                + `<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();
                });

        }

    }

    function clearMap() {
        closeAllPopups();
        closeAllTooltips();
        clearAllNodes();
        clearAllNeighbours();
        clearAllWaypoints();
        clearAllTraceroutes();
        clearNodeOutline();
        cleanUpNodeNeighbours();
    }

    // returns true if the element or one of its parents has the class classname
    function elementOrAnyAncestorHasClass(element, className) {

        // check if element contains class
        if(element.classList && element.classList.contains(className)){
            return true;
        }

        // check if parent node has the class
        if(element.parentNode){
            return elementOrAnyAncestorHasClass(element.parentNode, className);
        }

        // couldn't find the class
        return false;

    }

    // escape strings for tooltips etc, to prevent html/script injection
    // not used in vuejs, as that auto escapes
    function escapeString(string) {
        return string.replace(/</g, "&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;
    }

    function onNodesUpdated(updatedNodes) {

        // clear nodes cache
        nodes = [];

        // get config
        const now = moment();
        const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds();
        const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds();
        const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();

        // add nodes
        for(const node of updatedNodes){

            // skip nodes older than configured node max age
            if(configNodesMaxAgeInSeconds){
                const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
                if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){
                    continue;
                }
            }

            // add to cache
            nodes.push(node);

            // skip nodes without position
            if(!node.latitude || !node.longitude){
                continue;
            }

            // fix lat long
            node.latitude = node.latitude / 10000000;
            node.longitude = node.longitude / 10000000;

            // skip nodes with invalid position
            if(!isValidLatLng(node.latitude, node.longitude)){
                continue;
            }

            // wrap longitude for shortest path, everything to left of australia should be shown on the right
            var longitude = parseFloat(node.longitude);
            if(longitude <= 100){
                longitude += 360;
            }

            // icon based on channel preset
            var icon = iconLongFast;

            if (node.channel_id == "MediumFast") {
                icon = iconMediumFast;
            }

            // use offline icon for nodes older than configured node offline age
            if(configNodesOfflineAgeInSeconds){
                const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at));
                if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){
                    icon = iconOffline;
                }
            }

			// determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000)
			var zIndexOffset = 1000;
			if(icon == iconOffline){
				zIndexOffset = -2000;
			} else if(node.channel_id == 'LongFast'){
				zIndexOffset = -1000;
			}

            // determine if node was recently heard uplinking packets to mqtt
            //const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
            //if(nodeHasUplinkedToMqttRecently){
            //    icon = iconMqttConnected;
            //}

            // To not have overlapping nodes. 
			var latJitter = 0;
			var lonJitter = 0;
            // If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement
			if (node.position_precision < 19) {
				const maxMeters = 40;
				const r = maxMeters * Math.sqrt(Math.random());
				const theta = 2 * Math.PI * Math.random();
				const dy = r * Math.sin(theta);
				const dx = r * Math.cos(theta);
				const metersPerDegLat = 111320;
				const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180);
				latJitter = dy / metersPerDegLat;
				lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0;
			}

            // create node marker
			const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], {
                icon: icon,
                tagName: node.node_id,
				// zIndex: offline (-2000) < has channel_id (-1000) < others (1000)
				zIndexOffset: zIndexOffset,
            }).on('click', function(event) {
                // close tooltip on click to prevent tooltip and popup showing at same time
                event.target.closeTooltip();
            });

            // add marker to node layer groups
            marker.addTo(nodesLayerGroup);
            nodesClusteredLayerGroup.addLayer(marker);

            // add markers for routers and repeaters to routers layer group
            if(node.role_name === "ROUTER"
                || node.role_name === "ROUTER_CLIENT"
                || node.role_name === "ROUTER_LATE"
                || node.role_name === "REPEATER"){
                nodesRouterLayerGroup.addLayer(marker);
            }

            // add markers for backbone to layer group
            if(node.is_backbone) {
                nodesBackboneLayerGroup.addLayer(marker);
            }

            // add markers for MediumFast channel to layer group
            if(node.channel_id == "MediumFast") {
                nodesMediumFastLayerGroup.addLayer(marker);
            }

            // add markers for LongFast channel to layer group
            if(node.channel_id == "LongFast") {
                nodesLongFastLayerGroup.addLayer(marker);
            }

            // show tooltip on desktop only
            if(!isMobile()){
                marker.bindTooltip(getTooltipContentForNode(node), {
                    interactive: true,
                });
            }

            // show node info tooltip when clicking node marker
            marker.on("click", function(event) {

                // close all other popups and tooltips
                closeAllTooltips();
                closeAllPopups();

                // find node
                const node = findNodeById(event.target.options.tagName);
                if(!node){
                    return;
                }

                // show position precision outline
                showNodeOutline(node.node_id);

                // open tooltip for node
                map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), {
                    interactive: true, // allow clicking buttons inside tooltip
                    permanent: true, // don't auto dismiss when clicking buttons inside tooltip
                });

            });

            // add to cache
            nodeMarkers[node.node_id] = marker;

        }

        for(const node of updatedNodes){

            // find current node
            const currentNode = findNodeMarkerById(node.node_id);
            if(!currentNode){
                continue;
            }

            // add node neighbours
            const neighbours = node.neighbours ?? [];
            for(const neighbour of neighbours){

                // fixme: skipping zero snr? saw some crazy long neighbours with zero snr...
                if(neighbour.snr === 0){
                    continue;
                }

                const neighbourNode = findNodeById(neighbour.node_id);
                if(!neighbourNode){
                    continue;
                }

                const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id);
                if(neighbourNodeMarker){

                    // calculate distance in meters between nodes (rounded to 2 decimal places)
                    const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2);

                    // don't show this neighbour connection if further than config allows
                    if(configNeighboursMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configNeighboursMaxDistanceInMeters){
                        continue;
                    }

                    // 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
                    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,
                    }).addTo(neighboursLayerGroup);

                    // additional line for backbone neighbours
                    const backboneNeighbourLine = L.polyline([
                        neighbourNodeMarker.getLatLng(),
                        currentNode.getLatLng(),
                    ], {
                        color: getColourForSnr(neighbour.snr),
                        opacity: 0.75,
                        offset: 3, // assuming symmetrical connection
                    }).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);
                    }

                    // default to showing distance in meters
                    var distance = `${distanceInMeters} meters`;

                    // scale to distance in kms
                    if(distanceInMeters >= 1000){
                        const distanceInKilometers = (distanceInMeters / 1000).toFixed(2);
                        distance = `${distanceInKilometers} kilometers`;
                    }

                    const terrainImageUrl = getTerrainProfileImage(node, neighbourNode);

                    const tooltip = `<b>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}</b> heard <b>[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}</b>`
                        + `<br/>SNR: ${neighbour.snr}dB`
                        + `<br/>Distance: ${distance}`
                        + (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();
                    });

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

                }
            }

        }

        window._onNodesUpdated(nodes);

    }

    function onWaypointsUpdated(updatedWaypoints) {

        // clear nodes cache
        waypoints = [];

        // get config
        const now = moment();
        const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds();

        // add nodes
        for(const waypoint of updatedWaypoints){

            // skip waypoints older than configured waypoint max age
            if(configWaypointsMaxAgeInSeconds){
                const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at));
                if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){
                    continue;
                }
            }

            // skip expired waypoints
            if(waypoint.expire < Date.now() / 1000){
                continue;
            }

            // skip waypoints without position
            if(!waypoint.latitude || !waypoint.longitude){
                continue;
            }

            // fix lat long
            waypoint.latitude = waypoint.latitude / 10000000;
            waypoint.longitude = waypoint.longitude / 10000000;

            // skip waypoints with invalid position
            if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){
                continue;
            }

            // wrap longitude for shortest path, everything to left of australia should be shown on the right
            var longitude = parseFloat(waypoint.longitude);
            if(longitude <= 100){
                longitude += 360;
            }

            // determine emoji to show as marker icon
            const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon;
            const emojiText = String.fromCodePoint(emoji)

            var tooltip = getTooltipContentForWaypoint(waypoint);

            // create waypoint marker
            const marker = L.marker([waypoint.latitude, longitude], {
                icon: L.divIcon({
                    className: 'waypoint-label',
                    iconSize: [26, 26], // increase from 12px to 26px
                    html: emojiText,
                }),
            }).bindPopup(tooltip).on('click', function(event) {
                // close tooltip on click to prevent tooltip and popup showing at same time
                event.target.closeTooltip();
            });

            // show tooltip on desktop only
            if(!isMobile()){
                marker.bindTooltip(tooltip, {
                    interactive: true,
                });
            }

            // add marker to waypoints layer groups
            marker.addTo(waypointsLayerGroup);

            // add to cache
            waypoints.push(waypoint);

        }

    }

    function onTracerouteEdgesUpdated(edges) {

        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>`
                + `<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>` : '')
                + (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>`;

            // additional line for backbone neighbours
            const backboneNeighbourLine = L.polyline([
                fromMarker.getLatLng(),
                toMarker.getLatLng(),
            ], {
                color: getColourForSnr(snrDb),
                opacity: 0.75,
                offset: 3, // assuming symmetrical connection
            }).arrowheads({
                size: '10px',
                fill: true,
                offsets: {
                    start: '25px',
                    end: '25px',
                },
            });

            backboneNeighbourLine.bindTooltip(tooltip, {
                sticky: true,
                opacity: 1,
                interactive: true,
            }).bindPopup(tooltip)
            .on('click', function(event) {
                event.target.closeTooltip();
            });

            if(fromNode.is_backbone && toNode.is_backbone) {
                backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
            }
        }
    }

    function onPositionHistoryUpdated(updatedPositionHistories) {

        let positionHistoryLinesCords = [];

        // add nodes
        for(const positionHistory of updatedPositionHistories) {

            // skip position history without position
            if(!positionHistory.latitude || !positionHistory.longitude){
                continue;
            }

            // find node this position is for
            const node = findNodeById(positionHistory.node_id);
            if(!node){
                continue;
            }

            // fix lat long
            positionHistory.latitude = positionHistory.latitude / 10000000;
            positionHistory.longitude = positionHistory.longitude / 10000000;

            // skip position history with invalid position
            if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){
                continue;
            }

            // wrap longitude for shortest path, everything to left of australia should be shown on the right
            var longitude = parseFloat(positionHistory.longitude);
            if(longitude <= 100){
                longitude += 360;
            }

            positionHistoryLinesCords.push([positionHistory.latitude, longitude]);

            let tooltip = "";
            if(positionHistory.type === "position"){
                tooltip += `<b>Position</b>`;
            } else if(positionHistory.type === "map_report"){
                tooltip += `<b>Map Report</b>`;
            }
            tooltip += `<br/>[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`;
            tooltip += `<br/>${positionHistory.latitude}, ${positionHistory.longitude}`;
            tooltip += `<br/>Heard on: ${moment(new Date(positionHistory.created_at)).format("DD/MM/YYYY hh:mm A")}`;

            // add gateway info if available
            if(positionHistory.gateway_id){
                const gatewayNode = findNodeById(positionHistory.gateway_id);
                const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???";
                tooltip += `<br/>Heard by: <a href="javascript:void(0);" onclick="goToNode(${positionHistory.gateway_id})">${gatewayNodeInfo}</a>`;
            }

            // create position history marker
            const marker = L.marker([positionHistory.latitude, longitude],{
                icon: iconPositionHistory,
            }).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) {
                // close tooltip on click to prevent tooltip and popup showing at same time
                event.target.closeTooltip();
            });

            // add marker to position history layer group
            marker.addTo(nodePositionHistoryLayerGroup);

        }

        // show lines between position history markers
        L.polyline(positionHistoryLinesCords, {
            color: "#a855f7",
            opacity: 1,
        }).addTo(nodePositionHistoryLayerGroup);

    }

    function cleanUpPositionHistory() {

        // close tooltips and popups
        closeAllPopups();
        closeAllTooltips();

        // setup node neighbours layer
        nodePositionHistoryLayerGroup.clearLayers();
        nodePositionHistoryLayerGroup.removeFrom(map);
        nodePositionHistoryLayerGroup.addTo(map);

    }
    
    function setLoading(loading){
        var reloadButton = document.getElementById("reload-button");
        if(loading){
            reloadButton.classList.add("animate-spin");
        } else {
            reloadButton.classList.remove("animate-spin");
        }
    }

    async function reload(goToNodeId, zoom) {

        // show loading
        setLoading(true);

        // clear previous data
        clearMap();

        // fetch nodes
        await window.axios.get('/api/v1/nodes').then(async (response) => {

            // update nodes
            onNodesUpdated(response.data.nodes);

            // hide loading
            setLoading(false);

            // go to node id if provided
            if(goToNodeId){

                // go to node
                if(window.goToNode(goToNodeId, false, zoom)){
                    return;
                }

                // fallback to showing node details since we can't go to the node
                window.showNodeDetails(goToNodeId);

            }

        });

        // fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips)
        await window.axios.get('/api/v1/waypoints').then(async (response) => {
            onWaypointsUpdated(response.data.waypoints);
        });

        // 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([]);
        });

    }

    function getRegionFrequencyRange(regionName) {

        // determine lora frequency range based on region_name
        // https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21
        const regionNameToLoraFrequencyRange = {
            "US": "902-928 MHz",
            "EU_433": "433-434 MHz",
            "EU_868": "869.4-869.65 MHz",
            "CN": "470-510 MHz",
            "JP": "920.8-927.8 MHz",
            "ANZ": "915-928 MHz",
            "RU": "868.7-869.2 MHz",
            "KR": "920-923 MHz",
            "TW": "920-925 MHz",
            "IN": "865-867 MHz",
            "NZ_865": "864-868 MHz",
            "TH": "920-925 MHz",
            "UA_433": "433-434.7 MHz",
            "UA_868": "868-868.6 MHz",
            "MY_433": "433-435 MHz",
            "MY_919": "919-924 MHz",
            "SG_923": "917-925 MHz",
            "LORA_24": "2.4-2.4835 GHz",
            "UNSET": "902-928 MHz",
        }

        return regionNameToLoraFrequencyRange[regionName] ?? null;

    }

    function getPositionPrecisionInMeters(positionPrecision) {
        switch(positionPrecision){
            case 2: return 5976446;
            case 3: return 2988223;
            case 4: return 1494111;
            case 5: return 747055;
            case 6: return 373527;
            case 7: return 186763;
            case 8: return 93381;
            case 9: return 46690;
            case 10: return 23345;
            case 11: return 11672; // Android LOW_PRECISION
            case 12: return 5836;
            case 13: return 2918;
            case 14: return 1459;
            case 15: return 729;
            case 16: return 364; // Android MED_PRECISION
            case 17: return 182;
            case 18: return 91;
            case 19: return 45;
            case 20: return 22;
            case 21: return 11;
            case 22: return 5;
            case 23: return 2;
            case 24: return 1;
            case 32: return 0; // Android HIGH_PRECISION
        }
        return null;
    }

    function formatPositionPrecision(positionPrecision) {

        // get position precision in meters
        const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision);
        if(positionPrecisionInMeters == null){
            return "?";
        }

        // format kilometers
        if(positionPrecisionInMeters > 1000){
            const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000);
            return `±${positionPrecisionInKilometers}km`;
        }

        // format meters
        return `±${positionPrecisionInMeters}m`;

    }

    function getTooltipContentForNode(node) {

        // determine if node was recently heard uplinking packets to mqtt
        const nodeHasUplinkedToMqttRecently = hasNodeUplinkedToMqttRecently(node);
        var mqttStatus = `<span class="text-blue-700">Disconnected</span>`;
        if(node.mqtt_connection_state_updated_at){
            var mqttStatusUpdatedAt = moment(new Date(node.mqtt_connection_state_updated_at)).fromNow();
            if(nodeHasUplinkedToMqttRecently){
                mqttStatus = `<span><span class="text-green-700">Connected</span> (${mqttStatusUpdatedAt})</span>`;
            } else {
                mqttStatus = `<span><span class="text-blue-700">Disconnected</span> (${mqttStatusUpdatedAt})</span>`;
            }
        }

        var loraFrequencyRange = getRegionFrequencyRange(node.region_name);

        var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
            `<b>${escapeString(node.long_name)}</b>` +
            `<br/>Short Name: ${escapeString(node.short_name)}` +
            `<br/>MQTT: ${mqttStatus}` +
            (node.num_online_local_nodes != null ? `<br/>Local Nodes Online: ${node.num_online_local_nodes}` : '') +
            (node.position_precision != null && node.position_precision !== 32 ? `<br/>Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') +
            `<br/><br/>Role: ${node.role_name}` +
            `<br/>Hardware: ${node.hardware_model_name}` +
            (node.firmware_version != null ? `<br/>Firmware: ${node.firmware_version}` : '') +
            `<br/>OK to MQTT: ${node.ok_to_mqtt}`;

        if(node.battery_level){
            if(node.battery_level > 100){
                tooltip += `<br/>Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`;
            } else {
                tooltip += `<br/>Battery: ${node.battery_level}%`;
            }
        }

        if(node.voltage){
            tooltip += `<br/>Voltage: ${Number(node.voltage).toFixed(2)}V`;
        }

        if(node.channel_utilization){
            tooltip += `<br/>Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`;
        }

        if(node.air_util_tx){
            tooltip += `<br/>Air Util: ${Number(node.air_util_tx).toFixed(2)}%`;
        }

        // ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109
        if(node.altitude && node.altitude < 42949000){
            tooltip += `<br/>Altitude: ${node.altitude}m`;
        }

        // bottom info
        tooltip += `<br/><br/>ID: ${node.node_id}`;
        tooltip += `<br/>Hex ID: ${node.node_id_hex}`;
        tooltip += `<br/>Updated: ${moment(new Date(node.updated_at)).fromNow()}`;
        tooltip += (node.neighbours_updated_at ? `<br/>Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '');
        tooltip += (node.position_updated_at ? `<br/>Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : '');

        // show details button
        tooltip += `<br/><br/><button onclick="showNodeDetails(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Full Details</button>`;
        tooltip += `<br/><button onclick="showNodeNeighboursThatHeardUs(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200 mb-1">Show Neighbours (Heard Us)</button>`;
        tooltip += `<br/><button onclick="showNodeNeighboursThatWeHeard(${node.node_id});" class="border border-gray-300 bg-gray-100 p-1 w-full rounded hover:bg-gray-200">Show Neighbours (We Heard)</button>`;
        tooltip += `</div>`;

        return tooltip;

    }

    function getTooltipContentForWaypoint(waypoint) {

        // get from node name
        var fromNode = findNodeById(waypoint.from);

        var tooltip = `<b>${escapeString(waypoint.name)}</b>` +
            (waypoint.description ? `<br/>${escapeString(waypoint.description)}` : '') +
            `<br/><br/>Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` +
            `<br/>Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` +
            `<br/><br/>From ID: ${waypoint.from}` +
            `<br/>From Hex ID: !${Number(waypoint.from).toString(16)}`;

        // show node name this waypoint is from, if possible
        if(fromNode != null){
            tooltip += `<br/>From Node: <a href="#" onclick="goToNode(${waypoint.from})">${escapeString(fromNode.long_name) || 'Unnamed Node'}</a>`;
        } else {
            tooltip += `<br/>From Node: ???`;
        }

        // bottom info
        tooltip += `<br/><br/>ID: ${waypoint.waypoint_id}`;
        tooltip += `<br/>Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`;

        return tooltip;

    }

    window._onHideNodeNeighboursClick = function() {
        cleanUpNodeNeighbours();
    };

    // parse url params
    var queryParams = new URLSearchParams(location.search);
    var queryNodeId = queryParams.get('node_id');
    var queryLat = queryParams.get('lat');
    var queryLng = queryParams.get('lng');
    var queryZoom = queryParams.get('zoom');

    // go to lat/lng if provided
    if(queryLat && queryLng){
        const zoomLevel = queryZoom || getConfigZoomLevelGoToNode();
        map.flyTo([queryLat, queryLng], zoomLevel, {
            animate: false,
        });
    }

    // auto update url when lat/lng/zoom changes
    map.on("moveend zoomend", function() {

        // check if user enabled auto updating position in url
        const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl();
        if(!autoUpdatePositionInUrl){
            return;
        }

        // get map info
        const latLng = map.getCenter();
        const zoom = map.getZoom();

        // construct new url
        const url = new URL(window.location.href);
        url.searchParams.set("lat", latLng.lat);
        url.searchParams.set("lng", latLng.lng);
        url.searchParams.set("zoom", zoom);

        // update current url
        if(window.history.replaceState){
            window.history.replaceState(null, null, url.toString());
        }

    });

    // reload and go to provided node id
    reload(queryNodeId, queryZoom);

    // WebSocket connection for real-time messages
    var ws = null;
    var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
    var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
    var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup

    function connectWebSocket() {
        // Determine WebSocket URL - use same hostname as current page, port 8081
        const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
        // Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx
        const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
        const wsUrl = isLocalhost
            ? `${wsProtocol}//${location.hostname}:8081`
            : `${wsProtocol}//${location.host}/ws`;

        console.log('Connecting to WebSocket:', wsUrl);
        ws = new WebSocket(wsUrl);

        ws.onopen = function() {
            console.log('WebSocket connected');
        };

        ws.onmessage = function(event) {
            try {
                const message = JSON.parse(event.data);
                handleWebSocketMessage(message);
            } catch (e) {
                console.error('Error parsing WebSocket message:', e);
            }
        };

        ws.onerror = function(error) {
            console.error('WebSocket error:', error);
        };

        ws.onclose = function() {
            console.log('WebSocket disconnected, reconnecting in 5 seconds...');
            setTimeout(connectWebSocket, 5000);
        };
    }

    function handleWebSocketMessage(message) {
        if (message.type === 'traceroute') {
            handleTraceroute(message.data);
        }
    }

    function handleTraceroute(data) {
        // Only visualize traceroutes where want_response is false (the reply coming back)
        if (data.want_response) {
            return;
        }

        // When want_response is false, from and to are swapped from the original request
        // The path goes from 'to' (original sender) through route to 'from' (original destination)
        const originalSender = data.to; // This was the original sender
        const originalDestination = data.from; // This was the original destination
        const route = data.route || [];
        const snrTowards = data.snr_towards || [];

        // Deduplicate: ignore traceroutes from the same original sender for 20 seconds
        const now = Date.now();
        if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
            return; // Still in cooldown period
        }

        // Create unique key for this traceroute path to prevent duplicate visualizations
        // Use original sender (to), original destination (from), and route to create unique key
        // (ignoring gateway_id since multiple gateways can receive same route)
        const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
        if (activeTracerouteKeys.has(routeKey)) {
            return; // Already visualizing this route
        }

        // Mark as active and set cooldown
        activeTracerouteKeys.add(routeKey);
        tracerouteCooldown[originalSender] = now;

        // Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
        const path = [originalSender]; // Start from original sender
        if (route.length > 0) {
            path.push(...route);
        }
        path.push(originalDestination); // End at original destination

        // Visualize the traceroute with animated hops
        visualizeTraceroute(path, snrTowards, routeKey);
    }

    function visualizeTraceroute(path, snrTowards, routeKey) {
        // Verify all nodes in path exist on map
        const pathMarkers = [];
        for (const nodeId of path) {
            const marker = findNodeMarkerById(nodeId);
            if (!marker) {
                // Node not on map, skip this traceroute
                activeTracerouteKeys.delete(routeKey);
                return;
            }
            pathMarkers.push(marker);
        }

        // Store lines and overlays for this route key for cleanup
        const routeElements = {
            lines: [],
            startOverlay: null,
            endOverlay: null,
        };
        tracerouteLines[routeKey] = routeElements;

        // Color starting node (first in path) green and destination node (last in path) red
        const startMarker = pathMarkers[0];
        const endMarker = pathMarkers[pathMarkers.length - 1];
        
        const startOverlay = L.marker(startMarker.getLatLng(), {
            icon: iconTracerouteStart,
            zIndexOffset: 10000, // Ensure it's on top
        }).addTo(traceroutesLayerGroup);
        
        const endOverlay = L.marker(endMarker.getLatLng(), {
            icon: iconTracerouteEnd,
            zIndexOffset: 10000, // Ensure it's on top
        }).addTo(traceroutesLayerGroup);
        
        // Store overlays for cleanup
        routeElements.startOverlay = startOverlay;
        routeElements.endOverlay = endOverlay;

        // Animate each hop sequentially
        let hopIndex = 0;
        const animateNextHop = () => {
            if (hopIndex >= pathMarkers.length - 1) {
                // All hops animated, cleanup after delay
                setTimeout(() => {
                    if (tracerouteLines[routeKey]) {
                        const routeElements = tracerouteLines[routeKey];
                        // Remove all lines
                        if (routeElements.lines) {
                            routeElements.lines.forEach(line => {
                                line.remove();
                            });
                        }
                        // Remove node overlays
                        if (routeElements.startOverlay) {
                            routeElements.startOverlay.remove();
                        }
                        if (routeElements.endOverlay) {
                            routeElements.endOverlay.remove();
                        }
                        delete tracerouteLines[routeKey];
                    }
                    activeTracerouteKeys.delete(routeKey);
                }, 2000);
                return;
            }

            const fromMarker = pathMarkers[hopIndex];
            const toMarker = pathMarkers[hopIndex + 1];
            const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;

            // Use orange color for all traceroute lines
            const lineColor = '#f97316'; // orange

            // Create animated polyline for this hop with orange dotted style
            const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
                color: lineColor,
                weight: 4,
                opacity: 0, // Start invisible
                // dashArray: '10, 5', // Dotted line style
                zIndexOffset: 10000,
            }).addTo(traceroutesLayerGroup);

            // Fade in animation
            line.setStyle({ opacity: 1.0 });
            tracerouteLines[routeKey].lines.push(line);

            // Animate next hop after 600ms delay
            hopIndex++;
            setTimeout(animateNextHop, 600);
        };

        // Start animation
        animateNextHop();
    }

    // Connect WebSocket when page loads
    connectWebSocket();

</script>

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

</body>
</html>
