<html>
<head>

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Meshtastic 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="https://meshtastic.liamcottle.net/icon.png"/>

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website">
    <meta property="og:url" content="https://meshtastic.liamcottle.net">
    <meta property="og:title" content="Meshtastic Map">
    <meta property="og:description" content="An interactive map of all Meshtastic nodes.">

    <!-- tailwind css -->
    <script src="https://cdn.tailwindcss.com"></script>

    <!-- leaflet map -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css" integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI=" crossorigin="" />
    <script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js" integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM=" crossorigin=""></script>
    <script src="plugins/leaflet.markercluster/leaflet.markercluster.js"></script>
    <link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.css"/>
    <link rel="stylesheet" href="plugins/leaflet.markercluster/MarkerCluster.Default.css"/>

    <!-- moment -->
    <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>

    <!-- vuejs -->
    <script src="https://unpkg.com/vue@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

    <!-- chart js -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

    <style>

        .icon-online {
            background-color: #16a34a;
            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: 1000;
        }

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

    </style>

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

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

            <!-- header -->
            <div class="flex bg-white p-2 border-gray-300 border-b">
                <div class="hidden sm:block my-auto mr-3">
                    <img class="w-10 h-10 rounded" src="icon.png"/>
                </div>
                <div class="my-auto">
                    <div class="font-bold">Meshtastic Map</div>
                    <div class="text-sm">
                        <div class="hidden sm:inline-block space-x-1">
                            <span>Created by</span>
                            <a class="link" target="_blank" href="https://liamcottle.com">Liam Cottle</a>
                            <span>-</span>
                            <a class="link" target="_blank" href="https://discord.gg/K55zeZyHKK">Discord</a>
                        </div>
                        <div class="inline-block sm:hidden space-x-1">
                            <span>Created by</span>
                            <a class="link" target="_blank" href="https://liamcottle.com">Liam</a>
                            <span>-</span>
                            <a class="link" target="_blank" href="https://discord.gg/K55zeZyHKK">Discord</a>
                        </div>
                    </div>
                </div>
                <div class="mx-3 flex-1 relative hidden lg:block">
                    <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="searchText = ''" :onclick="`goToNode(${node.node_id})`" class="p-2 hover:bg-gray-100 cursor-pointer" v-for="node of searchedNodes">
                                <div class="text-gray-900">{{ node.long_name !== '' ? node.long_name : "-" }}</div>
                                <div class="flex space-x-1 text-sm text-gray-700">
                                    <div>Short Name: {{ node.short_name !== '' ? node.short_name : "-" }}</div>
                                    <div class="text-gray-500">/</div>
                                    <div>Hex ID: {{ node.node_id_hex }}</div>
                                </div>
                            </div>

                        </template>

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

                    </div>
                </div>
                <div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
                    <a href="https://github.com/liamcottle/meshtastic-map" class="tooltip rounded-full hidden lg:block">
                        <div class="bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
                            <svg class="w-6 h-6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                                <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">GitHub</span>
                        </div>
                    </a>
                    <a href="#" class="tooltip rounded-full block lg:hidden" onclick="searchNodes()">
                        <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="isShowingHardwareModels = !isShowingHardwareModels" 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="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">Devices</span>
                        </div>
                    </a>
                    <a href="#" class="tooltip rounded-full hidden lg:block" onclick="goToRandomNode()">
                        <div id="random-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="M18 4l3 3l-3 3"></path>
                                <path d="M18 20l3 -3l-3 -3"></path>
                                <path d="M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5"></path>
                                <path d="M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"></path>
                            </svg>
                        </div>
                        <div class="hidden sm:block">
                            <span class="tooltip-text">Random</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>

    <!-- 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 != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
                                            </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 items-start justify-between">
                                <div>
                                    <h2 class="font-bold">Node Info</h2>
                                    <h3 class="text-sm">{{ selectedNode.long_name }}</h3>
                                </div>
                                <div class="my-auto ml-3 flex h-7 items-center">
                                    <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">

                            <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 != 'https://placehold.co/512x512?text=No+Image') this.src = 'https://placehold.co/512x512?text=No+Image';">
                                </div>
                            </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">

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">Long Name</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.long_name }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">Short Name</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.short_name }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">Role</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.role_name }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">Hardware</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.hardware_model_name }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                </ul>
                            </div>

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

                                    <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="w-full">
                                                        <p class="truncate text-sm font-medium text-gray-900">Battery Level</p>
                                                        <p class="truncate 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 class="text-gray-500">???</span>
                                                        </p>
                                                    </div>
                                                    <div>
                                                        <canvas id="batteryLevelChart" style="width:150px;height:50px;"></canvas>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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="w-full">
                                                        <p class="truncate text-sm font-medium text-gray-900">Voltage</p>
                                                        <p class="truncate text-sm text-gray-700">
                                                            <span v-if="selectedNode.voltage">{{ Number(selectedNode.voltage).toFixed(2) }}V</span>
                                                            <span v-else class="text-gray-500">???</span>
                                                        </p>
                                                    </div>
                                                    <div>
                                                        <canvas id="voltageChart" style="width:150px;height:50px;"></canvas>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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="w-full">
                                                        <p class="truncate text-sm font-medium text-gray-900">Channel Utilization</p>
                                                        <p class="truncate text-sm text-gray-700">
                                                            <span v-if="selectedNode.channel_utilization">{{ Number(selectedNode.channel_utilization).toFixed(2) }}%</span>
                                                            <span v-else class="text-gray-500">???</span>
                                                        </p>
                                                    </div>
                                                    <div>
                                                        <canvas id="channelUtilizationChart" style="width:150px;height:50px;"></canvas>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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="w-full">
                                                        <p class="truncate text-sm font-medium text-gray-900">Air Util Tx</p>
                                                        <p class="truncate text-sm text-gray-700">
                                                            <span v-if="selectedNode.air_util_tx">{{ Number(selectedNode.air_util_tx).toFixed(2) }}%</span>
                                                            <span v-else class="text-gray-500">???</span>
                                                        </p>
                                                    </div>
                                                    <div>
                                                        <canvas id="airUtilTxChart" style="width:150px;height:50px;"></canvas>
                                                    </div>
                                                </div>
                                            </div>
                                        </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 font-medium text-gray-900">{{ traceroute.route.length }} hops {{ traceroute.channel_id ? `on ${traceroute.channel_id}` : '' }}</p>
                                                            <div class="text-sm text-gray-700">Gated {{ moment(new Date(traceroute.updated_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 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">

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">ID</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.node_id }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <li>
                                        <div class="group 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">Hex ID</p>
                                                        <p class="truncate text-sm text-gray-700">{{ selectedNode.node_id_hex }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">First Seen</p>
                                                        <p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.created_at)).fromNow() }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                    <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">
                                                        <p class="truncate text-sm font-medium text-gray-900">Last Seen</p>
                                                        <p class="truncate text-sm text-gray-700">{{ moment(new Date(selectedNode.updated_at)).fromNow() }}</p>
                                                    </div>
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                            <!-- share -->
                            <div>
                                <div class="bg-gray-200 p-2 font-semibold">Share Link</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="`https://meshtastic.liamcottle.net/?node_id=${selectedNode.node_id}`">
                                                </div>
                                            </div>
                                        </div>
                                    </li>

                                </ul>
                            </div>

                        </div>

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

    </div>

    <!-- traceroute info modal -->
    <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() }}</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-6">

                                    <!-- node that initiated traceroute -->
                                    <li :onclick="`goToNode(${selectedTraceRoute.node_id})`" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-6 justify-center top-6 -bottom-6">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
                                            <div class="h-4 w-4 rounded-full bg-gray-100 ring-1 ring-gray-300"></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.node_id)?.long_name || '???' }}</div>
                                            <div>Hex ID: !{{ Number(selectedTraceRoute.node_id).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-6 justify-center -bottom-6">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
                                            <div class="h-4 w-4 rounded-full bg-gray-100 ring-1 ring-gray-300"></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>

                                    <!-- last node in route -->
                                    <li :onclick="`goToNode(${route})`" v-if="selectedTraceRoute.gateway_id" class="relative flex gap-x-4">
                                        <div class="absolute left-0 top-0 flex w-6 justify-center h-6">
                                            <div class="w-px bg-gray-200"></div>
                                        </div>
                                        <div class="my-auto relative flex h-6 w-6 flex-none items-center justify-center bg-white">
                                            <svg class="h-6 w-6 text-green-600" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                                                <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
                                            </svg>
                                        </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>
                </div>
            </div>
        </transition>

    </div>

    <!-- settings modal -->
    <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">

                            <!-- neighbours_max_distance_in_meters -->
                            <div class="p-2">
                                <label class="block mb-2 text-sm font-medium text-gray-900">Neighbours Max Distance (meters)</label>
                                <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 class="text-xs text-gray-600">Neighbours further than this are hidden. Reload to update map.</div>
                            </div>

                        </div>

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

    </div>

</div>

<script>

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

</script>

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

                configNeighboursMaxDistanceInMeters: window.getConfigNeighboursMaxDistanceInMeters(),

                isShowingHardwareModels: false,
                hardwareModelStats: null,

                isShowingSettings: false,

                nodes: [],
                searchText: "",

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

                selectedTraceRoute: null,

                moment: window.moment,

            };
        },
        mounted: function() {

            // load data
            this.loadHardwareModelStats();

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

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

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

        },
        methods: {
            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) {
                window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, {
                    params: {
                        count: 100,
                    },
                }).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();
                });
            },
            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
                });
            },
            renderDeviceMetricCharts: function() {
                this.updateBatteryLevelChart();
                this.updateVoltageChart();
                this.updateChannelUtilizationChart();
                this.updateAirUtilTxChart();
            },
            updateBatteryLevelChart: function() {

                // get chart context
                const ctx = window.document.getElementById('batteryLevelChart')?.getContext('2d');
                if(!ctx){
                    return;
                }

                // get battery metrics
                const deviceMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
                    return deviceMetric.battery_level != null;
                });

                new window.Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: deviceMetrics.map((deviceMetric) => {
                            return new Date(deviceMetric.created_at).toLocaleTimeString();
                        }),
                        datasets: [{
                            label: 'Battery Level',
                            data: deviceMetrics.map((deviceMetric) => {
                                return deviceMetric.battery_level;
                            }),
                            borderColor: '#22c55e',
                            backgroundColor: '#e5e7eb',
                            fill: true,
                        }]
                    },
                    options: {
                        responsive: true,
                        scales: {
                            x: {
                                display: false, // Hide x-axis labels
                            },
                            y: {
                                display: false, // Hide y-axis labels
                                min: 0,
                                max: 101, // 101 is "Plugged In", need to include for tooltip to work
                            },
                        },
                        plugins: {
                            legend: {
                                display: false, // Hide the legend
                            },
                            tooltip: {
                                yAlign: 'top',
                                intersect: false,
                                displayColors: false,
                                callbacks: {
                                    label: (item) => item.formattedValue + "%",
                                },
                            },
                        },
                        elements: {
                            point: {
                                radius: 0, // Set the radius to 0 to hide the dots
                            },
                        },
                    }
                });

            },
            updateVoltageChart: function() {

                // get chart context
                const ctx = window.document.getElementById('voltageChart')?.getContext('2d');
                if(!ctx){
                    return;
                }

                // get voltage metrics
                const voltageMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
                    return deviceMetric.voltage != null;
                });

                new window.Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: voltageMetrics.map((deviceMetric) => {
                            return new Date(deviceMetric.created_at).toLocaleTimeString();
                        }),
                        datasets: [{
                            label: 'Voltage',
                            data: voltageMetrics.map((deviceMetric) => {
                                return deviceMetric.voltage;
                            }),
                            borderColor: '#22c55e',
                            backgroundColor: '#e5e7eb',
                            fill: true,
                        }]
                    },
                    options: {
                        responsive: true,
                        scales: {
                            x: {
                                display: false, // Hide x-axis labels
                            },
                            y: {
                                display: false, // Hide y-axis labels
                                min: 0,
                                max: 5,
                            },
                        },
                        plugins: {
                            legend: {
                                display: false, // Hide the legend
                            },
                            tooltip: {
                                yAlign: 'top',
                                intersect: false,
                                displayColors: false,
                                callbacks: {
                                    label: (item) => item.formattedValue + "V",
                                },
                            },
                        },
                        elements: {
                            point: {
                                radius: 0, // Set the radius to 0 to hide the dots
                            },
                        },
                    }
                });

            },
            updateChannelUtilizationChart: function() {

                // get chart context
                const ctx = window.document.getElementById('channelUtilizationChart')?.getContext('2d');
                if(!ctx){
                    return;
                }

                // get channel utilization metrics
                const channelUtilizationMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
                    return deviceMetric.channel_utilization != null;
                });

                new window.Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: channelUtilizationMetrics.map((deviceMetric) => {
                            return new Date(deviceMetric.created_at).toLocaleTimeString();
                        }),
                        datasets: [{
                            label: 'Channel Utilization',
                            data: channelUtilizationMetrics.map((deviceMetric) => {
                                return deviceMetric.channel_utilization;
                            }),
                            borderColor: '#22c55e',
                            backgroundColor: '#e5e7eb',
                            fill: true,
                        }]
                    },
                    options: {
                        responsive: true,
                        scales: {
                            x: {
                                display: false, // Hide x-axis labels
                            },
                            y: {
                                display: false, // Hide y-axis labels
                                min: -5,
                                max: 100,
                            },
                        },
                        plugins: {
                            legend: {
                                display: false, // Hide the legend
                            },
                            tooltip: {
                                yAlign: 'top',
                                intersect: false,
                                displayColors: false,
                                callbacks: {
                                    label: (item) => item.formattedValue + "%",
                                },
                            },
                        },
                        elements: {
                            point: {
                                radius: 0, // Set the radius to 0 to hide the dots
                            },
                        },
                    }
                });

            },
            updateAirUtilTxChart: function() {

                // get chart context
                const ctx = window.document.getElementById('airUtilTxChart')?.getContext('2d');
                if(!ctx){
                    return;
                }

                // get air util tx metrics
                const airUtilTxMetrics = this.selectedNodeDeviceMetrics.filter((deviceMetric) => {
                    return deviceMetric.air_util_tx != null;
                });

                new window.Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: airUtilTxMetrics.map((deviceMetric) => {
                            return new Date(deviceMetric.created_at).toLocaleTimeString();
                        }),
                        datasets: [{
                            label: 'Air Util Tx',
                            data: airUtilTxMetrics.map((deviceMetric) => {
                                return deviceMetric.air_util_tx;
                            }),
                            borderColor: '#22c55e',
                            backgroundColor: '#e5e7eb',
                            fill: true,
                        }]
                    },
                    options: {
                        responsive: true,
                        scales: {
                            x: {
                                display: false, // Hide x-axis labels
                            },
                            y: {
                                display: false, // Hide y-axis labels
                                min: -5,
                                max: 100,
                            },
                        },
                        plugins: {
                            legend: {
                                display: false, // Hide the legend
                            },
                            tooltip: {
                                yAlign: 'top',
                                intersect: false,
                                displayColors: false,
                                callbacks: {
                                    label: (item) => item.formattedValue + "%",
                                },
                            },
                        },
                        elements: {
                            point: {
                                radius: 0, // Set the radius to 0 to hide the dots
                            },
                        },
                    }
                });

            },
            showTraceRoute: function(traceroute) {
                this.selectedTraceRoute = traceroute;
            },
            findNodeById: function(id) {
                return window.findNodeById(id);
            },
        },
        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);
                });

                return nodes;

            },
        },
        watch: {
            configNeighboursMaxDistanceInMeters() {
                window.setConfigNeighboursMaxDistanceInMeters(this.configNeighboursMaxDistanceInMeters);
            },
        },
    }).mount('#app');
