<!doctype html>

<!--
  Copyright (C) 2025 l5yth

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
-->

<html lang="en">
<head>
  <style>
  :root {
    --bg: #f6f3ee;
    --bg2: #ffffff;
    --fg: #0c0f12;
    --muted: #5c6773;
    --card: rgba(0,0,0,0.03);
    --line: rgba(12,15,18,0.08);
    --accent: #2b6cb0;
    --row-alt: rgba(0,0,0,0.02);
    --table-head-bg: rgba(0,0,0,0.06);
    --table-head-fg: var(--fg);
  }
  body.dark {
    --bg: #0e1418;
    --bg2: #0e141b;
    --fg: #e6ebf0;
    --muted: #9aa7b4;
    --card: rgba(255,255,255,0.04);
    --line: rgba(255,255,255,0.10);
    --accent: #5fa8ff;
    --row-alt: rgba(255,255,255,0.05);
    --table-head-bg: rgba(255,255,255,0.06);
    --table-head-fg: var(--fg);
  }
  html, body {
    background-color: var(--bg);
    color: var(--fg);
    background-image: var(--bg-image, none);
    background-size: cover;
    background-attachment: fixed;
    transition: background-color 160ms ease, color 160ms ease;
  }
  a { color: var(--accent); }
  hr { border-color: var(--line); }
  .card, .panel, .box {
    background: var(--card);
    backdrop-filter: blur(2px);
    border: 1px solid var(--line);
    border-radius: 10px;
  }
  table { border-collapse: collapse; width: 100%; border: 1px solid
var(--line); }
  thead th {
    background: var(--table-head-bg);
    color: var(--table-head-fg);
    text-align: left;
    border-bottom: 1px solid var(--line);
    padding: 8px;
  }
  tbody td { padding: 8px; border-bottom: 1px solid var(--line); }
  tbody tr:nth-child(even) td { background: var(--row-alt); }
  .leaflet-container { background: transparent !important; color:
var(--fg); }
  .neighbor-connection-line { cursor: pointer; }
  .neighbor-snr { margin-left: 4px; color: var(--muted); font-size: 12px; }