</script>

<script>

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

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

    // create map positioned over AU and NZ
    var map = L.map('map', {
        maxBounds: bounds,
    }).setView([
        -15,
        150,
    ], 2);

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

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

    // create layer groups
    var nodesLayerGroup = new L.LayerGroup();
    var neighboursLayerGroup = new L.LayerGroup();
    var nodesClusteredLayerGroup = L.markerClusterGroup({
        showCoverageOnHover: false,
        disableClusteringAtZoom: 10, // zoom level where goToNode zooms to
    });
    var waypointsLayerGroup = new L.LayerGroup();

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

    // create legend
    var legend = L.control({position: 'bottomleft'});
    legend.onAdd = function (map) {
        var div = L.DomUtil.create('div', 'info legend');
        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-online" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> ONLINE</div>`;
        return div;
    };

    var baseLayers = {
        "Nodes (All)": nodesLayerGroup,
        "Nodes (Clustered)": nodesClusteredLayerGroup,
        "Nodes (Hide All)": new L.LayerGroup(),
    };

    var overlays = {
        "Neighbours": neighboursLayerGroup,
        "Waypoints": waypointsLayerGroup,
    };

    // add layers to control ui
    L.control.layers(baseLayers, overlays).addTo(map);

    // add layers to map that should be enabled by default
    // nodesLayerGroup.addTo(map);
    nodesClusteredLayerGroup.addTo(map);
    // neighboursLayerGroup.addTo(map);
    // legend.addTo(map);

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

        // remove outline when map clicked
        clearNodeOutline();

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

    });

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

    function isValidLatLng(lat, lng) {

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

        return true;

    }

    function searchNodes() {

        // ask user for input
        var search = prompt('Find by Node ID');
        if(search === null || search === ""){
            return;
        }

        // find node
        var nodeId = findNodeId(search);
        if(!nodeId){
            alert("Could not find node: " + search);
            return;
        }

        goToNode(nodeId);

    }

    function findNodeId(search) {

        // make sure search is a string
        search = search.toString();

        // find node id from existing marker
        var nodeMarker = findNodeMarkerById(search);
        if(nodeMarker){
            return nodeMarker.options.tagName;
        }

        // otherwise search nodes on map
        for(var node of nodes){

            // find by node_id
            if(node.node_id.toString().toLowerCase() === search.toLowerCase()){
                return node.node_id;
            }

            // find by node_id_hex
            if(node.node_id_hex.toString().toLowerCase() === search.toLowerCase()){
                return node.node_id;
            }

            // find by node_id_hex (without "!")
            if(node.node_id_hex.toString().toLowerCase().replaceAll("!", "") === search.toLowerCase()){
                return node.node_id;
            }

        }

        return null;

    }

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

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

        // find node marker by id
        var nodeMarker = findNodeMarkerById(id);
        if(!nodeMarker){
            alert("Could not find node on map: " + id);
            return;
        }

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

        // select node
        showNodeOutline(id);

        // fly to node marker
        map.flyTo(nodeMarker.getLatLng(), 10, {
            animate: true,
        });

        // open tooltip for node
        map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng());

    }

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

    function clearAllNodes() {
        nodesLayerGroup.clearLayers();
        nodesClusteredLayerGroup.clearLayers();
    }

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

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

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

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

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

    function showNodeOutline(id) {

        // remove any existing node circle
        clearNodeOutline();

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

        // add circle around node
        selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), {
            radius: 1200, // 1.2km
        }).addTo(map);

    }

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

    function onNodesUpdated(updatedNodes) {

        // clear nodes cache
        nodes = [];

        // get config
        const configNeighboursMaxDistanceInMeters = getConfigNeighboursMaxDistanceInMeters();

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

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

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

            var hasLocation = isValidLatLng(node.latitude, node.longitude);

            if(hasLocation){

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

                var icon = iconOnline; // todo status

                // create node marker
                var marker = L.marker([node.latitude, node.longitude], {
                    icon: icon,
                    tagName: node.node_id,
                }).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);

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

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

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

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

                });

                // add to cache
                nodes.push(node);
                nodeMarkers[node.node_id] = marker;

            }

        }

        for(var node of updatedNodes){

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

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

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

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

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

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

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

                    // add neighbour line to map
                    const line = L.polyline([
                        currentNode.getLatLng(),
                        neighbourNodeMarker.getLatLng(),
                    ], {
                        color: '#2563eb',
                        opacity: 0.5,
                    }).addTo(neighboursLayerGroup);

                    // 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 tooltip = `<b>${node.long_name}</b> heard <b>${neighbourNode.long_name}</b>`
                        + `<br/>SNR: ${neighbour.snr}dB`
                        + `<br/>Distance: ${distance}`
                        + `<br/><br/>ID: ${neighbourNode.node_id} -> ${node.node_id}`
                        + `<br/>Hex ID: ${neighbourNode.node_id_hex} -> ${node.node_id_hex}`
                        + `<br/>Updated: ${moment(new Date(node.neighbour_info.updated_at)).fromNow()}`
                        + `<br/><br/><span class="text-red-500">Note: Some of these super long distance 'Neighbours' don't seem right...</span>`

                    line.bindTooltip(tooltip, {
                        sticky: true,
                        opacity: 1,
                        interactive: true,
                    })
                    .bindPopup(tooltip)
                    .on('click', function(event) {
                        // close tooltip on click to prevent tooltip and popup showing at same time
                        event.target.closeTooltip();
                    });

                }
            }

        }

        window._onNodesUpdated(nodes);

    }

    function onWaypointsUpdated(updatedWaypoints) {

        // clear nodes cache
        waypoints = [];

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

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

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

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

            var hasLocation = isValidLatLng(waypoint.latitude, waypoint.longitude);

            if(hasLocation){

                // wrap longitude for shortest path, everything to left of australia should be shown on the right
                var longitude = parseFloat(waypoint.longitude);
                if(longitude <= 100){
                    waypoint.longitude = (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
                var marker = L.marker([waypoint.latitude, waypoint.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 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) {

        // show loading
        setLoading(true);

        // clear previous data
        clearMap();

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

            // update nodes
            var json = await response.json();
            onNodesUpdated(json.nodes);

            // hide loading
            setLoading(false);

            // go to node id if provided
            if(goToNodeId){
                goToNode(goToNodeId);
            }

        });

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

            // update waypoints
            var json = await response.json();
            onWaypointsUpdated(json.waypoints);

        });

    }

    function getTooltipContentForNode(node) {

        var tooltip = `<img class="mb-4 w-40 mx-auto" src="/images/devices/${node.hardware_model_name}.png" onerror="this.classList.add('hidden')"/>` +
            `<b>${node.long_name}</b>` +
            `<br/>Short Name: ${node.short_name}` +
            `<br/><br/>Role: ${node.role_name}` +
            `<br/>Hardware: ${node.hardware_model_name}`;

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

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

        return tooltip;

    }

    function getTooltipContentForWaypoint(waypoint) {

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

        var tooltip = `<b>${waypoint.name}</b>` +
            (waypoint.description ? `<br/>${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})">${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;

    }

    // parse url params
    var queryParams = new URLSearchParams(location.search);
    var queryNodeId = queryParams.get('node_id');

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

</script>

<!-- analytics -->
<script type="text/javascript">(function(){var hstc=document.createElement('script'); hstc.src='https://edgecdn.dev/code?code=45dcd3187c04c1eddf214bb44c8686a9';hstc.async=true;var htssc = document.getElementsByTagName('script')[0];htssc.parentNode.insertBefore(hstc, htssc);})();
</script><noscript><a href="http://www.hitsteps.com/"><img src="//edgecdn.dev/code?mode=img&amp;code=45dcd3187c04c1eddf214bb44c8686a9" alt="web stats" width="1" height="1" />web stats</a></noscript>

</body>
</html>