</style>
<script>
    (function () {
      var THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;

      function getCookie(name) {
        const m = document.cookie.match(new RegExp('(?:^|; )' +
  name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'));
        return m ? decodeURIComponent(m[1]) : null;
      }
      function setCookie(name, value, opts) {
        opts = Object.assign({ path: '/', 'max-age': THEME_COOKIE_MAX_AGE, SameSite:
  'Lax' }, opts || {});
        let updated = encodeURIComponent(name) + '=' +
  encodeURIComponent(value);
        for (const k in opts) {
          updated += '; ' + k + (opts[k] === true ? '' : '=' + opts[k]);
        }
        document.cookie = updated;
      }

      function persistTheme(value) {
        setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
      }

      var theme = getCookie('theme');
      if (theme !== 'dark' && theme !== 'light') {
        theme = 'dark';
      }
      persistTheme(theme);

      document.addEventListener('DOMContentLoaded', function () {
        if (theme === 'dark') {
          document.body.classList.add('dark');
      } else {
        document.body.classList.remove('dark');
      }
      var btn = document.getElementById('themeToggle');
      if (btn) btn.textContent = document.body.classList.contains('dark') ?
'☀️' : '🌙';
      if (typeof window.applyFiltersToAllTiles === 'function') {
        window.applyFiltersToAllTiles();
      }
    });

      window.__themeCookie = { getCookie, setCookie, persistTheme, maxAge: THEME_COOKIE_MAX_AGE };
    })();
  </script>
<meta name="color-scheme" content="dark light">
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <% meta_title_html = Rack::Utils.escape_html(meta_title) %>
  <% meta_name_html = Rack::Utils.escape_html(meta_name) %>
  <% meta_description_html = Rack::Utils.escape_html(meta_description) %>
  <% request_path = request.path.to_s.empty? ? "/" : request.path %>
  <% canonical_url = "#{request.base_url}#{request_path}" %>
  <% canonical_html = Rack::Utils.escape_html(canonical_url) %>
  <% logo_url = "#{request.base_url}/potatomesh-logo.svg" %>
  <% logo_url_html = Rack::Utils.escape_html(logo_url) %>
  <% logo_alt_html = Rack::Utils.escape_html("#{meta_name} logo") %>
  <title><%= meta_title_html %></title>
  <meta name="application-name" content="<%= meta_name_html %>" />
  <meta name="apple-mobile-web-app-title" content="<%= meta_name_html %>" />
  <meta name="description" content="<%= meta_description_html %>" />
  <link rel="canonical" href="<%= canonical_html %>" />
  <meta property="og:title" content="<%= meta_title_html %>" />
  <meta property="og:site_name" content="<%= meta_name_html %>" />
  <meta property="og:description" content="<%= meta_description_html %>" />
  <meta property="og:type" content="website" />
  <meta property="og:url" content="<%= canonical_html %>" />
  <meta property="og:image" content="<%= logo_url_html %>" />
  <meta property="og:image:alt" content="<%= logo_alt_html %>" />
  <meta name="twitter:card" content="summary" />
  <meta name="twitter:title" content="<%= meta_title_html %>" />
  <meta name="twitter:description" content="<%= meta_description_html %>" />
  <meta name="twitter:image" content="<%= logo_url_html %>" />
  <meta name="twitter:image:alt" content="<%= logo_alt_html %>" />
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
  <link rel="icon" type="image/svg+xml" href="/potatomesh-logo.svg" />
  <% refresh_interval_seconds = 60 %>
  <% tile_filter_light = "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)" %>
  <% tile_filter_dark = "grayscale(1) invert(1) brightness(0.9) contrast(1.08)" %>

  <!-- Leaflet CSS/JS (CDN) -->
  <link
    rel="stylesheet"
    href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
    integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
    crossorigin=""
  />
  <script
    src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
    integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
    crossorigin=""
  ></script>

  <style>
    :root {
      --pad: 16px;
      --map-tile-filter-light: <%= tile_filter_light %>;
      --map-tile-filter-dark: <%= tile_filter_dark %>;
    }
    body {
      font-family: system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif;
      margin: var(--pad);
      padding-bottom: 32px;
      --map-tiles-filter: var(--map-tile-filter-light);
    }
    h1  { margin: 0 0 8px }
    .site-title { display: inline-flex; align-items: center; gap: 12px; }
    .site-title img { width: 52px; height: 52px; display: block; border-radius: 12px; }
  .meta { color:#555; margin-bottom:12px }
    .pill{ display:inline-block; padding:2px 8px; border-radius:999px; background:#eee; font-size:12px }
    #map {
      position: relative;
      flex: 1;
      height: 60vh;
      border: 1px solid #ddd;
      border-radius: 8px;
      overflow: hidden;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #map[data-map-status="placeholder"] {
      background: repeating-linear-gradient(135deg, rgba(0,0,0,0.02), rgba(0,0,0,0.02) 12px, rgba(0,0,0,0.04) 12px, rgba(0,0,0,0.04) 24px);
    }
    #map .map-placeholder-message {
      text-align: center;
      color: #555;
      font-size: 14px;
      line-height: 1.5;
      padding: 0 16px;
    }
    #map .map-placeholder-message strong { display: block; margin-bottom: 6px; font-size: 16px; }
    #map .map-placeholder-message span { display: block; margin-top: 4px; }
    body.dark #map .map-placeholder-message { color: #ddd; }
    #map .map-status-message {
      position: absolute;
      top: 12px;
      left: 50%;
      transform: translateX(-50%);
      padding: 6px 12px;
      border-radius: 999px;
      font-size: 12px;
      line-height: 1.2;
      backdrop-filter: blur(6px);
      pointer-events: none;
      border: 1px solid rgba(0,0,0,0.15);
      background: rgba(255,255,255,0.9);
      color: #333;
      box-shadow: 0 4px 16px rgba(0,0,0,0.12);
      z-index: 1200;
    }
    body.dark #map[data-map-status="placeholder"] {
      background: repeating-linear-gradient(135deg, rgba(255,255,255,0.02), rgba(255,255,255,0.02) 12px, rgba(255,255,255,0.06) 12px, rgba(255,255,255,0.06) 24px);
    }
    body.dark #map .map-status-message {
      border-color: rgba(255,255,255,0.16);
      background: rgba(0,0,0,0.65);
      color: #eee;
      box-shadow: 0 4px 16px rgba(0,0,0,0.4);
    }
    table { border-collapse: collapse; width: 100%; margin: 0; }
    th, td { padding: 4px 6px; text-align: left; }
    th { position: sticky; top: 0; background: #fafafa; }
    .mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
    .row { display: flex; gap: var(--pad); align-items: center; justify-content: space-between; }
    .map-row { display: flex; gap: var(--pad); align-items: stretch; }
    #chat { flex: 0 0 33%; max-width: 33%; height: 60vh; border: 1px solid #ddd; border-radius: 8px; overflow-y: auto; padding: 6px; font-size: 12px; }
    .chat-entry-node { font-family: ui-monospace, Menlo, Consolas, monospace; color: #555 }
    .chat-entry-msg { font-family: ui-monospace, Menlo, Consolas, monospace; }
    .chat-entry-date { font-family: ui-monospace, Menlo, Consolas, monospace; font-weight: bold; }
    .short-name { display:inline-block; border-radius:4px; padding:0 2px; }
    .short-name[data-node-info] { cursor: pointer; }
    .short-info-overlay { position: absolute; background: #fff; color: #111; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 8px 10px 10px; font-size: 11px; line-height: 1.4; min-width: 200px; max-width: 240px; z-index: 2000; }
    .short-info-overlay[hidden] { display: none; }
    .short-info-overlay .short-info-close { position: absolute; top: 4px; right: 4px; border: none; background: transparent; font-size: 14px; line-height: 1; padding: 2px; border-radius: 4px; cursor: pointer; color: inherit; }
    .short-info-overlay .short-info-close:hover { background: rgba(0, 0, 0, 0.08); }
    .short-info-content { margin: 0; }
    .meta-info { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
    .refresh-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: start; width: 100%; }
    .refresh-info { margin: 0; color: #555; }
    .refresh-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-self: end; }
    .auto-refresh-toggle { display: inline-flex; align-items: center; gap: 6px; }
    .controls { display: flex; gap: 8px; align-items: center; }
    .controls label { display: inline-flex; align-items: center; gap: 6px; }
    .controls .filter-input { display: inline-flex; align-items: center; flex: 1 1 260px; position: relative; }
    .filter-input input[type="text"] { flex: 1 1 auto; width: 100%; padding-right: 28px; }
    .filter-clear {
      position: absolute;
      right: 6px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 20px;
      height: 20px;
      border: none;
      background: transparent;
      color: var(--muted);
      font-size: 16px;
      line-height: 1;
      border-radius: 50%;
      cursor: pointer;
      padding: 0;
    }
    .filter-clear:hover {
      background: rgba(0, 0, 0, 0.06);
    }
    body.dark .filter-clear:hover {
      background: rgba(255, 255, 255, 0.12);
    }
    .filter-clear:focus-visible {
      outline: 2px solid #4a90e2;
      outline-offset: 2px;
    }
    button { padding: 6px 10px; border: 1px solid #ccc; background: #fff; border-radius: 6px; cursor: pointer; color: var(--fg); }
    button:hover { background: #f6f6f6; }
    .sort-button { padding: 0; border: none; background: none; color: inherit; font: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
    .sort-button:hover { background: none; }
    .sort-button:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; }
    .sort-indicator { font-size: 0.75em; opacity: 0.6; }
    th[aria-sort] .sort-indicator { opacity: 1; }
    label { font-size: 14px; color: #333; }
    input[type="text"] { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
    .legend { position: relative; background: #fff; color: var(--fg); padding: 8px 10px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 12px; line-height: 18px; min-width: 160px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); }
    .legend-header { display: flex; align-items: center; justify-content: flex-start; gap: 4px; margin-bottom: 6px; font-weight: 600; }
    .legend-title { font-size: 13px; }
    .legend-items { display: flex; flex-direction: column; gap: 2px; }
    .legend-item {
      display: flex;
      align-items: center;
      gap: 6px;
      font: inherit;
      color: inherit;
      background: transparent;
      border: 1px solid transparent;
      border-radius: 4px;
      padding: 3px 6px;
      cursor: pointer;
      width: 100%;
      justify-content: flex-start;
      text-align: left;
    }
    .legend-item:hover { background: rgba(0, 0, 0, 0.05); }
    .legend-item:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; }
    .legend-item[aria-pressed="true"] { border-color: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.08); }
    .legend-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 2px; }
    .legend-hidden { display: none !important; }
    .legend-toggle { margin-top: 8px; }
    .legend-toggle-button { font-size: 12px; color: var(--fg); }
    #map .leaflet-tile-pane,
    #map .leaflet-layer,
    #map .leaflet-tile.map-tiles {
      opacity: 0.75;
      filter: var(--map-tiles-filter, var(--map-tile-filter-light));
      -webkit-filter: var(--map-tiles-filter, var(--map-tile-filter-light));
    }
    .leaflet-popup-content-wrapper,
    .leaflet-popup-tip {
      background: #fff;
      color: #333;
      box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
    }
    #nodes { font-size: 12px; }
    footer { position: fixed; bottom: 0; left: var(--pad); width: calc(100% - 2 * var(--pad)); background: #fafafa; border-top: 1px solid #ddd; text-align: center; font-size: 12px; padding: 4px 0; }
    .info-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; padding: var(--pad); z-index: 4000; }
    .info-overlay[hidden] { display: none; }
    .info-dialog { background: #fff; color: #111; max-width: 420px; width: min(100%, 420px); border-radius: 12px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); position: relative; padding: 20px 24px; outline: none; }
    .info-dialog:focus { outline: 2px solid #4a90e2; outline-offset: 4px; }
    .info-close { position: absolute; top: 10px; right: 10px; padding: 4px; border: none; background: transparent; font-size: 20px; line-height: 1; border-radius: 999px; }
    .info-close:hover { background: rgba(0, 0, 0, 0.06); }
    .info-title { margin: 0 0 8px; font-size: 20px; }
    .info-intro { margin: 0 0 12px; font-size: 14px; color: #444; }
    .info-details { margin: 0; font-size: 14px; line-height: 1.6; }
    .info-details dt { font-weight: 600; margin-top: 12px; color: #222; }
    .info-details dd { margin: 4px 0 0; }
    .info-details dd a { color: inherit; word-break: break-word; }
    @media (max-width: 1280px) {
      #nodes th:nth-child(15),
      #nodes td:nth-child(15),
      #nodes th:nth-child(16),
      #nodes td:nth-child(16),
      #nodes th:nth-child(17),
      #nodes td:nth-child(17),
      #nodes th:nth-child(18),
      #nodes td:nth-child(18) {
        display: none;
      }
    }

    @media (max-width: 1024px) {
      .row { flex-direction: column; align-items: stretch; gap: var(--pad); }
      .site-title img { width: 44px; height: 44px; }
      .map-row { flex-direction: column; }
      .controls { order: 2; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; width: 100%; gap: 12px; }
      .controls .filter-input { width: 100%; }
      .controls button { justify-self: end; }
      .meta-info { order: 1; width: 100%; }
      .refresh-row { grid-template-columns: 1fr; row-gap: 8px; }
      .refresh-actions { flex-direction: row; align-items: center; gap: 8px; justify-self: start; flex-wrap: nowrap; }
      #map { order: 1; flex: none; max-width: 100%; height: 50vh; }
      #chat { order: 2; flex: none; max-width: 100%; height: 30vh; }
      #nodes th:nth-child(1),
      #nodes td:nth-child(1),
      #nodes th:nth-child(5),
      #nodes td:nth-child(5),
      #nodes th:nth-child(6),
      #nodes td:nth-child(6),
      #nodes th:nth-child(9),
      #nodes td:nth-child(9),
      #nodes th:nth-child(11),
      #nodes td:nth-child(11),
      #nodes th:nth-child(13),
      #nodes td:nth-child(13),
      #nodes th:nth-child(14),
      #nodes td:nth-child(14),
      #nodes th:nth-child(15),
      #nodes td:nth-child(15),
      #nodes th:nth-child(16),
      #nodes td:nth-child(16),
      #nodes th:nth-child(17),
      #nodes td:nth-child(17),
      #nodes th:nth-child(18),
      #nodes td:nth-child(18) {
        display: none;
      }
      .legend { max-width: min(240px, 80vw); }
    }

    /* Dark mode overrides */
    body.dark {
      background: #111;
      color: #eee;
      --map-tiles-filter: var(--map-tile-filter-dark);
    }
    body.dark .meta { color: #bbb; }
    body.dark .refresh-info { color: #bbb; }
    body.dark .pill { background: #444; }
    body.dark #map { border-color: #444; }
    body.dark #chat { border-color: #444; background: #222; color: #eee; }
    body.dark th { background: #222; }
    body.dark button { background: #333; border-color: #444; color: #eee; }
    body.dark button:hover { background: #444; }
    body.dark .sort-button { background: none; border: none; color: inherit; }
    body.dark .sort-button:hover { background: none; }
    body.dark label { color: #ddd; }
    body.dark input[type="text"] { background: #222; color: #eee; border-color: #444; }
    body.dark .legend { background: #333; border-color: #444; color: #eee; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); }
    body.dark .legend-toggle-button { background: #333; border-color: #444; color: #eee; }
    body.dark .legend-toggle-button:hover { background: #444; }
    body.dark .legend-item:hover { background: rgba(255, 255, 255, 0.1); }
    body.dark .legend-item[aria-pressed="true"] { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.16); }
    body.dark .leaflet-popup-content-wrapper,
    body.dark .leaflet-popup-tip {
      background: #333;
      color: #eee;
      box-shadow: 0 3px 14px rgba(0, 0, 0, 0.8);
    }
    body.dark footer { background: #222; border-top-color: #444; color: #eee; }
    body.dark a { color: #9bd; }
    body.dark .chat-entry-node { color: #777 }
    body.dark .chat-entry-msg { color: #bbb }
    body.dark .short-name { color: #555 }
    body.dark .chat-entry-date { color: #bbb }
    body.dark .info-overlay { background: rgba(0, 0, 0, 0.7); }
    body.dark .info-dialog { background: #1c1c1c; color: #eee; border: 1px solid #444; }
    body.dark .info-intro { color: #bbb; }
    body.dark .info-details dt { color: #ddd; }
    body.dark .info-close:hover { background: rgba(255, 255, 255, 0.1); }
    body.dark .short-info-overlay { background: #1c1c1c; border-color: #444; color: #eee; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55); }
    body.dark .short-info-overlay .short-info-close:hover { background: rgba(255, 255, 255, 0.1); }
  </style>
  <style id="map-tiles-light">
    body:not(.dark) {
      --map-tiles-filter: <%= tile_filter_light %>;
    }
    body:not(.dark) #map .leaflet-tile-pane,
    body:not(.dark) #map .leaflet-layer,
    body:not(.dark) #map .leaflet-tile.map-tiles {
      filter: <%= tile_filter_light %>;
      -webkit-filter: <%= tile_filter_light %>;
    }
  </style>
  <style id="map-tiles-dark">
    body.dark {
      --map-tiles-filter: <%= tile_filter_dark %>;
    }
    body.dark #map .leaflet-tile-pane,
    body.dark #map .leaflet-layer,
    body.dark #map .leaflet-tile.map-tiles {
      filter: <%= tile_filter_dark %>;
      -webkit-filter: <%= tile_filter_dark %>;
    }
  </style>
  <script>
    (function(){
      function xmur3(str){for(var i=0,h=1779033703^str.length;i<str.length;i++)h=Math.imul(h^str.charCodeAt(i),3432918353),h=h<<13|h>>>19;return function(){h=Math.imul(h^h>>>16,2246822507);h=Math.imul(h^h>>>13,3266489909);return (h^h>>>16)>>>0;};}
      function mulberry32(a){return function(){var t=a+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296;}}
      function genBackground(theme){
        var seedInput = location.hostname + '::' + theme;
        var seed = xmur3(seedInput)();
        var rnd = mulberry32(seed);
        var w = 1400, h = 900;
        var c = document.createElement('canvas'); c.width=w; c.height=h;
        var ctx = c.getContext('2d');
        if(theme==='dark'){
          var g = ctx.createLinearGradient(0,0,w,h);
          g.addColorStop(0, '#0b1119');
          g.addColorStop(1, '#121b27');
          ctx.fillStyle = g; ctx.fillRect(0,0,w,h);
        } else {
          var g2 = ctx.createLinearGradient(0,0,w,h);
          g2.addColorStop(0, '#efe8d9'); g2.addColorStop(1, '#dfe5ec');
          ctx.fillStyle = g2; ctx.fillRect(0,0,w,h);
        }
        ctx.globalAlpha = (theme==='dark') ? 0.05 : 0.06;
        for(var i=0;i<14000;i++){
          var x = Math.floor(rnd()*w), y = Math.floor(rnd()*h);
          var s = Math.floor(rnd()*2)+1;
          ctx.fillStyle = (theme==='dark') ? '#ffffff' : '#000000';
          ctx.fillRect(x,y,s,s);
        }
        var rad = ctx.createRadialGradient(w*0.5,h*0.5,Math.min(w,h)*0.2, w*0.5,h*0.5,Math.max(w,h)*0.7);
        if(theme==='dark'){
          rad.addColorStop(0,'rgba(0,0,0,0)');
          rad.addColorStop(1,'rgba(0,0,0,0.20)');
        } else {
          rad.addColorStop(0,'rgba(255,255,255,0)');
          rad.addColorStop(1,'rgba(255,255,255,0.22)');
        }
        ctx.globalAlpha = 1; ctx.fillStyle = rad; ctx.fillRect(0,0,w,h);
        var url = c.toDataURL('image/png');
        document.documentElement.style.setProperty('--bg-image', 'url('+url+')');
      }
      function currentTheme(){
        return document.body.classList.contains('dark') ? 'dark' : 'light';
      }
      document.addEventListener('DOMContentLoaded', function(){
        genBackground(currentTheme());
      });
      window.addEventListener('themechange', function(e){
        var theme = e.detail && e.detail.theme || currentTheme();
        genBackground(theme);
      });
      var obs = new MutationObserver(function(){ genBackground(currentTheme());});
      obs.observe(document.documentElement, { attributes:true, attributeFilter:['class'] });
      window.__regenBackground = genBackground;
    })();
  </script>
  <style>
    /* Make common wrappers transparent so the generated background is visible */
    #app, main, .container, .content, .wrapper, .layout, .page, .root, body > div:first-child {
      background: transparent !important;
    }
    /* Soften dark cards a bit to avoid heavy overlay */
    body.dark .card, body.dark .panel, body.dark .box {
      background: rgba(255,255,255,0.02);
      border-color: rgba(255,255,255,0.07);
    }
    body.dark thead th {
      background: rgba(255,255,255,0.04);
    }
  </style>
  <style>
    /* Dark theme: avoid any solid blocks that hide the background */
    body.dark :is(#app, main, .container, .content, .wrapper, .page, .layout, .root, .section) {
      background: rgba(255,255,255,0.04) !important;
    }
    /* Dark theme tables & boxes */
    body.dark :is(.card, .panel, .box) {
      background: var(--card) !important;
      border-color: var(--line) !important;
    }
    /* Defensive: Leaflet map stays transparent */
    body.dark .leaflet-container { background: transparent !important; }
  </style>
</head>
<body>
  <h1 class="site-title">
    <img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
    <span class="site-title-text"><%= site_name %></span>
  </h1>
  <div class="row meta">
    <div class="meta-info">
      <div class="refresh-row">
        <p id="refreshInfo" class="refresh-info" aria-live="polite"><%= default_channel %> (<%= default_frequency %>) — active nodes: …</p>
        <div class="refresh-actions">
          <label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
          <button id="refreshBtn" type="button">Refresh now</button>
          <span id="status" class="pill">loading…</span>
        </div>
      </div>
    </div>
    <div class="controls">
      <label><input type="checkbox" id="fitBounds" checked /> Auto-fit map</label>
      <div class="filter-input">
        <input type="text" id="filterInput" placeholder="Filter nodes" />
        <button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
      </div>
      <button id="themeToggle" type="button" aria-label="Toggle dark mode">🌙</button>
      <button id="infoBtn" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information">ℹ️ Info</button>
    </div>
  </div>

  <div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
    <div class="info-dialog" tabindex="-1">
      <button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
      <h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
      <p class="info-intro">Quick facts about this PotatoMesh instance.</p>
      <dl class="info-details">
        <dt>Default channel</dt>
        <dd><%= default_channel %></dd>
        <dt>Frequency</dt>
        <dd><%= default_frequency %></dd>
        <dt>Map center</dt>
        <dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
        <dt>Visible range</dt>
        <dd>Nodes within roughly <%= max_node_distance_km %> km of the center are shown.</dd>
        <dt>Auto-refresh</dt>
        <dd>Updates every <%= refresh_interval_seconds %> seconds.</dd>
        <% if matrix_room && !matrix_room.empty? %>
          <dt>Matrix room</dt>
          <dd><a href="https://matrix.to/#/<%= matrix_room %>" target="_blank" rel="noreferrer noopener"><%= matrix_room %></a></dd>
        <% end %>
      </dl>
    </div>
  </div>

  <div class="map-row">
    <% unless private_mode %>
      <div id="chat" aria-label="Chat log"></div>
    <% end %>
    <div id="map" role="region" aria-label="Nodes map"></div>
  </div>

  <table id="nodes">
    <thead>
      <tr>
        <th><button type="button" class="sort-button" data-sort-key="node_id" data-sort-label="Node ID">Node ID <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="short_name" data-sort-label="Short Name">Short <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="long_name" data-sort-label="Long Name">Long Name <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="last_heard" data-sort-label="Last Seen">Last Seen <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="role" data-sort-label="Role">Role <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="hw_model" data-sort-label="Hardware Model">HW Model <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="battery_level" data-sort-label="Battery Level">Battery <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="voltage" data-sort-label="Voltage">Voltage <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="uptime_seconds" data-sort-label="Uptime">Uptime <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="channel_utilization" data-sort-label="Channel Utilization">Channel Util <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="air_util_tx" data-sort-label="Air Utilization (Tx)">Air Util Tx <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="temperature" data-sort-label="Temperature">Temperature <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="relative_humidity" data-sort-label="Humidity">Humidity <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="barometric_pressure" data-sort-label="Barometric Pressure">Pressure <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="altitude" data-sort-label="Altitude">Altitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
        <th><button type="button" class="sort-button" data-sort-key="position_time" data-sort-label="Last Position">Last Position <span class="sort-indicator" aria-hidden="true"></span></button></th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>

  <div id="shortInfoOverlay" class="short-info-overlay" role="dialog" hidden>
    <button type="button" class="short-info-close" aria-label="Close node details">×</button>
    <div class="short-info-content"></div>
  </div>

  <footer>
    PotatoMesh
    <% if version && !version.empty? %>
      <span class="mono"><%= version %></span> —
    <% end %>
    GitHub: <a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
    <% if matrix_room && !matrix_room.empty? %>
      — <%= site_name %> Matrix:
      <a href="https://matrix.to/#/<%= matrix_room %>" target="_blank"><%= matrix_room %></a>
    <% end %>
  </footer>

  <script>
    const statusEl = document.getElementById('status');
    const fitBoundsEl = document.getElementById('fitBounds');
    const autoRefreshEl = document.getElementById('autoRefresh');
    const refreshBtn = document.getElementById('refreshBtn');
    const filterInput = document.getElementById('filterInput');
    const filterClearButton = document.getElementById('filterClear');
    const themeToggle = document.getElementById('themeToggle');
    const infoBtn = document.getElementById('infoBtn');
    const infoOverlay = document.getElementById('infoOverlay');
    const infoClose = document.getElementById('infoClose');
    const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
    const shortInfoOverlay = document.getElementById('shortInfoOverlay');
    const shortInfoClose = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-close') : null;
    const shortInfoContent = shortInfoOverlay ? shortInfoOverlay.querySelector('.short-info-content') : null;
    const titleEl = document.querySelector('title');
    const headerEl = document.querySelector('h1');
    const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
    const chatEl = document.getElementById('chat');
    const refreshInfo = document.getElementById('refreshInfo');
    const baseTitle = document.title;
    const nodesTable = document.getElementById('nodes');
    const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
    const tableSorters = {
      node_id: { getValue: n => n.node_id, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
      short_name: { getValue: n => n.short_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
      long_name: { getValue: n => n.long_name, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
      last_heard: { getValue: n => n.last_heard, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      role: { getValue: n => n.role, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
      hw_model: { getValue: n => n.hw_model, compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
      battery_level: { getValue: n => n.battery_level, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      voltage: { getValue: n => n.voltage, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      uptime_seconds: { getValue: n => n.uptime_seconds, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      channel_utilization: { getValue: n => n.channel_utilization, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      air_util_tx: { getValue: n => n.air_util_tx, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      temperature: { getValue: n => n.temperature, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      relative_humidity: { getValue: n => n.relative_humidity, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      barometric_pressure: { getValue: n => n.barometric_pressure, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      latitude: { getValue: n => n.latitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
      longitude: { getValue: n => n.longitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
      altitude: { getValue: n => n.altitude, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' },
      position_time: { getValue: n => n.position_time, compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'desc' }
    };
    let sortState = {
      key: 'last_heard',
      direction: tableSorters.last_heard ? tableSorters.last_heard.defaultDirection : 'desc'
    };
    let allNodes = [];
    let allNeighbors = [];
    let nodesById = new Map();
    let shortInfoAnchor = null;
    let lastChatDate;
    const NODE_LIMIT = 1000;
    const CHAT_LIMIT = 1000;
    const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
    const REFRESH_MS = <%= refresh_interval_seconds * 1000 %>;
    const CHAT_ENABLED = <%= private_mode ? "false" : "true" %>;
    refreshInfo.textContent = `<%= default_channel %> (<%= default_frequency %>) — active nodes: …`;

    let refreshTimer = null;

    function hasStringValue(value) {
      if (value == null) return false;
      return String(value).trim().length > 0;
    }

    function hasNumberValue(value) {
      if (value == null || value === '') return false;
      const num = typeof value === 'number' ? value : Number(value);
      return Number.isFinite(num);
    }

    function compareString(a, b) {
      const strA = (a == null ? '' : String(a)).trim();
      const strB = (b == null ? '' : String(b)).trim();
      const hasA = strA.length > 0;
      const hasB = strB.length > 0;
      if (!hasA && !hasB) return 0;
      if (!hasA) return 1;
      if (!hasB) return -1;
      return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' });
    }

    function compareNumber(a, b) {
      const numA = typeof a === 'number' ? a : Number(a);
      const numB = typeof b === 'number' ? b : Number(b);
      const validA = Number.isFinite(numA);
      const validB = Number.isFinite(numB);
      if (validA && validB) {
        if (numA === numB) return 0;
        return numA < numB ? -1 : 1;
      }
      if (validA) return -1;
      if (validB) return 1;
      return 0;
    }

    function sortNodes(nodes) {
      if (!Array.isArray(nodes)) return [];
      const config = tableSorters[sortState.key];
      if (!config) return nodes.slice();
      const dir = sortState.direction === 'asc' ? 1 : -1;
      const getter = config.getValue;
      const hasValue = config.hasValue;
      const compare = config.compare;
      const arr = nodes.slice();
      arr.sort((a, b) => {
        const valueA = getter(a);
        const valueB = getter(b);
        const presentA = hasValue ? hasValue(valueA) : valueA != null && valueA !== '';
        const presentB = hasValue ? hasValue(valueB) : valueB != null && valueB !== '';
        if (!presentA && !presentB) return 0;
        if (!presentA) return 1;
        if (!presentB) return -1;
        const result = compare(valueA, valueB);
        return result * dir;
      });
      return arr;
    }

    function updateSortIndicators() {
      if (!nodesTable || !sortButtons.length) return;
      nodesTable.querySelectorAll('thead th').forEach(th => th.removeAttribute('aria-sort'));
      sortButtons.forEach(button => {
        const indicator = button.querySelector('.sort-indicator');
        if (indicator) indicator.textContent = '';
        button.removeAttribute('data-sort-active');
        button.setAttribute('aria-pressed', 'false');
        const label = button.dataset.sortLabel || button.textContent.trim();
        button.setAttribute('aria-label', `Sort by ${label}`);
      });
      const activeButton = sortButtons.find(button => button.dataset.sortKey === sortState.key);
      if (!activeButton) return;
      const indicator = activeButton.querySelector('.sort-indicator');
      if (indicator) indicator.textContent = sortState.direction === 'asc' ? '▲' : '▼';
      const th = activeButton.closest('th');
      if (th) {
        th.setAttribute('aria-sort', sortState.direction === 'asc' ? 'ascending' : 'descending');
      }
      activeButton.setAttribute('data-sort-active', 'true');
      activeButton.setAttribute('aria-pressed', 'true');
      const label = activeButton.dataset.sortLabel || activeButton.textContent.trim();
      const directionLabel = sortState.direction === 'asc' ? 'ascending' : 'descending';
      const nextDirection = sortState.direction === 'asc' ? 'descending' : 'ascending';
      activeButton.setAttribute('aria-label', `${label}, sorted ${directionLabel}. Activate to sort ${nextDirection}.`);
    }

    if (sortButtons.length) {
      sortButtons.forEach(button => {
        button.addEventListener('click', () => {
          const key = button.dataset.sortKey;
          if (!key) return;
          if (sortState.key === key) {
            sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
          } else {
            const config = tableSorters[key];
            const dir = config && config.defaultDirection ? config.defaultDirection : 'asc';
            sortState = { key, direction: dir };
          }
          applyFilter();
        });
      });
    }

    updateSortIndicators();

    function restartAutoRefresh() {
      if (refreshTimer) {
        clearInterval(refreshTimer);
        refreshTimer = null;
      }
      if (autoRefreshEl && autoRefreshEl.checked) {
        refreshTimer = setInterval(refresh, REFRESH_MS);
      }
    }

    const MAP_CENTER_COORDS = Object.freeze({ lat: <%= map_center_lat %>, lon: <%= map_center_lon %> });
    const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
    const mapContainer = document.getElementById('map');
    let mapStatusEl = null;
    let map = null;
    let mapCenterLatLng = null;
    let tiles = null;
    let offlineTiles = null;
    let usingOfflineTiles = false;
    const MAX_NODE_DISTANCE_KM = <%= max_node_distance_km %>;
    let neighborLinesLayer = null;
    let neighborLinesVisible = true;
    let neighborLinesToggleButton = null;
    let markersLayer = null;
    let tileDomObserver = null;

    // Firmware 2.7.10 / Android 2.7.0 roles and colors (see issue #177)
    const roleColors = Object.freeze({
      CLIENT_HIDDEN: '#A9CBE8',
      SENSOR: '#A8D5BA',
      TRACKER: '#B9DFAC',
      CLIENT_MUTE: '#CDE7A9',
      CLIENT: '#E8E6A1',
      CLIENT_BASE: '#F6D0A6',
      REPEATER: '#F7B7A3',
      ROUTER_LATE: '#F29AA3',
      ROUTER: '#E88B94',
      LOST_AND_FOUND: '#C3A8E8'
    });

    const roleRenderOrder = Object.freeze({
      CLIENT_HIDDEN: 1,
      SENSOR: 2,
      TRACKER: 3,
      CLIENT_MUTE: 4,
      CLIENT: 5,
      CLIENT_BASE: 6,
      REPEATER: 7,
      ROUTER_LATE: 8,
      ROUTER: 9,
      LOST_AND_FOUND: 10
    });

    const activeRoleFilters = new Set();
    const legendRoleButtons = new Map();

    function ensureMapStatusElement() {
      if (!mapContainer) return null;
      if (mapStatusEl && mapStatusEl.parentElement === mapContainer) {
        return mapStatusEl;
      }
      mapStatusEl = document.createElement('div');
      mapStatusEl.className = 'map-status-message';
      mapStatusEl.hidden = true;
      mapContainer.appendChild(mapStatusEl);
      return mapStatusEl;
    }

    function showMapStatus(message) {
      if (!mapContainer) return;
      const el = ensureMapStatusElement();
      if (!el) return;
      if (message) {
        el.textContent = message;
        el.hidden = false;
      } else {
        el.hidden = true;
      }
    }

    function hideMapStatus() {
      if (mapStatusEl) {
        mapStatusEl.hidden = true;
      }
    }

    function setMapPlaceholder(message) {
      if (!mapContainer) return;
      mapContainer.dataset.mapStatus = 'placeholder';
      mapContainer.innerHTML = '';
      const placeholder = document.createElement('div');
      placeholder.className = 'map-placeholder-message';
      placeholder.innerHTML = `<strong>Map unavailable</strong>${message ? `<br/><span>${message}</span>` : ''}`;
      mapContainer.appendChild(placeholder);
    }

    function normalizeRole(role) {
      if (role == null) return 'CLIENT';
      const str = String(role).trim();
      return str.length ? str : 'CLIENT';
    }

    function getRoleKey(role) {
      const normalized = normalizeRole(role);
      if (roleColors[normalized]) return normalized;
      const upper = normalized.toUpperCase();
      if (roleColors[upper]) return upper;
      return normalized;
    }

    function getRoleColor(role) {
      const key = getRoleKey(role);
      return roleColors[key] || roleColors.CLIENT || '#3388ff';
    }

    function getRoleRenderPriority(role) {
      const key = getRoleKey(role);
      const priority = roleRenderOrder[key];
      return typeof priority === 'number' ? priority : 0;
    }

    // --- Map setup ---
    const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
    const TILE_FILTER_LIGHT = '<%= tile_filter_light %>';
    const TILE_FILTER_DARK = '<%= tile_filter_dark %>';

    if (hasLeaflet) {
      mapCenterLatLng = L.latLng(MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon);
    }

    function resolveTileFilter() {
      return document.body.classList.contains('dark') ? TILE_FILTER_DARK : TILE_FILTER_LIGHT;
    }

    function applyFilterToTileElement(tile, filterValue) {
      if (!tile || usingOfflineTiles) return;
      if (tile.classList && !tile.classList.contains('map-tiles')) {
        tile.classList.add('map-tiles');
      }
      const value = filterValue || resolveTileFilter();
      if (tile.style) {
        tile.style.filter = value;
        tile.style.webkitFilter = value;
      }
    }

    function getActiveTileLayerContainer() {
      if (!map) return null;
      const layer = usingOfflineTiles ? offlineTiles : tiles;
      return layer && typeof layer.getContainer === 'function' ? layer.getContainer() : null;
    }

    function applyFilterToTileContainers(filterValue) {
      if (!map) return;
      const value = filterValue || resolveTileFilter();
      const container = getActiveTileLayerContainer();
      if (container && container.style) {
        container.style.filter = value;
        container.style.webkitFilter = value;
      }
      const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
      if (tilePane && tilePane.style) {
        tilePane.style.filter = value;
        tilePane.style.webkitFilter = value;
      }
    }

    function ensureTileHasCurrentFilter(tile) {
      if (!map || usingOfflineTiles) return;
      const filterValue = resolveTileFilter();
      applyFilterToTileElement(tile, filterValue);
    }

    function applyFiltersToAllTiles() {
      if (!map) return;
      const filterValue = resolveTileFilter();
      document.body.style.setProperty('--map-tiles-filter', filterValue);
      if (!usingOfflineTiles) {
        const tileEls = mapContainer ? mapContainer.querySelectorAll('.leaflet-tile') : [];
        tileEls.forEach(tile => applyFilterToTileElement(tile, filterValue));
      }
      applyFilterToTileContainers(filterValue);
    }

    function tileToLon(x, z) {
      return (x / Math.pow(2, z)) * 360 - 180;
    }

    function tileToLat(y, z) {
      const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
      return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
    }

    function createOfflineTileLayer() {
      if (!hasLeaflet) return null;
      const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' });
      offlineLayer.createTile = coords => {
        const size = 256;
        const canvas = document.createElement('canvas');
        canvas.width = size;
        canvas.height = size;
        const ctx = canvas.getContext('2d');
        const gradient = ctx.createLinearGradient(0, 0, size, size);
        gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)');
        gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)');
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, size, size);

        ctx.strokeStyle = 'rgba(255,255,255,0.12)';
        ctx.lineWidth = 1;
        const steps = 4;
        for (let i = 1; i < steps; i++) {
          const pos = (size / steps) * i;
          ctx.beginPath();
          ctx.moveTo(pos, 0);
          ctx.lineTo(pos, size);
          ctx.stroke();
          ctx.beginPath();
          ctx.moveTo(0, pos);
          ctx.lineTo(size, pos);
          ctx.stroke();
        }

        const west = tileToLon(coords.x, coords.z);
        const east = tileToLon(coords.x + 1, coords.z);
        const north = tileToLat(coords.y, coords.z);
        const south = tileToLat(coords.y + 1, coords.z);

        ctx.fillStyle = 'rgba(255,255,255,0.7)';
        ctx.font = '12px system-ui, sans-serif';
        ctx.textBaseline = 'top';
        ctx.fillText(`${west.toFixed(1)}°`, 8, 8);
        ctx.textBaseline = 'bottom';
        ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8);
        ctx.textAlign = 'right';
        ctx.textBaseline = 'top';
        ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8);
        ctx.textBaseline = 'bottom';
        ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8);

        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillStyle = 'rgba(255,255,255,0.35)';
        ctx.font = 'bold 22px system-ui, sans-serif';
        ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2);

        return canvas;
      };
      return offlineLayer;
    }

    function disconnectTileObserver() {
      if (tileDomObserver) {
        tileDomObserver.disconnect();
        tileDomObserver = null;
      }
    }

    function observeTileContainer(layer) {
      if (!map || typeof MutationObserver !== 'function') return;
      const targetLayer = layer || (usingOfflineTiles ? offlineTiles : tiles);
      const container = targetLayer && typeof targetLayer.getContainer === 'function' ? targetLayer.getContainer() : null;
      const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
      const targets = [];
      if (container) targets.push(container);
      if (tilePane && !targets.includes(tilePane)) targets.push(tilePane);
      if (!targets.length) return;
      disconnectTileObserver();
      tileDomObserver = new MutationObserver(mutations => {
        const filterValue = resolveTileFilter();
        mutations.forEach(mutation => {
          mutation.addedNodes.forEach(node => {
            if (!node || node.nodeType !== 1) return;
            if (!usingOfflineTiles && node.classList && node.classList.contains('leaflet-tile')) {
              applyFilterToTileElement(node, filterValue);
            }
            if (typeof node.querySelectorAll === 'function') {
              const nestedTiles = node.querySelectorAll('.leaflet-tile');
              nestedTiles.forEach(tile => applyFilterToTileElement(tile, filterValue));
            }
          });
        });
        applyFilterToTileContainers(filterValue);
      });
      targets.forEach(target => tileDomObserver.observe(target, { childList: true, subtree: true }));
    }

    function activateOfflineTiles(message) {
      if (!hasLeaflet || !map) {
        if (mapContainer) {
          setMapPlaceholder(message);
        }
        return;
      }
      if (usingOfflineTiles) {
        if (message) showMapStatus(message);
        return;
      }
      usingOfflineTiles = true;
      if (tiles && map.hasLayer(tiles)) {
        map.removeLayer(tiles);
      }
      if (!offlineTiles) {
        offlineTiles = createOfflineTileLayer();
      }
      if (offlineTiles) {
        offlineTiles.addTo(map);
        observeTileContainer(offlineTiles);
      }
      if (message) {
        showMapStatus(message);
      }
      applyFiltersToAllTiles();
    }

    if (hasLeaflet && mapContainer) {
      map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
      showMapStatus('Loading map tiles…');
      tiles = L.tileLayer(TILE_LAYER_URL, {
        maxZoom: 19,
        className: 'map-tiles',
        crossOrigin: 'anonymous'
      });

      tiles.on('tileloadstart', event => {
        if (!event || !event.tile) return;
        ensureTileHasCurrentFilter(event.tile);
        applyFilterToTileContainers();
      });

      tiles.on('tileload', event => {
        if (!event || !event.tile) return;
        ensureTileHasCurrentFilter(event.tile);
        applyFilterToTileContainers();
      });

      tiles.on('load', () => {
        usingOfflineTiles = false;
        hideMapStatus();
        applyFiltersToAllTiles();
        observeTileContainer(tiles);
      });

      tiles.on('tileerror', () => {
        activateOfflineTiles('Map tiles unavailable. Showing offline placeholder basemap.');
      });

      tiles.addTo(map);
      observeTileContainer(tiles);
      map.setView(mapCenterLatLng || [MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], 10);
      applyFiltersToAllTiles();

      map.on('moveend', applyFiltersToAllTiles);
      map.on('zoomend', applyFiltersToAllTiles);

      neighborLinesLayer = L.layerGroup().addTo(map);
      markersLayer = L.layerGroup().addTo(map);

      if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
        activateOfflineTiles('Offline mode detected. Using placeholder basemap.');
      }
    } else if (mapContainer) {
      setMapPlaceholder('Leaflet assets are unavailable. Data will continue to refresh without a live map.');
    }

    if (typeof window !== 'undefined') {
      window.applyFiltersToAllTiles = applyFiltersToAllTiles;
    }

    let legendContainer = null;
    let legendToggleButton = null;
    let legendVisible = true;

    function updateLegendToggleState() {
      if (!legendToggleButton) return;
      const hasFilters = activeRoleFilters.size > 0;
      legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
      const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
      const baseText = legendVisible ? 'Hide legend' : 'Show legend';
      const labelSuffix = hasFilters ? ' (role filters active)' : '';
      const textSuffix = ' (filters)';
      legendToggleButton.setAttribute('aria-label', baseLabel + labelSuffix);
      legendToggleButton.textContent = baseText + textSuffix;
      if (hasFilters) {
        legendToggleButton.setAttribute('data-has-active-filters', 'true');
      } else {
        legendToggleButton.removeAttribute('data-has-active-filters');
      }
    }

    function setLegendVisibility(visible) {
      legendVisible = visible;
      if (legendContainer) {
        legendContainer.classList.toggle('legend-hidden', !visible);
        legendContainer.setAttribute('aria-hidden', visible ? 'false' : 'true');
      }
      updateLegendToggleState();
    }

    function updateNeighborLinesToggleState() {
      if (!neighborLinesToggleButton) return;
      const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
      neighborLinesToggleButton.textContent = label;
      neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
      neighborLinesToggleButton.setAttribute('aria-label', label);
    }

    function setNeighborLinesVisibility(visible) {
      neighborLinesVisible = Boolean(visible);
      if (neighborLinesLayer && map) {
        const hasLayer = map.hasLayer(neighborLinesLayer);
        if (neighborLinesVisible && !hasLayer) {
          neighborLinesLayer.addTo(map);
        } else if (!neighborLinesVisible && hasLayer) {
          map.removeLayer(neighborLinesLayer);
        }
      }
      updateNeighborLinesToggleState();
    }

    function updateLegendRoleFiltersUI() {
      const hasFilters = activeRoleFilters.size > 0;
      legendRoleButtons.forEach((button, role) => {
        if (!button) return;
        const isActive = activeRoleFilters.has(role);
        button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
      });
      if (legendContainer) {
        if (hasFilters) {
          legendContainer.setAttribute('data-has-active-filters', 'true');
        } else {
          legendContainer.removeAttribute('data-has-active-filters');
        }
      }
      updateLegendToggleState();
    }

    function toggleRoleFilter(role) {
      if (!role) return;
      if (activeRoleFilters.has(role)) {
        activeRoleFilters.delete(role);
      } else {
        activeRoleFilters.add(role);
      }
      updateLegendRoleFiltersUI();
      applyFilter();
    }

    if (map && hasLeaflet) {
      const legend = L.control({ position: 'bottomright' });
      legend.onAdd = function () {
        const div = L.DomUtil.create('div', 'legend');
        div.id = 'mapLegend';
        div.setAttribute('role', 'region');
        div.setAttribute('aria-label', 'Map legend');
        legendContainer = div;

        const header = L.DomUtil.create('div', 'legend-header', div);
        const title = L.DomUtil.create('span', 'legend-title', header);
        title.textContent = 'Legend';

        const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
      legendRoleButtons.clear();
      for (const [role, color] of Object.entries(roleColors)) {
        if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
        const item = L.DomUtil.create('button', 'legend-item', itemsContainer);
        item.type = 'button';
        item.setAttribute('aria-pressed', 'false');
          item.dataset.role = role;
          const swatch = L.DomUtil.create('span', 'legend-swatch', item);
          swatch.style.background = color;
          swatch.setAttribute('aria-hidden', 'true');
          const label = L.DomUtil.create('span', 'legend-label', item);
          label.textContent = role;
          item.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            const exclusive = event.metaKey || event.ctrlKey;
            if (exclusive) {
              activeRoleFilters.clear();
              activeRoleFilters.add(role);
              updateLegendRoleFiltersUI();
              applyFilter();
            } else {
              toggleRoleFilter(role);
            }
          });
          legendRoleButtons.set(role, item);
        }
        updateLegendRoleFiltersUI();

        const toggle = L.DomUtil.create('div', 'legend-toggle', div);
        neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', toggle);
        neighborLinesToggleButton.type = 'button';
        neighborLinesToggleButton.addEventListener('click', event => {
          event.preventDefault();
          event.stopPropagation();
          setNeighborLinesVisibility(!neighborLinesVisible);
        });
        updateNeighborLinesToggleState();

        const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
        resetButton.type = 'button';
        resetButton.textContent = 'Clear filters';
        resetButton.addEventListener('click', event => {
          event.preventDefault();
          event.stopPropagation();
          activeRoleFilters.clear();
          updateLegendRoleFiltersUI();
          applyFilter();
        });

        L.DomEvent.disableClickPropagation(div);
        L.DomEvent.disableScrollPropagation(div);

        return div;
      };
      legend.addTo(map);
      legendContainer = legend.getContainer();

      legendToggleControl = L.control({ position: 'bottomright' });
      legendToggleControl.onAdd = function () {
        const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
        const button = L.DomUtil.create('button', 'legend-toggle-button', container);
        button.type = 'button';
        button.textContent = 'Hide legend (filters)';
        button.setAttribute('aria-pressed', 'true');
        button.setAttribute('aria-label', 'Hide map legend');
        button.setAttribute('aria-controls', 'mapLegend');
        button.addEventListener('click', event => {
          event.preventDefault();
          event.stopPropagation();
          setLegendVisibility(!legendVisible);
        });
        legendToggleButton = button;
        updateLegendToggleState();
        L.DomEvent.disableClickPropagation(container);
        L.DomEvent.disableScrollPropagation(container);
        return container;
      };
      legendToggleControl.addTo(map);

      const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
      setLegendVisibility(!legendMediaQuery.matches);
      legendMediaQuery.addEventListener('change', event => {
        setLegendVisibility(!event.matches);
      });
    } else if (mapContainer && !hasLeaflet) {
      setLegendVisibility(false);
    }

      themeToggle.addEventListener('click', () => {
        const dark = document.body.classList.toggle('dark');
        const themeValue = dark ? 'dark' : 'light';
        themeToggle.textContent = dark ? '☀️' : '🌙';
        if (window.__themeCookie) {
          if (typeof window.__themeCookie.persistTheme === 'function') {
            window.__themeCookie.persistTheme(themeValue);
          } else if (typeof window.__themeCookie.setCookie === 'function') {
            window.__themeCookie.setCookie('theme', themeValue);
          }
        }
        window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
        if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
      });

    let lastFocusBeforeInfo = null;

    function openInfoOverlay() {
      if (!infoOverlay || !infoDialog) return;
      lastFocusBeforeInfo = document.activeElement;
      infoOverlay.hidden = false;
      document.body.style.setProperty('overflow', 'hidden');
      infoDialog.focus();
    }

    function closeInfoOverlay() {
      if (!infoOverlay || !infoDialog) return;
      infoOverlay.hidden = true;
      document.body.style.removeProperty('overflow');
      const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
      if (target && typeof target.focus === 'function') {
        target.focus();
      }
      lastFocusBeforeInfo = null;
    }

    if (infoBtn && infoOverlay && infoClose) {
      infoBtn.addEventListener('click', openInfoOverlay);
      infoClose.addEventListener('click', closeInfoOverlay);
      infoOverlay.addEventListener('click', event => {
        if (event.target === infoOverlay) {
          closeInfoOverlay();
        }
      });
      document.addEventListener('keydown', event => {
        if (event.key === 'Escape' && !infoOverlay.hidden) {
          closeInfoOverlay();
        }
      });
    }

    if (shortInfoClose) {
      shortInfoClose.addEventListener('click', event => {
        event.preventDefault();
        event.stopPropagation();
        closeShortInfoOverlay();
      });
    }

    document.addEventListener('click', event => {
      const shortTarget = event.target.closest('.short-name');
      if (shortTarget && shortTarget.dataset && shortTarget.dataset.nodeInfo) {
        event.preventDefault();
        event.stopPropagation();
        let info = null;
        try {
          info = JSON.parse(shortTarget.dataset.nodeInfo);
        } catch (err) {
          console.warn('Failed to parse node info payload', err);
        }
        if (!info) return;
        if (!info.shortName && shortTarget.textContent) {
          info.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
        }
        if (!info.role) {
          info.role = 'CLIENT';
        }
        if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === shortTarget) {
          closeShortInfoOverlay();
        } else {
          openShortInfoOverlay(shortTarget, info);
        }
        return;
      }
      if (event.target.closest('.neighbor-connection-line')) {
        return;
      }
      if (shortInfoOverlay && !shortInfoOverlay.hidden && !shortInfoOverlay.contains(event.target)) {
        closeShortInfoOverlay();
      }
    });

    document.addEventListener('keydown', event => {
      if (event.key === 'Escape' && shortInfoOverlay && !shortInfoOverlay.hidden) {
        closeShortInfoOverlay();
      }
    });

    window.addEventListener('resize', () => {
      if (shortInfoOverlay && !shortInfoOverlay.hidden) {
        requestAnimationFrame(positionShortInfoOverlay);
      }
    });

    // --- Helpers ---
    function escapeHtml(str) {
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    }

    function renderShortHtml(short, role, longName, nodeData = null){
      const safeTitle = longName ? escapeHtml(String(longName)) : '';
      const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
      const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
      let infoAttr = '';
      if (nodeData && typeof nodeData === 'object') {
        const info = {
          nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
          shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
          longName: nodeData.long_name ?? longName ?? '',
          role: roleValue,
          hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
          battery: nodeData.battery_level ?? nodeData.battery ?? null,
          voltage: nodeData.voltage ?? null,
          uptime: nodeData.uptime_seconds ?? nodeData.uptime ?? null,
          channel: nodeData.channel_utilization ?? nodeData.channel ?? null,
          airUtil: nodeData.air_util_tx ?? nodeData.airUtil ?? null,
          temperature: nodeData.temperature ?? nodeData.temp ?? null,
          humidity: nodeData.relative_humidity ?? nodeData.relativeHumidity ?? nodeData.humidity ?? null,
          pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
          telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
        };
        infoAttr = ` data-node-info="${escapeHtml(JSON.stringify(info))}"`;
      }
      if (!short) {
        return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
      }
      const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
      const color = getRoleColor(roleValue);
      return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
    }

    function rebuildNodeIndex(nodes) {
      nodesById = new Map();
      if (!Array.isArray(nodes)) return;
      for (const node of nodes) {
        if (!node || typeof node !== 'object') continue;
        const nodeId = typeof node.node_id === 'string'
          ? node.node_id
          : (typeof node.nodeId === 'string' ? node.nodeId : null);
        if (!nodeId) continue;
        nodesById.set(nodeId, node);
      }
    }

    function getNeighborNodesFor(nodeId) {
      if (typeof nodeId !== 'string' || nodeId.length === 0) return [];
      if (!Array.isArray(allNeighbors) || allNeighbors.length === 0) return [];
      const neighborsById = new Map();
      for (const entry of allNeighbors) {
        if (!entry || typeof entry !== 'object') continue;
        const sourceId = typeof entry.node_id === 'string' ? entry.node_id : null;
        if (sourceId !== nodeId) continue;
        const neighborId = typeof entry.neighbor_id === 'string' ? entry.neighbor_id : null;
        if (!neighborId) continue;
        const snrValue = toFiniteNumber(entry.snr);
        const rxTime = resolveTimestampSeconds(entry.rx_time, entry.rxTime);
        let record = neighborsById.get(neighborId);
        if (!record) {
          let neighborNode = nodesById.get(neighborId);
          if (!neighborNode) {
            const placeholder = { node_id: neighborId };
            applyNodeNameFallback(placeholder);
            neighborNode = placeholder;
          }
          record = {
            node: neighborNode,
            neighborId,
            snr: snrValue != null ? snrValue : null,
            rxTime: rxTime != null ? rxTime : null
          };
          neighborsById.set(neighborId, record);
          continue;
        }
        if (snrValue != null && (record.snr == null || snrValue > record.snr)) {
          record.snr = snrValue;
        }
        if (rxTime != null && (record.rxTime == null || rxTime > record.rxTime)) {
          record.rxTime = rxTime;
        }
      }
      return Array.from(neighborsById.values());
    }

    function renderNeighborWithSnrHtml(neighborEntry) {
      if (!neighborEntry || !neighborEntry.node) return '';
      const node = neighborEntry.node;
      const shortHtml = renderShortHtml(
        node && (node.short_name ?? node.shortName),
        node && node.role,
        node && (node.long_name ?? node.longName),
        node
      );
      if (!shortHtml) return '';
      const snrText = shortInfoValueOrDash(formatSnrDisplay(neighborEntry.snr));
      return `${shortHtml}<span class="neighbor-snr">(SNR ${escapeHtml(snrText)})</span>`;
    }

    function formatShortInfoUptime(value) {
      if (value == null || value === '') return '';
      const num = Number(value);
      if (!Number.isFinite(num)) return '';
      return num === 0 ? '0s' : timeHum(num);
    }

    function shortInfoValueOrDash(value) {
      return value != null && value !== '' ? String(value) : '—';
    }

    function appendTelemetryLine(lines, label, rawValue, formatter) {
      if (!Array.isArray(lines)) return;
      if (rawValue == null || rawValue === '') return;
      const formatted = formatter ? formatter(rawValue) : rawValue;
      if (formatted == null || formatted === '') return;
      lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
    }

    function closeShortInfoOverlay() {
      if (!shortInfoOverlay) return;
      shortInfoOverlay.hidden = true;
      shortInfoOverlay.style.visibility = 'visible';
      shortInfoAnchor = null;
    }

    function positionShortInfoOverlay() {
      if (!shortInfoOverlay || shortInfoOverlay.hidden || !shortInfoAnchor) return;
      if (!document.body.contains(shortInfoAnchor)) {
        closeShortInfoOverlay();
        return;
      }
      const rect = shortInfoAnchor.getBoundingClientRect();
      const overlayRect = shortInfoOverlay.getBoundingClientRect();
      const viewportWidth = document.documentElement.clientWidth;
      const viewportHeight = document.documentElement.clientHeight;
      let left = rect.left + window.scrollX;
      let top = rect.top + window.scrollY;
      const maxLeft = window.scrollX + viewportWidth - overlayRect.width - 8;
      const maxTop = window.scrollY + viewportHeight - overlayRect.height - 8;
      left = Math.max(window.scrollX + 8, Math.min(left, maxLeft));
      top = Math.max(window.scrollY + 8, Math.min(top, maxTop));
      shortInfoOverlay.style.left = `${left}px`;
      shortInfoOverlay.style.top = `${top}px`;
      shortInfoOverlay.style.visibility = 'visible';
    }

    function openShortInfoOverlay(target, info) {
      if (!shortInfoOverlay || !shortInfoContent || !info) return;
      const lines = [];
      const longNameValue = shortInfoValueOrDash(info.longName ?? '');
      if (longNameValue !== '—') {
        lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
      }
      const shortParts = [];
      const shortHtml = renderShortHtml(info.shortName, info.role, info.longName);
      if (shortHtml) {
        shortParts.push(shortHtml);
      }
      const nodeIdValue = shortInfoValueOrDash(info.nodeId ?? '');
      if (nodeIdValue !== '—') {
        shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
      }
      if (shortParts.length) {
        lines.push(shortParts.join(' '));
      }
      const roleValue = shortInfoValueOrDash(info.role || 'CLIENT');
      if (roleValue !== '—') {
        lines.push(`Role: ${escapeHtml(roleValue)}`);
      }
      let neighborLineHtml = '';
      const neighborEntries = getNeighborNodesFor(info.nodeId);
      if (neighborEntries.length) {
        const neighborParts = neighborEntries
          .map(renderNeighborWithSnrHtml)
          .filter(html => html && html.length);
        if (neighborParts.length) {
          neighborLineHtml = `Neighbors: ${neighborParts.join(' ')}`;
        }
      }
      const modelValue = fmtHw(info.hwModel);
      if (modelValue) {
        lines.push(`Model: ${escapeHtml(modelValue)}`);
      }
      appendTelemetryLine(lines, 'Battery', info.battery, value => fmtAlt(value, '%'));
      appendTelemetryLine(lines, 'Voltage', info.voltage, value => fmtAlt(value, 'V'));
      appendTelemetryLine(lines, 'Uptime', info.uptime, formatShortInfoUptime);
      appendTelemetryLine(lines, 'Channel Util', info.channel, fmtTx);
      appendTelemetryLine(lines, 'Air Util Tx', info.airUtil, fmtTx);
      appendTelemetryLine(lines, 'Temperature', info.temperature, fmtTemperature);
      appendTelemetryLine(lines, 'Humidity', info.humidity, fmtHumidity);
      appendTelemetryLine(lines, 'Pressure', info.pressure, fmtPressure);
      if (neighborLineHtml) {
        lines.push(neighborLineHtml);
      }
      shortInfoContent.innerHTML = lines.join('<br/>');
      shortInfoAnchor = target;
      shortInfoOverlay.hidden = false;
      shortInfoOverlay.style.visibility = 'hidden';
      requestAnimationFrame(positionShortInfoOverlay);
    }

    function openNeighborOverlay(target, segment) {
      if (!shortInfoOverlay || !shortInfoContent || !segment) return;
      const nodeName = shortInfoValueOrDash(segment.sourceDisplayName || segment.sourceId || '');
      const snrText = shortInfoValueOrDash(formatSnrDisplay(segment.snr));
      const sourceShortHtml = renderShortHtml(
        segment.sourceShortName,
        segment.sourceRole,
        segment.sourceDisplayName
      );
      const targetShortHtml = renderShortHtml(
        segment.targetShortName,
        segment.targetRole,
        segment.targetDisplayName
      );
      const sourceIdText = shortInfoValueOrDash(segment.sourceId || '');
      const neighborFullName = shortInfoValueOrDash(segment.targetDisplayName || segment.targetId || '');
      const lines = [];
      lines.push(`<strong>${escapeHtml(nodeName)}</strong>`);
      lines.push(`${sourceShortHtml} <span class="mono">${escapeHtml(sourceIdText)}</span>`);
      const neighborLine = `${targetShortHtml} [${escapeHtml(neighborFullName)}]`;
      lines.push(neighborLine);
      lines.push(`SNR: ${escapeHtml(snrText)}`);
      shortInfoContent.innerHTML = lines.join('<br/>');
      shortInfoAnchor = target;
      shortInfoOverlay.hidden = false;
      shortInfoOverlay.style.visibility = 'hidden';
      requestAnimationFrame(positionShortInfoOverlay);
    }

    function maybeCreateDateDivider(ts) {
      if (!ts) return null;
      const d = new Date(ts * 1000);
      const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
      if (lastChatDate !== key) {
        lastChatDate = key;
        const midnight = new Date(d);
        midnight.setHours(0, 0, 0, 0);
        const div = document.createElement('div');
        div.className = 'chat-entry-date';
        div.textContent = `-- ${formatDate(midnight)} --`;
        return div;
      }
      return null;
    }

    function createNodeChatEntry(n) {
      const div = document.createElement('div');
      const ts = formatTime(new Date(n.first_heard * 1000));
      div.className = 'chat-entry-node';
      const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
      const longName = escapeHtml(n.long_name || '');
      div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
      return div;
    }

    function createMessageChatEntry(m) {
      const div = document.createElement('div');
      const ts = formatTime(new Date(m.rx_time * 1000));
      const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
      const text = escapeHtml(m.text || '');
      div.className = 'chat-entry-msg';
      div.innerHTML = `[${ts}] ${short} ${text}`;
      return div;
    }

    function renderChatLog(nodes, messages) {
      if (!CHAT_ENABLED || !chatEl) return;
      const entries = [];
      for (const n of nodes || []) {
        entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
      }
      for (const m of messages || []) {
        if (!m || m.encrypted) continue;
        entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
      }
      const nowSeconds = Math.floor(Date.now() / 1000);
      const cutoff = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
      const recentEntries = entries.filter(entry => {
        if (entry == null) return false;
        const rawTs = entry.ts;
        if (rawTs == null) return false;
        const ts = typeof rawTs === 'number' ? rawTs : Number(rawTs);
        if (!Number.isFinite(ts)) return false;
        entry.ts = ts;
        return ts >= cutoff;
      });
      recentEntries.sort((a, b) => {
        if (a.ts !== b.ts) return a.ts - b.ts;
        return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
      });
      const frag = document.createDocumentFragment();
      lastChatDate = null;
      for (const entry of recentEntries) {
        const divider = maybeCreateDateDivider(entry.ts);
        if (divider) frag.appendChild(divider);
        if (entry.type === 'node') {
          frag.appendChild(createNodeChatEntry(entry.item));
        } else {
          frag.appendChild(createMessageChatEntry(entry.item));
        }
      }
      chatEl.replaceChildren(frag);
      while (chatEl.childElementCount > CHAT_LIMIT) {
        chatEl.removeChild(chatEl.firstChild);
      }
      chatEl.scrollTop = chatEl.scrollHeight;
    }

    function pad(n) { return String(n).padStart(2, "0"); }

    function formatTime(d) {
      return pad(d.getHours()) + ":" +
            pad(d.getMinutes()) + ":" +
            pad(d.getSeconds());
    }

    function formatDate(d) {
      return d.getFullYear() + "-" +
            pad(d.getMonth() + 1) + "-" +
            pad(d.getDate());
    }

    function fmtHw(v) {
      return v && v !== "UNSET" ? String(v) : "";
    }

    function fmtCoords(v, d = 5) {
      if (v == null || v === '') return "";
      const n = Number(v);
      return Number.isFinite(n) ? n.toFixed(d) : "";
    }

    function fmtAlt(v, s) {
      return (v == null || v === '') ? "" : `${v}${s}`;
    }

    function fmtTx(v, d = 3) {
      if (v == null || v === '') return "";
      const n = Number(v);
      return Number.isFinite(n) ? `${n.toFixed(d)}%` : "";
    }

    function fmtTemperature(v) {
      if (v == null || v === '') return "";
      const n = Number(v);
      return Number.isFinite(n) ? `${n.toFixed(1)}°C` : "";
    }

    function fmtHumidity(v) {
      if (v == null || v === '') return "";
      const n = Number(v);
      return Number.isFinite(n) ? `${n.toFixed(1)}%` : "";
    }

    function fmtPressure(v) {
      if (v == null || v === '') return "";
      const n = Number(v);
      return Number.isFinite(n) ? `${n.toFixed(1)} hPa` : "";
    }

    function formatSnrDisplay(value) {
      if (value == null || value === '') return '';
      const n = Number(value);
      if (!Number.isFinite(n)) return '';
      return `${n.toFixed(1)} dB`;
    }

    function normalizeNodeNameValue(value) {
      if (value == null) return '';
      const str = String(value).trim();
      return str.length ? str : '';
    }

    function getNodeDisplayNameForOverlay(node) {
      if (!node || typeof node !== 'object') return '';
      return (
        normalizeNodeNameValue(node.long_name ?? node.longName) ||
        normalizeNodeNameValue(node.short_name ?? node.shortName) ||
        (typeof node.node_id === 'string' ? node.node_id : '')
      );
    }

    function applyNodeNameFallback(node) {
      if (!node || typeof node !== 'object') return;
      const short = normalizeNodeNameValue(node.short_name ?? node.shortName);
      const long = normalizeNodeNameValue(node.long_name ?? node.longName);
      if (short || long) return;
      const nodeId = normalizeNodeNameValue(node.node_id ?? node.nodeId);
      if (!nodeId) return;
      const fallbackShort = nodeId.slice(-4);
      const fallbackLong = `Meshtastic ${nodeId}`;
      node.short_name = fallbackShort;
      node.long_name = fallbackLong;
      if ('shortName' in node) node.shortName = fallbackShort;
      if ('longName' in node) node.longName = fallbackLong;
    }

    function timeHum(unixSec) {
      if (!unixSec) return "";
      if (unixSec < 0) return "0s";
      if (unixSec < 60) return `${unixSec}s`;
      if (unixSec < 3600) return `${Math.floor(unixSec/60)}m ${Math.floor((unixSec%60))}s`;
      if (unixSec < 86400) return `${Math.floor(unixSec/3600)}h ${Math.floor((unixSec%3600)/60)}m`;
      return `${Math.floor(unixSec/86400)}d ${Math.floor((unixSec%86400)/3600)}h`;
    }

    function timeAgo(unixSec, nowSec = Date.now()/1000) {
      if (!unixSec) return "";
      const diff = Math.floor(nowSec - Number(unixSec));
      if (diff < 0) return "0s";
      if (diff < 60) return `${diff}s`;
      if (diff < 3600) return `${Math.floor(diff/60)}m ${Math.floor((diff%60))}s`;
      if (diff < 86400) return `${Math.floor(diff/3600)}h ${Math.floor((diff%3600)/60)}m`;
      return `${Math.floor(diff/86400)}d ${Math.floor((diff%86400)/3600)}h`;
    }

    async function fetchNodes(limit = NODE_LIMIT) {
      const r = await fetch(`/api/nodes?limit=${limit}`, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }

    async function fetchMessages(limit = NODE_LIMIT) {
      if (!CHAT_ENABLED) return [];
      const r = await fetch(`/api/messages?limit=${limit}`, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }

    async function fetchNeighbors(limit = NODE_LIMIT) {
      const r = await fetch(`/api/neighbors?limit=${limit}`, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }

    async function fetchTelemetry(limit = NODE_LIMIT) {
      const r = await fetch(`/api/telemetry?limit=${limit}`, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }

    async function fetchPositions(limit = NODE_LIMIT) {
      const r = await fetch(`/api/positions?limit=${limit}`, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }

    function toFiniteNumber(value) {
      if (value == null || value === '') return null;
      const num = typeof value === 'number' ? value : Number(value);
      return Number.isFinite(num) ? num : null;
    }

    function resolveTimestampSeconds(numeric, isoString) {
      const parsedNumeric = toFiniteNumber(numeric);
      if (parsedNumeric != null) return parsedNumeric;
      if (typeof isoString === 'string' && isoString.length) {
        const parsedIso = Date.parse(isoString);
        if (Number.isFinite(parsedIso)) {
          return parsedIso / 1000;
        }
      }
      return null;
    }

    function mergePositionsIntoNodes(nodes, positions) {
      if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return;

      const nodesById = new Map();
      for (const node of nodes) {
        if (!node || typeof node !== 'object') continue;
        const key = typeof node.node_id === 'string' ? node.node_id : null;
        if (key) nodesById.set(key, node);
      }

      if (nodesById.size === 0) return;

      const updated = new Set();
      for (const pos of positions) {
        if (!pos || typeof pos !== 'object') continue;
        const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null;
        if (!nodeId || updated.has(nodeId)) continue;
        const node = nodesById.get(nodeId);
        if (!node) continue;

        const lat = toFiniteNumber(pos.latitude);
        const lon = toFiniteNumber(pos.longitude);
        if (lat == null || lon == null) continue;

        const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso);
        const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso);
        if (currentTimestamp != null) {
          if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) {
            continue;
          }
        }

        updated.add(nodeId);
        node.latitude = lat;
        node.longitude = lon;

        const alt = toFiniteNumber(pos.altitude);
        if (alt != null) node.altitude = alt;

        const posTime = toFiniteNumber(pos.position_time);
        if (posTime != null) {
          node.position_time = posTime;
          node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length
            ? pos.position_time_iso
            : new Date(posTime * 1000).toISOString();
        } else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) {
          node.pos_time_iso = pos.position_time_iso;
        }

        if (pos.location_source != null && pos.location_source !== '') {
          node.location_source = pos.location_source;
        }

        const precision = toFiniteNumber(pos.precision_bits);
        if (precision != null) node.precision_bits = precision;
      }
    }

    function buildTelemetryIndex(entries) {
      const byNodeId = new Map();
      const byNodeNum = new Map();
      if (!Array.isArray(entries)) {
        return { byNodeId, byNodeNum };
      }
      for (const entry of entries) {
        if (!entry || typeof entry !== 'object') continue;
        const nodeId = typeof entry.node_id === 'string' ? entry.node_id : (typeof entry.nodeId === 'string' ? entry.nodeId : null);
        const nodeNumRaw = entry.node_num ?? entry.nodeNum;
        const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
        const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
        const telemetryTime = toFiniteNumber(entry.telemetry_time ?? entry.telemetryTime);
        const timestamp = rxTime != null ? rxTime : telemetryTime != null ? telemetryTime : Number.NEGATIVE_INFINITY;
        if (nodeId) {
          const existing = byNodeId.get(nodeId);
          if (!existing || timestamp > existing.timestamp) {
            byNodeId.set(nodeId, { entry, timestamp });
          }
        }
        if (Number.isFinite(nodeNum)) {
          const existing = byNodeNum.get(nodeNum);
          if (!existing || timestamp > existing.timestamp) {
            byNodeNum.set(nodeNum, { entry, timestamp });
          }
        }
      }
      return { byNodeId, byNodeNum };
    }

    function mergeTelemetryIntoNodes(nodes, telemetryEntries) {
      if (!Array.isArray(nodes) || !nodes.length) return;
      const { byNodeId, byNodeNum } = buildTelemetryIndex(telemetryEntries);
      for (const node of nodes) {
        if (!node || typeof node !== 'object') continue;
        const nodeId = typeof node.node_id === 'string' ? node.node_id : (typeof node.nodeId === 'string' ? node.nodeId : null);
        const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
        const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
        let telemetryEntry = null;
        if (nodeId && byNodeId.has(nodeId)) {
          telemetryEntry = byNodeId.get(nodeId).entry;
        } else if (Number.isFinite(nodeNum) && byNodeNum.has(nodeNum)) {
          telemetryEntry = byNodeNum.get(nodeNum).entry;
        }
        if (!telemetryEntry || typeof telemetryEntry !== 'object') continue;
        const metrics = {
          battery_level: toFiniteNumber(telemetryEntry.battery_level ?? telemetryEntry.batteryLevel),
          voltage: toFiniteNumber(telemetryEntry.voltage),
          uptime_seconds: toFiniteNumber(telemetryEntry.uptime_seconds ?? telemetryEntry.uptimeSeconds),
          channel_utilization: toFiniteNumber(telemetryEntry.channel_utilization ?? telemetryEntry.channelUtilization),
          air_util_tx: toFiniteNumber(telemetryEntry.air_util_tx ?? telemetryEntry.airUtilTx),
          temperature: toFiniteNumber(telemetryEntry.temperature),
          relative_humidity: toFiniteNumber(telemetryEntry.relative_humidity ?? telemetryEntry.relativeHumidity),
          barometric_pressure: toFiniteNumber(telemetryEntry.barometric_pressure ?? telemetryEntry.barometricPressure),
        };
        for (const [key, value] of Object.entries(metrics)) {
          if (value == null) continue;
          node[key] = value;
        }
        const telemetryTime = toFiniteNumber(telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime);
        if (telemetryTime != null) {
          node.telemetry_time = telemetryTime;
        }
        const rxTime = toFiniteNumber(telemetryEntry.rx_time ?? telemetryEntry.rxTime);
        if (rxTime != null) {
          node.telemetry_rx_time = rxTime;
        }
      }
    }

    function toRadians(deg) {
      return (deg * Math.PI) / 180;
    }

    function haversineDistanceKm(lat1, lon1, lat2, lon2) {
      const R = 6371;
      const dLat = toRadians(lat2 - lat1);
      const dLon = toRadians(lon2 - lon1);
      const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
        Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
        Math.sin(dLon / 2) * Math.sin(dLon / 2);
      const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
      return R * c;
    }

    function distanceFromCenterKm(lat, lon) {
      if (hasLeaflet && mapCenterLatLng) {
        try {
          return L.latLng(lat, lon).distanceTo(mapCenterLatLng) / 1000;
        } catch (err) {
          // fall through to haversine fallback
        }
      }
      return haversineDistanceKm(lat, lon, MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon);
    }

    function computeDistances(nodes) {
      for (const n of nodes) {
        const latRaw = n.latitude;
        const lonRaw = n.longitude;
        if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') {
          n.distance_km = null;
          continue;
        }
        const lat = Number(latRaw);
        const lon = Number(lonRaw);
        if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
          n.distance_km = null;
          continue;
        }
        n.distance_km = distanceFromCenterKm(lat, lon);
      }
    }

    function renderTable(nodes, nowSec) {
      const tb = document.querySelector('#nodes tbody');
      const frag = document.createDocumentFragment();
      for (const n of nodes) {
        const tr = document.createElement('tr');
        const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
        const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
        tr.innerHTML = `
          <td class="mono">${n.node_id || ""}</td>
          <td>${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
          <td>${n.long_name || ""}</td>
          <td>${timeAgo(n.last_heard, nowSec)}</td>
          <td>${n.role || "CLIENT"}</td>
          <td>${fmtHw(n.hw_model)}</td>
          <td>${fmtAlt(n.battery_level, "%")}</td>
          <td>${fmtAlt(n.voltage, "V")}</td>
          <td>${timeHum(n.uptime_seconds)}</td>
          <td>${fmtTx(n.channel_utilization)}</td>
          <td>${fmtTx(n.air_util_tx)}</td>
          <td>${fmtTemperature(n.temperature)}</td>
          <td>${fmtHumidity(n.relative_humidity)}</td>
          <td>${fmtPressure(n.barometric_pressure)}</td>
          <td>${fmtCoords(n.latitude)}</td>
          <td>${fmtCoords(n.longitude)}</td>
          <td>${fmtAlt(n.altitude, "m")}</td>
          <td class="mono">${lastPositionCell}</td>`;
        frag.appendChild(tr);
      }
      tb.replaceChildren(frag);
      if (shortInfoOverlay && shortInfoAnchor && !document.body.contains(shortInfoAnchor)) {
        closeShortInfoOverlay();
      }
    }

    function renderMap(nodes, nowSec) {
      if (!map || !markersLayer || !hasLeaflet) {
        return;
      }
      if (neighborLinesLayer) {
        neighborLinesLayer.clearLayers();
      }
      markersLayer.clearLayers();
      const pts = [];
      const nodesById = new Map();
      for (const node of nodes) {
        if (!node || typeof node !== 'object') continue;
        const nodeId = node.node_id;
        if (typeof nodeId !== 'string' || nodeId.length === 0) continue;
        nodesById.set(nodeId, node);
      }

      if (neighborLinesLayer && Array.isArray(allNeighbors) && allNeighbors.length) {
        const neighborSegments = [];
        const seenDirections = new Set();
        for (const entry of allNeighbors) {
          if (!entry || typeof entry !== 'object') continue;
          const sourceId = typeof entry.node_id === 'string' ? entry.node_id : null;
          const targetId = typeof entry.neighbor_id === 'string' ? entry.neighbor_id : null;
          if (!sourceId || !targetId) continue;
          const directionKey = `${sourceId}→${targetId}`;
          if (seenDirections.has(directionKey)) continue;
          seenDirections.add(directionKey);

          const sourceNode = nodesById.get(sourceId);
          const targetNode = nodesById.get(targetId);
          if (!sourceNode || !targetNode) continue;

          const srcLatRaw = sourceNode.latitude;
          const srcLonRaw = sourceNode.longitude;
          const tgtLatRaw = targetNode.latitude;
          const tgtLonRaw = targetNode.longitude;
          if (
            srcLatRaw == null || srcLatRaw === '' || srcLonRaw == null || srcLonRaw === '' ||
            tgtLatRaw == null || tgtLatRaw === '' || tgtLonRaw == null || tgtLonRaw === ''
          ) {
            continue;
          }
          const srcLat = Number(srcLatRaw);
          const srcLon = Number(srcLonRaw);
          const tgtLat = Number(tgtLatRaw);
          const tgtLon = Number(tgtLonRaw);
          if (!Number.isFinite(srcLat) || !Number.isFinite(srcLon) || !Number.isFinite(tgtLat) || !Number.isFinite(tgtLon)) {
            continue;
          }
          if (sourceNode.distance_km != null && sourceNode.distance_km > MAX_NODE_DISTANCE_KM) continue;
          if (targetNode.distance_km != null && targetNode.distance_km > MAX_NODE_DISTANCE_KM) continue;

          const priority = getRoleRenderPriority(sourceNode.role);
          const rxTimeRaw = entry.rx_time;
          let rxTime = 0;
          if (typeof rxTimeRaw === 'number' && Number.isFinite(rxTimeRaw)) {
            rxTime = rxTimeRaw;
          } else if (typeof rxTimeRaw === 'string') {
            const parsed = Number(rxTimeRaw);
            rxTime = Number.isFinite(parsed) ? parsed : 0;
          }

          const snrValue = toFiniteNumber(entry.snr);
          const sourceDisplayName = getNodeDisplayNameForOverlay(sourceNode);
          const targetDisplayName = getNodeDisplayNameForOverlay(targetNode);
          const sourceShortName = normalizeNodeNameValue(sourceNode.short_name ?? sourceNode.shortName);
          const targetShortName = normalizeNodeNameValue(targetNode.short_name ?? targetNode.shortName);

          neighborSegments.push({
            latlngs: [[srcLat, srcLon], [tgtLat, tgtLon]],
            color: getRoleColor(sourceNode.role),
            priority,
            rxTime,
            sourceId,
            targetId,
            snr: snrValue,
            sourceDisplayName,
            targetDisplayName,
            sourceShortName,
            sourceRole: sourceNode.role,
            targetShortName,
            targetRole: targetNode.role
          });
        }

        neighborSegments
          .sort((a, b) => {
            if (a.priority !== b.priority) return a.priority - b.priority;
            if (a.rxTime !== b.rxTime) return b.rxTime - a.rxTime;
            if (a.sourceId !== b.sourceId) return a.sourceId < b.sourceId ? -1 : 1;
            if (a.targetId !== b.targetId) return a.targetId < b.targetId ? -1 : 1;
            return 0;
          })
          .forEach(segment => {
            const polyline = L.polyline(segment.latlngs, {
              color: segment.color,
              weight: 2,
              opacity: 0.42,
              className: 'neighbor-connection-line'
            }).addTo(neighborLinesLayer);
            if (polyline && typeof polyline.on === 'function') {
              polyline.on('click', event => {
                if (event && event.originalEvent) {
                  if (typeof event.originalEvent.preventDefault === 'function') {
                    event.originalEvent.preventDefault();
                  }
                  if (typeof event.originalEvent.stopPropagation === 'function') {
                    event.originalEvent.stopPropagation();
                  }
                }
                const clickTarget =
                  event.originalEvent &&
                  typeof Element !== 'undefined' &&
                  event.originalEvent.target instanceof Element
                    ? event.originalEvent.target
                    : null;
                const anchorEl = polyline.getElement() || clickTarget;
                if (!anchorEl) return;
                if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === anchorEl) {
                  closeShortInfoOverlay();
                  return;
                }
                openNeighborOverlay(anchorEl, segment);
              });
            }
          });
      }

      const nodesByRenderOrder = nodes
        .map((node, index) => ({ node, index }))
        .sort((a, b) => {
          const orderA = getRoleRenderPriority(a.node && a.node.role);
          const orderB = getRoleRenderPriority(b.node && b.node.role);
          if (orderA !== orderB) return orderA - orderB;
          return a.index - b.index;
        })
        .map(entry => entry.node);

      for (const n of nodesByRenderOrder) {
        const latRaw = n.latitude, lonRaw = n.longitude;
        if (latRaw == null || latRaw === '' || lonRaw == null || lonRaw === '') continue;
        const lat = Number(latRaw), lon = Number(lonRaw);
        if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
        if (n.distance_km != null && n.distance_km > MAX_NODE_DISTANCE_KM) continue;

        const color = getRoleColor(n.role);
        const marker = L.circleMarker([lat, lon], {
          radius: 9,
          color: '#000',
          weight: 1,
          fillColor: color,
          fillOpacity: 0.7,
          opacity: 0.7
        });
        const lines = [];
        lines.push(`<b>${n.long_name || ''}</b>`);
        lines.push(`${renderShortHtml(n.short_name, n.role, n.long_name, n)} <span class="mono">${n.node_id || ''}</span>`);
        if (n.hw_model) {
          lines.push(`Model: ${fmtHw(n.hw_model)}`);
        }
        lines.push(`Role: ${n.role || 'CLIENT'}`);
        const batteryParts = [];
        const batteryText = fmtAlt(n.battery_level, "%");
        if (batteryText) batteryParts.push(batteryText);
        const voltageText = fmtAlt(n.voltage, "V");
        if (voltageText) batteryParts.push(voltageText);
        if (batteryParts.length) {
          lines.push(`Battery: ${batteryParts.join(', ')}`);
        }
        const tempText = fmtTemperature(n.temperature);
        if (tempText) {
          lines.push(`Temperature: ${tempText}`);
        }
        const humidityText = fmtHumidity(n.relative_humidity);
        if (humidityText) {
          lines.push(`Humidity: ${humidityText}`);
        }
        const pressureText = fmtPressure(n.barometric_pressure);
        if (pressureText) {
          lines.push(`Pressure: ${pressureText}`);
        }
        if (n.last_heard) {
          lines.push(`Last seen: ${timeAgo(n.last_heard, nowSec)}`);
        }
        if (n.uptime_seconds) {
          lines.push(`Uptime: ${timeHum(n.uptime_seconds)}`);
        }
        const mapNeighborEntries = getNeighborNodesFor(n.node_id ?? n.nodeId ?? '');
        if (mapNeighborEntries.length) {
          const neighborParts = mapNeighborEntries
            .map(renderNeighborWithSnrHtml)
            .filter(html => html && html.length);
          if (neighborParts.length) {
            lines.push(`Neighbors: ${neighborParts.join(' ')}`);
          }
        }
        marker.bindPopup(lines.join('<br/>'));
        marker.addTo(markersLayer);
        pts.push([lat, lon]);
      }
      if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
        const b = L.latLngBounds(pts);
        map.fitBounds(b.pad(0.2), { animate: false });
      }
    }

    function matchesTextFilter(node, query) {
      if (!query) return true;
      return [node?.node_id, node?.short_name, node?.long_name]
        .filter(value => value != null && value !== '')
        .some(value => String(value).toLowerCase().includes(query));
    }

    function matchesRoleFilter(node) {
      if (!activeRoleFilters.size) return true;
      const roleKey = getRoleKey(node && node.role);
      return activeRoleFilters.has(roleKey);
    }

    function updateFilterClearVisibility() {
      if (!filterInput || !filterClearButton) return;
      const hasValue = filterInput.value && filterInput.value.length > 0;
      filterClearButton.hidden = !hasValue;
    }

    function applyFilter() {
      updateFilterClearVisibility();
      const rawQuery = filterInput ? filterInput.value : '';
      const q = rawQuery.trim().toLowerCase();
      const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
      const sortedNodes = sortNodes(filteredNodes);
      const nowSec = Date.now()/1000;
      renderTable(sortedNodes, nowSec);
      renderMap(sortedNodes, nowSec);
      updateCount(sortedNodes, nowSec);
      updateRefreshInfo(sortedNodes, nowSec);
      updateSortIndicators();
    }

    if (filterInput) {
      filterInput.addEventListener('input', () => {
        updateFilterClearVisibility();
        applyFilter();
      });
      updateFilterClearVisibility();
    }

    if (filterClearButton) {
      filterClearButton.addEventListener('click', () => {
        if (!filterInput) return;
        if (filterInput.value.length === 0) {
          filterInput.focus();
          return;
        }
        filterInput.value = '';
        updateFilterClearVisibility();
        applyFilter();
        filterInput.focus();
      });
    }

    async function refresh() {
      try {
        statusEl.textContent = 'refreshing…';
        const neighborPromise = fetchNeighbors().catch(err => {
          console.warn('neighbor refresh failed; continuing without connections', err);
          return [];
        });
        const telemetryPromise = fetchTelemetry().catch(err => {
          console.warn('telemetry refresh failed; continuing without telemetry', err);
          return [];
        });
        const positionsPromise = fetchPositions().catch(err => {
          console.warn('position refresh failed; continuing without updates', err);
          return [];
        });
        const [nodes, positions, neighborTuples, messages, telemetryEntries] = await Promise.all([
          fetchNodes(),
          positionsPromise,
          neighborPromise,
          fetchMessages(),
          telemetryPromise,
        ]);
        nodes.forEach(applyNodeNameFallback);
        mergePositionsIntoNodes(nodes, positions);
        computeDistances(nodes);
        mergeTelemetryIntoNodes(nodes, telemetryEntries);
        if (Array.isArray(messages)) {
          messages.forEach(message => {
            if (message && message.node) applyNodeNameFallback(message.node);
          });
        }
        renderChatLog(nodes, messages);
        allNodes = nodes;
        rebuildNodeIndex(allNodes);
        allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
        applyFilter();
        statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
      } catch (e) {
        statusEl.textContent = 'error: ' + e.message;
        console.error(e);
      }
    }

    refresh();
    restartAutoRefresh();
    refreshBtn.addEventListener('click', refresh);

    if (autoRefreshEl) {
      autoRefreshEl.addEventListener('change', () => {
        restartAutoRefresh();
        if (autoRefreshEl.checked) {
          refresh();
        }
      });
    }

    function updateCount(nodes, nowSec) {
      const dayAgoSec = nowSec - 86400;
      const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
      const text = `${baseTitle} (${count})`;
      titleEl.textContent = text;
      if (headerTitleTextEl) {
        headerTitleTextEl.textContent = text;
      } else if (headerEl) {
        headerEl.textContent = text;
      }
    }

    function updateRefreshInfo(nodes, nowSec) {
      const windows = [
        { label: 'hour', secs: 3600 },
        { label: 'day', secs: 86400 },
        { label: 'week', secs: 7 * 86400 },
      ];
      const counts = windows.map(w => {
        const c = nodes.filter(n => n.last_heard && nowSec - Number(n.last_heard) <= w.secs).length;
        return `${c}/${w.label}`;
      }).join(', ');
      refreshInfo.textContent = `<%= default_channel %> (<%= default_frequency %>) — active nodes: ${counts}.`;
    }
  </script>
</body>
</html>
