<?php
// peta.php
session_start();
require 'routeros_api.class.php';

// pastikan sudah login via connect.php
if (!isset($_SESSION['ip'], $_SESSION['user'], $_SESSION['pass'])) {
    header('Location: index.php');
    exit;
}

// buat nama file JSON per router (bersihkan IP untuk nama file)
$mt_ip = $_SESSION['ip'];
$safe_ip = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $mt_ip);
$dataFile = __DIR__ . "/ap_data_{$safe_ip}.json";
if (!file_exists($dataFile)) file_put_contents($dataFile, json_encode([]));

// helper: format "since" ke bentuk Dec/09/2025 18:00:47 jika memungkinkan
function formatSince($s){
    if (!$s) return '';
    // coba parse dengan strtotime, jika gagal kembalikan string asli
    $ts = @strtotime($s);
    if ($ts === false || $ts === -1) return $s;
    return date('M/d/Y H:i:s', $ts);
}

// --- REPLACE START ---
// ambil data netwatch & parse comment (koordinat, icon, line, warna)
$apList = [];
$netwatch = []; // akan berisi: ip => ['status'=>..., 'since'=>...]
$hotspotActive = [];

// baca file lama (jika ada) supaya bisa reuse id / mapping parent
$oldJson = [];
if (file_exists($dataFile)) {
    $oldJson = json_decode(file_get_contents($dataFile), true) ?: [];
}
$mapIpToId = [];
$mapNameToId = [];
$mapNameToIdLower = [];
foreach ($oldJson as $o) {
    if (!empty($o['ip'])) $mapIpToId[$o['ip']] = $o['id'] ?? null;
    if (!empty($o['name'])) {
        $nm = trim($o['name']);
        $mapNameToId[$nm] = $o['id'] ?? null;
        $mapNameToIdLower[strtolower($nm)] = $o['id'] ?? null;
    }
}

// daftar warna yang diijinkan (kata-kata, bukan hex)
$acceptedColors = ['lime','pink','blue','gray','green','gold','aqua','gainsboro','chartreuse','magenta','orange','fuchsia','black','yellow','brown'];

$API = new RouterosAPI();
$API->debug = false;

// coba connect ke Mikrotik; kalau gagal, fallback pakai oldJson
if ($API->connect($_SESSION['ip'], $_SESSION['user'], $_SESSION['pass'])) {
    $rows = $API->comm('/tool/netwatch/print');

    foreach ($rows as $r) {
        if (!isset($r['host'])) continue;
        $hostIp = $r['host'];
        $status = strtolower($r['status'] ?? 'unknown');
        $since = $r['since'] ?? '';

        $netwatch[$hostIp] = ['status' => $status, 'since' => $since];

        $comment = trim($r['comment'] ?? '');
        if ($comment === '') continue; // skip kalau kosong comment

        // pengguna kadang pakai // sebagai pemisah — normalisasi: ubah `//` jadi `/`
        $normalized = str_replace('//', '/', $comment);
        $parts = explode('/', $normalized);

        // cari bagian yang mengandung koordinat (lat,lng) — cari dengan regex
        $coordsPart = null;
        $coordsIndex = null;
        foreach ($parts as $i => $p) {
            $pTrim = trim($p);
            if ($pTrim === '') continue;
            if (preg_match('/^\s*[-+]?\d+(\.\d+)?\s*,\s*[-+]?\d+(\.\d+)?\s*$/', $pTrim)) {
                $coordsPart = $pTrim;
                $coordsIndex = $i;
                break;
            }
        }
        if ($coordsPart === null) continue; // skip jika tidak terdapat koordinat

        list($latStr, $lngStr) = array_map('trim', explode(',', $coordsPart));
        $lat = (float)$latStr;
        $lng = (float)$lngStr;

        // nama AP: ambil first non-empty part (fallback ke host ip)
        $name = null;
        foreach ($parts as $p) {
            if (trim($p) !== '') { $name = trim($p); break; }
        }
        if (!$name) $name = $hostIp;

        // ------------- ICON DETECTION (lebih toleran) -------------
        $icon = 'wifi';
        // helper checks
        $isCoordToken = function($t){
            return preg_match('/^\s*[-+]?\d+(\.\d+)?\s*,\s*[-+]?\d+(\.\d+)?\s*$/', $t);
        };
        $isIpToken = function($t){
            return filter_var($t, FILTER_VALIDATE_IP) !== false;
        };
        $isApId = function($t){
            return strpos($t, 'ap_') === 0;
        };

        foreach ($parts as $p) {
            $t = trim($p);
            if ($t === '') continue;
            // skip if this token is the name (already used)
            if ($t === $name) continue;
            // skip coords
            if ($isCoordToken($t)) continue;
            $lower = strtolower($t);
            // skip color words
            if (in_array($lower, $acceptedColors)) continue;
            // skip hex color like #rrggbb or #rgb
            if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $t)) continue;
            // skip explicit none markers
            if (in_array($lower, ['none','no','null','-'])) continue;
            // skip ap_id and IP tokens
            if ($isApId($t) || $isIpToken($t)) continue;

            // remaining token likely icon/product
            $icon = $t;
            break;
        }
        // ---------------- END ICON ----------------

        // tentukan possible parent (line): cari token yang cocok dengan ip, nama, atau sudah berbentuk ap_...
        $parentId = null;
        foreach ($parts as $p) {
            $t = trim($p);
            if ($t === '' || $t === $name) continue;
            // jika explicitly 'none' atau 'no' --> artinya tidak ada parent
            $lowerT = strtolower($t);
            if ($lowerT === 'none' || $lowerT === 'no' || $lowerT === 'null' || $lowerT === '-') {
                $parentId = null;
                break;
            }

            // jika token sudah berupa ap_xxx — pakai langsung
            if (strpos($t, 'ap_') === 0) {
                $parentId = $t;
                break;
            }

            // kalau token adalah IP dan kita punya mapping IP ke id
            if (filter_var($t, FILTER_VALIDATE_IP)) {
                if (isset($mapIpToId[$t])) { $parentId = $mapIpToId[$t]; break; }
                // jika IP tidak ada di map, skip
                continue;
            }

            // cek nama exact (case-sensitive) lalu case-insensitive
            if (isset($mapNameToId[$t])) { $parentId = $mapNameToId[$t]; break; }
            if (isset($mapNameToIdLower[$lowerT])) { $parentId = $mapNameToIdLower[$lowerT]; break; }

            // juga cobalah jika token berbentuk 'NamaAP' yang sama dengan nama field tapi beda kapital
            if (strtolower($t) === strtolower($name)) { continue; }
        }

        // cari warna garis: terima kata warna atau hex. default 'lime'
        $lineColor = 'lime';
        foreach ($parts as $p) {
            $pTrim = trim($p);
            if ($pTrim === '') continue;
            $lc = strtolower($pTrim);
            if (in_array($lc, $acceptedColors)) { $lineColor = $lc; break; }
            if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $pTrim)) { $lineColor = $pTrim; break; }
        }

        // reuse id bila ada (berdasarkan ip atau nama), supaya line reference stabil
        $id = $mapIpToId[$hostIp] ?? ($mapNameToId[$name] ?? ($mapNameToIdLower[strtolower($name)] ?? null));
        if (!$id) $id = 'ap_' . uniqid();

        $apList[] = [
            'id'        => $id,
            'name'      => $name,
            'ip'        => $hostIp,
            'lat'       => $lat,
            'lng'       => $lng,
            'line'      => $parentId,
            'lineColor' => $lineColor,
            'icon'      => $icon,
            'status'    => $status,
            'lasttime'  => $since
        ];
    }

    // tambahan: ambil user hotspot aktif (jika ada)
    $hs = $API->comm('/ip/hotspot/active/print');
    if (is_array($hs)) {
        foreach ($hs as $u) {
            // tergantung RouterOS versi field bisa 'user' atau 'name'; tangani keduanya
            $username = $u['user'] ?? $u['name'] ?? '';
            $address = $u['address'] ?? '';
            $uptime = $u['uptime'] ?? '';
            if ($username || $address) {
                $hotspotActive[] = ['user' => $username, 'address' => $address, 'uptime' => $uptime];
            }
        }
    }

    $API->disconnect();
} else {
    // koneksi API gagal — fallback: pakai data lama agar peta tetap tampil
    // Anda bisa mengaktifkan logging di sini untuk debugging
    error_log("RouterOS API connect FAILED for {$_SESSION['ip']} (check credentials / API service).");
    $apList = $oldJson;
    // populate netwatch dari oldJson jika tersedia
    foreach ($apList as $a) {
        if (!empty($a['ip'])) {
            $netwatch[$a['ip']] = [
                'status' => $a['status'] ?? 'unknown',
                'since'  => $a['lasttime'] ?? ''
            ];
        }
    }
}

// simpan hasil yang baru ke file JSON (sinkron)
file_put_contents($dataFile, json_encode($apList, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

// ===== NEW: bangun file JSON untuk garis/line & meter supaya client bisa load cepat =====
$linesFile = __DIR__ . "/ap_lines_{$safe_ip}.json";
$linesData = [];

// helper haversine sederhana (meter)
function haversine($lat1, $lon1, $lat2, $lon2){
    $R = 6378137; // radius bumi WGS84
    $dLat = deg2rad($lat2 - $lat1);
    $dLon = deg2rad($lon2 - $lon1);
    $a = sin($dLat/2) * sin($dLat/2) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon/2) * sin($dLon/2);
    $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
    return $R * $c;
}

// index cepat by id
$apByIdTmp = [];
foreach ($apList as $a) $apByIdTmp[$a['id']] = $a;

// build linesData (straight two-point latlngs + straight distance + midpoint)
// only include lines where parent exists (we skip otherwise)
foreach ($apList as $ap) {
    if (empty($ap['line'])) continue;
    $fromRef = $ap['line'];
    $from = null;
    // try direct id
    if (isset($apByIdTmp[$fromRef])) $from = $apByIdTmp[$fromRef];
    else {
        // try match by ip (if fromRef looks like ip)
        foreach ($apList as $cand) {
            if (!empty($cand['ip']) && $cand['ip'] === $fromRef) { $from = $cand; break; }
        }
    }
    if (!$from) continue;
    $lat1 = (float)$from['lat'];
    $lng1 = (float)$from['lng'];
    $lat2 = (float)$ap['lat'];
    $lng2 = (float)$ap['lng'];
    $straight = haversine($lat1, $lng1, $lat2, $lng2);
    $midLat = ($lat1 + $lat2) / 2.0;
    $midLng = ($lng1 + $lng2) / 2.0;
    $linesData[$ap['id']] = [
        'id' => $ap['id'],
        'fromRef' => $fromRef,
        'toId' => $ap['id'],
        'latlngs' => [ [ $lat1, $lng1 ], [ $lat2, $lng2 ] ],
        'straight' => $straight,
        'mid' => [ $midLat, $midLng ],
        'color' => $ap['lineColor'] ?? 'lime'
    ];
}
// simpan ke file
file_put_contents($linesFile, json_encode($linesData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

// ===== BACA PENGATURAN AUTO-RELOAD (disimpan oleh index.php di settings.json) =====
$settingsFile = __DIR__ . '/settings.json';
$reloadMinutes = 0;
if (file_exists($settingsFile)) {
    $s = json_decode(file_get_contents($settingsFile), true);
    if (isset($s['reload_minutes'])) $reloadMinutes = intval($s['reload_minutes']);
}
// --- REPLACE END ---

?>
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Netwatch AP Monitoring</title>
<link rel="icon" href="favicon.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<style>
  :root{
    --up: #16a34a;
    --down:#ef4444;
    --unknown:#6b7280;
    --glass-bg: rgba(255,255,255,0.85);
  }
  html,body{height:100%;margin:0;font-family:system-ui, -apple-system, "Segoe UI", Roboto, Arial;}
  .wrap{display:flex;height:100vh;overflow:hidden;position:relative;}
#sidebar {
    position: absolute;
    top: 0px;
    right: 0px;
    width: 266px;
    max-height: 80vh;
    background: #3d4242;
    color: #fff;
    border-radius: 2px;
    padding: 10px;
    overflow-y: auto;
    z-index: 1200;
    transition: transform 0.3s ease;
}
#sidebar.hidden {
    transform: translateX(110%);
}

/* Tombol toggle */
#sidebar-toggle {
    position: absolute;
    top: 0px;
    right: 286px;
    width: 26px;
    background: rgba(255,255,255,0.85);
    color: #000;
    text-align:center;
    padding: 4px 6px;
    border-radius: 6px 0 0 6px;
    cursor: pointer;
    z-index: 1300;
    font-size: 28px;
    font-weight: bold;
    user-select: none;
    transition: right 0.3s ease;
}

body.sidebar-hidden #sidebar-toggle {
    right: 0px;
}

#map{ flex:1; height:100%; }
h3{ margin:6px 0 12px 0; font-size:16px; display:flex; justify-content:space-between; align-items:center;}
.ap-item{ background:var(--glass-bg); border:1px solid rgba(0,0,0,0.05); padding:6px; border-radius:8px; margin-bottom:8px; cursor:pointer;}
.ap-item .name{ font-weight:700; display:flex; justify-content:space-between; align-items:center; gap:6px; font-size:12px; }
.ap-item .sub{ font-size:11px; color:#475569; margin-top:2px; }
.ap-actions{ margin-top:6px; display:flex; gap:6px; flex-wrap:wrap; }
.btn{ display:inline-block; padding:6px 8px; border-radius:6px; background:#0ea5e9; color:#fff; text-decoration:none; font-size:15px }
.btn.danger{ background:#ef4444 }
.hint{ font-size:11px; color:#64748b; margin-top:8px; }

#toggleSidebar {
  position:absolute; top:12px; left:12px; z-index:1300;
  background:#fff; color:#000; border-radius:8px; padding:6px 8px; cursor:pointer;
  box-shadow:0 6px 18px rgba(2,6,23,0.2); font-size:12px;
}

.wifi-icon {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px solid #111827;
  /* Kurangi efek berat: hilangkan box-shadow */
  box-shadow: none;
}

.wifi-icon img.wifi-img {
  width: 13px;
  height: 13px;
}

/* Hilangkan animasi kedip pada ikon agar lebih ringan */
.wifi-icon.up { background: lime; }
.wifi-icon.down { background: red; }
.wifi-icon.unknown { background: gray; }

.hotspot-control {
  margin-top:-6px;
  background:#f3eee5;
  color:#000;
  padding:2px 10px;
  border-radius:6px;
  font-weight:700;
  cursor:pointer;
  box-shadow:0 2px 6px rgba(0,0,0,0.2);
}

/* keyframes pulse dihapus karena tidak digunakan lagi */

.leaflet-tooltip.my-label{
  background: rgba(255,255,255,0.95);
  border:1px solid rgba(0,0,0,0.06);
  color:#0f172a; font-weight:600; border-radius:6px; padding:2px 4px;
  font-size:11px;
}
.leaflet-control-attribution {
  display: none !important;
}
.leaflet-top.leaflet-left {
  top: -12px;
  left: -12px;
  font-size: 12px;
}

.line-distance-tooltip {
  background: rgb(255 255 255 / 10%);
  border: 1px solid rgba(0,0,0,0.2);
  color: #fff;
  font-size: 9px;
  font-weight: 600;
  padding: 2px 4px;
  border-radius: 4px;
  pointer-events: none;
}
.separator{margin:10px auto 10px;border:0;}
.separator--line2{border:0;border-bottom:3px solid #fff;-webkit-animation:separator-width 1s ease-out forwards;animation:separator-width 1s ease-out forwards;}
@media (max-width: 768px) {
  #sidebar {
    width: 204px;
    top: 0px;
    max-height: 70vh;
    font-size: 13px;
  }

  #sidebar-toggle {
    top: 0px;
    right: 224px;
    font-size: 28px;
  }
  .leaflet-bottom.leaflet-right {
    margin-bottom: 60px;
  }
  body.sidebar-hidden #sidebar-toggle {
    right: 0px;
  }
}

/* filter badges */
.filter-badge {
  display:inline-block;
  padding:4px 8px;
  border-radius:6px;
  cursor:pointer;
  user-select:none;
  font-weight:700;
}
.filter-badge.active {
  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
  transform: translateY(-1px);
}
.filter-badge.offline { background: #ef4444; color:#fff; }
.filter-badge.online { background: #16a34a; color:#fff; margin-left:6px; }
.filter-badge.all { background: #64748b; color:#fff; margin-left:6px; }

.parent-row { font-weight:700; }
.child-row { font-weight:400; }

 /* kecilkan hotspot-toggle button agar tidak terlalu besar */
#meter-toggle-btn {
  padding: 4px 6px;
  font-size: 12px;
  border-radius: 6px;
  background: #fff;
  color: #000;
  border: 0;
  cursor: pointer;
}
#meter-toggle-btn.on { background: #e6f4ea; }
#meter-toggle-btn.off { background: #fff; opacity: 0.85; }

.color-picker { display:flex; align-items:center; gap:8px; margin-top:4px; }
.color-swatch {
  width:20px;
  height:20px;
  border:2px solid #111827;
  border-radius:3px;
  box-sizing: border-box;
  flex: 0 0 20px;
}
.line-color-select {
  min-width:120px;
  padding:6px 10px;
  border-radius:6px;
  border:2px solid rgba(0,0,0,0.15);
  font-weight:700;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background-repeat: no-repeat;
  background-position: right 8px center;
  background-size: 16px 16px;
  cursor: pointer;
}
.line-color-select::-ms-expand { display: block; }
@media (max-width:420px){
  .line-color-select { min-width:100px; padding:5px 8px; }
}

/* end new styles */
</style>
</head>
<body>
<!-- Tombol toggle -->
<div id="sidebar-toggle">❯</div>
<div class="wrap">
<?php
$countUp = 0; $countDown = 0; $countUnknown = 0;
foreach ($apList as $ap){
    $nw = $netwatch[$ap['ip']] ?? null;
    $st = $nw['status'] ?? ($ap['status'] ?? 'unknown');
    if ($st==='up') $countUp++;
    elseif ($st==='down') $countDown++;
    else $countUnknown++;
}
?>


<!-- Sidebar -->
<aside id="sidebar">
  <div style="margin-top:-6px">
    <a class="btn" href="peta.php">Refresh</a>
    <span style="display:inline-block;width:6px"></span>
    <a class="btn" href="index.php" style="float:right;background:#94a3b8">Logout</a>
  </div>
<hr class="separator separator--line2" />
  <div style="margin:6px 0 12px; font-size:12px;">
    <!-- clickable badges: klik offline/online untuk filter, klik lagi untuk reset -->
    <span id="filter-offline" class="filter-badge offline" data-status="down"> <?= $countDown ?> Off </span>
    <span id="filter-online" class="filter-badge online" data-status="up"> <?= $countUp ?> On </span>
    <span id="filter-all" class="filter-badge all" data-status="all"> All (<?= $countUp+$countDown+$countUnknown ?>) </span>
  </div>

  <div style="max-height:70vh;overflow:auto;border:1px solid #ddd;border-radius:8px;">
<table id="ap-table" style="width:90%;border-collapse:collapse;font-size:smaller;">
  <tbody>
<?php
// Urutkan AP berdasar status (down → unknown → up)
usort($apList, function($a, $b) use ($netwatch) {
    $order = ['down'=>0,'unknown'=>1,'up'=>2];
    $sa = $netwatch[$a['ip']]['status'] ?? ($a['status'] ?? 'unknown');
    $sb = $netwatch[$b['ip']]['status'] ?? ($b['status'] ?? 'unknown');
    return $order[$sa] <=> $order[$sb];
});

// Index cepat berdasar ID
$apById = [];
foreach ($apList as $ap) {
    $apById[$ap['id']] = $ap;
}

// Ambil semua turunan (anak, cucu, dst) TAPI hanya yang status 'down' dan hanya menelusuri jalur yang 'down'
function getDownChildren($id, $apList, $netwatch) {
    $result = [];
    foreach ($apList as $ap) {
        if (!empty($ap['line']) && $ap['line'] === $id) {
            $status = $netwatch[$ap['ip']]['status'] ?? ($ap['status'] ?? 'unknown');
            if ($status === 'down') {
                $result[] = $ap;
                $result = array_merge($result, getDownChildren($ap['id'], $apList, $netwatch));
            }
        }
    }
    return $result;
}

// Cari grup offline
$offlineGroups = [];
foreach ($apList as $ap) {
    $status = $netwatch[$ap['ip']]['status'] ?? ($ap['status'] ?? 'unknown');
    if ($status !== 'down') continue;

    // Jika parent (line) nya juga down, lewati — supaya kita hanya membuat grup dari root top-down saja
    $parentLine = $ap['line'] ?? null;
    if ($parentLine && isset($apById[$parentLine])) {
        $parentAp = $apById[$parentLine];
        $parentStatus = $netwatch[$parentAp['ip']]['status'] ?? ($parentAp['status'] ?? 'unknown');
        if ($parentStatus === 'down') {
            continue; // akan ditangani oleh grup parent teratas
        }
    }

    // Ambil semua turunan (rekursif) tetapi hanya yang status 'down' dan hanya lewat jalur 'down'
    $childrenDown = getDownChildren($ap['id'], $apList, $netwatch);

    // Buat grup hanya kalau ada turunan yang juga down
    if (!empty($childrenDown)) {
        $offlineGroups[$ap['id']] = $childrenDown;
    }
}

// Simpan ID yang sudah tampil di grup
$shownIds = [];

// Tampilkan grup offline
foreach ($offlineGroups as $parentId => $group) {
    $parent = $apById[$parentId];
    $groupId = 'group_' . md5($parentId);

    echo '<tr class="parent-row" data-group="'.$groupId.'" data-root-status="down" style="background:#2d2d2d;color:#ef4444;font-weight:bold;cursor:pointer;">
            <td colspan="3" style="padding:4px 6px;">
                🔴 '.htmlspecialchars($parent['name']).' (+'.count($group).')
                <span class="toggle-icon" style="float:right;">⯈</span>
            </td>
          </tr>';

    foreach ($group as $child) {
        $shownIds[] = $child['id'];
        $sinceRaw = $netwatch[$child['ip']]['since'] ?? ($child['lasttime'] ?? '');
        $since = formatSince($sinceRaw);
        $childStatus = $netwatch[$child['ip']]['status'] ?? ($child['status'] ?? 'unknown');
        echo '<tr class="child-row '.$groupId.'" style="display:none;border-bottom:1px solid #444;cursor:pointer;" data-status="'.htmlspecialchars($childStatus).'" onclick="focusToAp(\''.$child['id'].'\')">
                <td style="width:18px;padding:2px 4px;color:#ef4444;">↳</td>
                <td style="padding:2px 4px;">'.htmlspecialchars($child['name']).'</td>
                <td style="padding:2px 4px;text-align:right;color:#75ddff;">'.htmlspecialchars($since).'</td>
              </tr>';
    }

    $shownIds[] = $parentId;
}

// pastikan uniq (hindari duplikat id)
$shownIds = array_values(array_unique($shownIds));

// Tampilkan AP lain yang belum tampil (termasuk online)
foreach ($apList as $ap):
    if (in_array($ap['id'], $shownIds)) continue;

    $status = $netwatch[$ap['ip']]['status'] ?? ($ap['status'] ?? 'unknown');
    $cls = $status==='up'?'color:#16a34a;':($status==='down'?'color:#ef4444;':'color:#6b7280;');
    $icon = $status==='up'?'🟢':($status==='down'?'🔴':'⚪');
    $sinceRaw = $netwatch[$ap['ip']]['since'] ?? ($ap['lasttime'] ?? '');
    $since = formatSince($sinceRaw);
?>
<tr style="border-bottom:1px solid #eee;cursor:pointer;" data-status="<?= htmlspecialchars($status) ?>" onclick="focusToAp('<?= $ap['id'] ?>')">
  <td style="padding:2px 4px;<?= $cls ?>width:18px;"><?= $icon ?></td>
  <td style="padding:2px 4px;white-space:nowrap;width:18px;"><?= htmlspecialchars($ap['name']) ?></td>
  <td style="padding:2px 4px;text-align:right;white-space:nowrap;color:#75ddff;width:10px;"><?= htmlspecialchars($since) ?></td>
</tr>
<?php endforeach; ?>
  </tbody>
</table>

<script>
// 🧠 Toggle tampil/sembunyi child group
document.querySelectorAll('.parent-row').forEach(row => {
    row.addEventListener('click', () => {
        const groupId = row.dataset.group;
        const children = document.querySelectorAll('.'+groupId);
        const icon = row.querySelector('.toggle-icon');
        const isVisible = children[0]?.style.display !== 'none';

        children.forEach(tr => {
            tr.style.display = isVisible ? 'none' : 'table-row';
        });

        icon.textContent = isVisible ? '⯈' : '⯆';
    });
});
</script>


  </div>

</aside>

<div id="map"></div>
</div>

<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylineDecorator.js"></script>

<script>
const MT_IP = <?= json_encode($mt_ip, JSON_UNESCAPED_SLASHES) ?>;
const AP_LIST = <?= json_encode($apList, JSON_UNESCAPED_UNICODE) ?>;
const NW_STATUS = <?= json_encode($netwatch, JSON_UNESCAPED_UNICODE) ?>;
const HOTSPOT_ACTIVE = <?= json_encode($hotspotActive, JSON_UNESCAPED_UNICODE) ?>;
// lines JSON precomputed (lightweight) — created by server
const LINES_JSON = <?= json_encode($linesData ?? [], JSON_UNESCAPED_UNICODE) ?>;

// accepted color words (server-side must also allow these)
const ACCEPTED_COLORS = ['lime','pink','blue','gray','green','gold','aqua','gainsboro','chartreuse','magenta','orange','fuchsia','black','yellow','brown'];

function getStatus(ip){ return (NW_STATUS[ip] && NW_STATUS[ip].status) ? NW_STATUS[ip].status : 'unknown'; }
function getSince(ip){ return (NW_STATUS[ip] && NW_STATUS[ip].since) ? NW_STATUS[ip].since : ''; }

 // ---- NEW: color utilities for swatch & select text color ----
function colorNameToHex(name){
  if(!name) return name;
  const m = {
    'lime':'#00ff00',
    'pink':'#ff69b4',
    'blue':'#007bff',
    'gray':'#6b7280',
    'green':'#008000',
    'gold':'#ffd700',
    'aqua':'#00ffff',
    'gainsboro':'#dcdcdc',
    'chartreuse':'#7fff00',
    'magenta':'#ff00ff',
    'orange':'#f59e0b',
    'fuchsia':'#ff00ff',
    'black':'#000000',
    'yellow':'#ffff00',
    'brown':'#a0522d'
  };
  const s = String(name).trim().toLowerCase();
  return m[s] || name;
}
function expandHex3(h){
  // '#abc' -> '#aabbcc'
  return h.replace(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, (m,r,g,b) => '#' + r+r + g+g + b+b);
}
function getTextColorFor(bg){
  if(!bg) return '#000';
  let hex = colorNameToHex(bg);
  if(/^#[0-9a-f]{3}$/i.test(hex)) hex = expandHex3(hex);
  if(/^#[0-9a-f]{6}$/i.test(hex)){
    const r = parseInt(hex.substr(1,2),16);
    const g = parseInt(hex.substr(3,2),16);
    const b = parseInt(hex.substr(5,2),16);
    const lum = 0.299*r + 0.587*g + 0.114*b;
    return lum > 186 ? '#000' : '#fff';
  }
  // fallback: if it's a CSS color name not in map, try some defaults
  const darkNames = ['black','navy','purple','maroon','brown','darkblue','darkgreen'];
  if(darkNames.indexOf(String(bg).toLowerCase()) !== -1) return '#fff';
  return '#000';
}
// ---- end new utils ----

// create map
const sat = L.tileLayer(
  'https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
  { maxZoom: 21, subdomains:['mt0','mt1','mt2','mt3'] }
);
const hybrid = L.tileLayer(
  'https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
  { maxZoom: 21, subdomains:['mt0','mt1','mt2','mt3'] }
);
const map = L.map('map', { center:[0,118], zoom:4, layers:[sat], zoomControl:false });
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.control.layers({ "Google Satellite": sat, "Google Hybrid": hybrid }, null, { position:'topleft' }).addTo(map);

// HOTSPOT control (unchanged behavior)
function buildHotspotHtml(){
  const cnt = (HOTSPOT_ACTIVE && HOTSPOT_ACTIVE.length) ? HOTSPOT_ACTIVE.length : 0;
  return `<div<style="display:flex;align-items:center;gap:8px;flex-direction: column;">
    <div class="hotspot-control">Aktif: ${cnt}</div>
    <button id="meter-toggle-btn" class="hotspot-control" style="margin-top:6px;">Meter: On</button>
  </div>`;
}
const hotspotControlDiv = buildHotspotHtml();
const HotspotControl = L.Control.extend({
  options: { position: 'topleft' },
  onAdd: function() {
    const container = L.DomUtil.create('div');
    container.innerHTML = hotspotControlDiv;
    L.DomEvent.disableClickPropagation(container);
    return container;
  }
});
map.addControl(new HotspotControl());

// state containers
const markers={}; const markersByIp={}; const lines={}; const allLatLng=[];
// store tooltips keyed by line id so we can reposition them accurately on curved lines
const lineTooltips = {}; // map id => L.tooltip
const decorators = {};   // decorators[ap.id] => L.marker arrow
const lineColors = {};   // lineColors[ap.id] => color word

// metadata for lines (so we can recompute curves after interactions)
const linesMeta = {}; // linesMeta[id] = { fromRef: ..., toId: ... }

 // quick AP_BY_ID map
const AP_BY_ID = {};
AP_LIST.forEach(a => { AP_BY_ID[a.id] = a; });

function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c])); }
function makeWifiIcon(status){
  const cls = status==='up'?'up':(status==='down'?'down':'unknown');
  return L.divIcon({
    className:'',
    html:`<div class="wifi-icon ${cls}"><img src="icons/Wifi.png" class="wifi-img"></div>`,
    iconSize:[15,15],
    iconAnchor:[10,10],
    popupAnchor:[0,-15]
  });
}

function normalizeColor(c){
  if(!c) return 'lime';
  const s = String(c).trim();
  const lower = s.toLowerCase();
  if (ACCEPTED_COLORS.indexOf(lower) !== -1) return lower;
  if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s;
  return 'lime';
}

function getMarkerForReference(ref){
  if(!ref) return null;
  if(markers[ref]) return markers[ref];
  if(markersByIp[ref]) return markersByIp[ref];
  if(AP_BY_ID[ref] && markers[AP_BY_ID[ref].id]) return markers[AP_BY_ID[ref].id];
  const lower = String(ref).toLowerCase();
  for(const k in AP_BY_ID){
    if(String(AP_BY_ID[k].name).toLowerCase() === lower && markers[AP_BY_ID[k].id]) return markers[AP_BY_ID[k].id];
  }
  return null;
}

// ---------- Performance-minded tweaks (keep arrows and all features) ----------
// Use canvas renderer for polylines to reduce SVG overhead
const canvasRenderer = L.canvas({ padding: 0.5 });

// visibility helper: only create heavy overlays (decorators/tooltips) when visible
function isLatLngInView(latlng){
  try{
    return map.getBounds().pad(0.25).contains(latlng);
  }catch(e){ return true; }
}
function isLineInView(first, last){
  try{
    const bounds = map.getBounds().pad(0.25);
    const box = L.latLngBounds([first, last]);
    return bounds.intersects(box) || bounds.contains(first) || bounds.contains(last);
  }catch(e){ return true; }
}

// debounce helper to avoid repeated heavy work during rapid zoom/pan
function debounce(fn, wait){
  let t;
  return function(...args){
    clearTimeout(t);
    t = setTimeout(()=> fn.apply(this, args), wait);
  };
}

// ---------- polyline & decorator (kept functionality) ----------

function hashSignForId(id){
  let sum = 0;
  for(let i=0;i<id.length;i++) sum += id.charCodeAt(i);
  return (sum % 2 === 0) ? 1 : -1;
}

// ---------- Helper geodesic / polyline utilities (added to improve meter accuracy & midpoint on curved line) ----------
// (same as before)
const EARTH_RADIUS = 6378137; // WGS84 radius in meters (match Google Maps more closely)

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

function haversineDistance(lat1, lon1, lat2, lon2){
  const φ1 = toRad(lat1), φ2 = toRad(lat2);
  const dφ = toRad(lat2 - lat1);
  const dλ = toRad(lon2 - lon1);
  const a = Math.sin(dφ/2)*Math.sin(dφ/2) + Math.cos(φ1)*Math.cos(φ2)*Math.sin(dλ/2)*Math.sin(dλ/2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  return EARTH_RADIUS * c;
}

// flatten getLatLngs result to a simple array of {lat,lng}
function flattenLatLngs(raw){
  if (Array.isArray(raw) && Array.isArray(raw[0]) && raw[0][0] && raw[0][0].lat !== undefined) return raw[0];
  return raw;
}

// compute polyline total length (sum of geodesic distances between consecutive points)
function polylineTotalLength(latlngs){
  if(!latlngs || latlngs.length < 2) return 0;
  let sum = 0;
  for(let i=1;i<latlngs.length;i++){
    sum += haversineDistance(latlngs[i-1].lat, latlngs[i-1].lng, latlngs[i].lat, latlngs[i].lng);
  }
  return sum;
}

// interpolate linearly between two latlngs (sufficient for small segments)
function interpolateLatLng(a, b, t){
  return L.latLng(
    a.lat + (b.lat - a.lat) * t,
    a.lng + (b.lng - a.lng) * t
  );
}

// find the latlng at given distance along polyline (distance in meters)
function pointAtDistanceAlong(latlngs, dist){
  if(!latlngs || latlngs.length === 0) return null;
  if(dist <= 0) return latlngs[0];
  let acc = 0;
  for(let i=1;i<latlngs.length;i++){
    const seg = haversineDistance(latlngs[i-1].lat, latlngs[i-1].lng, latlngs[i].lat, latlngs[i].lng);
    if(acc + seg >= dist){
      const remain = dist - acc;
      const t = seg <= 0 ? 0 : (remain / seg);
      return interpolateLatLng(latlngs[i-1], latlngs[i], t);
    }
    acc += seg;
  }
  // fallback: return last
  return latlngs[latlngs.length-1];
}
// ---------- end helpers ----------

// ---------- Web Mercator helpers (to make curved path independent of map zoom) ----------
function latLngToMercator(lat, lng){
  // EPSG:3857 (spherical mercator)
  const x = EARTH_RADIUS * toRad(lng);
  const y = EARTH_RADIUS * Math.log(Math.tan(Math.PI/4 + toRad(lat)/2));
  return L.point(x, y);
}
function mercatorToLatLng(pt){
  const lng = (pt.x / EARTH_RADIUS) * 180 / Math.PI;
  const lat = (2 * Math.atan(Math.exp(pt.y / EARTH_RADIUS)) - Math.PI/2) * 180 / Math.PI;
  return L.latLng(lat, lng);
}
// ---------- end mercator helpers ----------

/* ========== OPTIMIZED LINE / DECORATOR IMPLEMENTATION ========== */
/* Tujuan: membuat garis/arrow lebih ringan ketika banyak AP tanpa mengubah fungsi lain.
   Strategi:
   - mode "simplified" ketika jumlah AP besar => gambar garis 2 titik.
   - batasi jumlah decorator (arrow) aktif.
   - hanya buat curved/arrows/tooltips saat garis terlihat dan zoom tinggi.
   - kurangi sampling pada curve.
*/

 // performance tuning parameters (sesuaikan bila perlu)
const SIMPLE_LINES_THRESHOLD = 200;   // jika AP >= nilai ini => mode sederhana (garis 2-titik)
const ZOOM_THRESHOLD_FOR_DETAILS = 18; // zoom minimal agar curved/arrows/tooltips dibuat
const maxDecorators = 120;            // batasi jumlah decorator (arrow markers) aktif
const maxTooltips = 200;              // batasi jumlah tooltip jarak aktif

// deteksi mode sederhana berdasarkan jumlah AP
const simplifiedMode = (AP_LIST && AP_LIST.length >= SIMPLE_LINES_THRESHOLD);

// buildBentLatLngs: curve sampling lebih ringan dan cepat
function buildBentLatLngs(fromLatLng, toLatLng, id){
  try {
    // If simplified mode OR very short segment => return straight line with two points
    const straightFallback = [fromLatLng, toLatLng];
    if (simplifiedMode) return straightFallback;

    const p1 = latLngToMercator(fromLatLng.lat, fromLatLng.lng);
    const p2 = latLngToMercator(toLatLng.lat, toLatLng.lng);

    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const segM = Math.sqrt(dx*dx + dy*dy);

    // If segment is very short in meters, keep straight line
    if (segM < 80) return straightFallback;

    const mid = L.point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);

    // perpendicular normalized
    let nx = -dy, ny = dx;
    const norm = Math.sqrt(nx*nx + ny*ny) || 1; nx /= norm; ny /= norm;

    // offset in meters (clamp)
    const offsetM = Math.min(Math.max(8, segM * 0.055), 140);
    const sign = hashSignForId(id);
    const control = L.point(mid.x + nx * offsetM * sign, mid.y + ny * offsetM * sign);

    // sampling count reduced: dynamic but capped low
    const N = Math.min(12, Math.max(4, Math.round(segM / 300))); // fewer points => lighter
    const latlngs = [];
    for (let i = 0; i <= N; i++) {
      const t = i / N;
      const x = (1 - t)*(1 - t)*p1.x + 2*(1 - t)*t*control.x + t*t*p2.x;
      const y = (1 - t)*(1 - t)*p1.y + 2*(1 - t)*t*control.y + t*t*p2.y;
      latlngs.push(mercatorToLatLng(L.point(x, y)));
    }
    return latlngs;
  } catch (err) {
    return [fromLatLng, toLatLng];
  }
}

// createPolylineFor: gunakan garis lurus saat simplifiedMode dan zoom rendah
function createPolylineFor(id, fromLatLng, toLatLng, options){
  const zoom = map.getZoom ? map.getZoom() : ZOOM_THRESHOLD_FOR_DETAILS;
  if (simplifiedMode && zoom < ZOOM_THRESHOLD_FOR_DETAILS) {
    const opts = Object.assign({ renderer: canvasRenderer, color: options.color || '#00ff00', weight: 2, opacity: 0.8, interactive:false }, options||{});
    return L.polyline([fromLatLng, toLatLng], opts);
  }
  const latlngs = buildBentLatLngs(fromLatLng, toLatLng, id);
  const opts = Object.assign({ renderer: canvasRenderer, color: options.color || '#00ff00', weight: 2, opacity: 1, interactive:false, smoothFactor:1 }, options||{});
  return L.polyline(latlngs, opts);
}

// createDecoratorForLine: batasi jumlah decorator & cek zoom/visible
function createDecoratorForLine(id, poly, color){
  if(!poly) return;

  // Avoid creating many decorators: limit by maxDecorators count
  const existingDecoratorsCount = Object.keys(decorators).length;
  if (existingDecoratorsCount >= maxDecorators && !decorators[id]) {
    return;
  }

  // In simplified mode, only create decorator when zoom high enough and line is visible
  const zoom = map.getZoom ? map.getZoom() : 0;
  if (simplifiedMode && zoom < ZOOM_THRESHOLD_FOR_DETAILS) return;

  // Offscreen check
  let latlngs = [];
  try {
    const raw = poly.getLatLngs();
    latlngs = flattenLatLngs(raw);
  } catch (e) { return; }
  if(!latlngs || latlngs.length < 2) return;
  const first = latlngs[0], last = latlngs[latlngs.length-1];
  if (!isLineInView(first, last)) {
    if(decorators[id]) removeDecorator(id);
    return;
  }

  // remove old if exists
  if(decorators[id]) { removeDecorator(id); }

  function bearingDeg(lat1, lon1, lat2, lon2){
    const φ1 = lat1 * Math.PI/180;
    const φ2 = lat2 * Math.PI/180;
    const Δλ = (lon2 - lon1) * Math.PI/180;
    const y = Math.sin(Δλ) * Math.cos(φ2);
    const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
    const θ = Math.atan2(y, x);
    return (θ*180/Math.PI + 360) % 360;
  }

  // compute a point slightly before the end avoiding marker overlap (layer pixels)
  const pts = latlngs.map(ll => map.latLngToLayerPoint(ll));
  if(pts.length < 2) return;

  let markerRadiusPx = 6;
  const mk = markers[id];
  if(mk && mk._icon){
    try{
      const w = mk._icon.offsetWidth || mk._icon.clientWidth || 0;
      const h = mk._icon.offsetHeight || mk._icon.clientHeight || 0;
      markerRadiusPx = Math.max(w, h) / 2;
    }catch(e){}
  }
  const GAP_PX = 4;
  const desiredOffsetFromMarker = Math.max(markerRadiusPx + GAP_PX, 6);
  let remaining = desiredOffsetFromMarker;
  let foundPoint = null;
  for (let i = pts.length - 1; i > 0; i--) {
    const A = pts[i-1], B = pts[i];
    const segLen = A.distanceTo(B);
    if (segLen >= remaining) {
      const t = (segLen - remaining) / segLen;
      const x = A.x + (B.x - A.x) * t;
      const y = A.y + (B.y - A.y) * t;
      foundPoint = L.point(x, y);
      break;
    } else remaining -= segLen;
  }
  if(!foundPoint){
    const lastIdx = pts.length - 1;
    const A = pts[lastIdx - 1], B = pts[lastIdx];
    const segLen = A.distanceTo(B) || 1;
    const t = Math.max(0, (segLen - desiredOffsetFromMarker) / segLen);
    const x = A.x + (B.x - A.x) * t;
    const y = A.y + (B.y - A.y) * t;
    foundPoint = L.point(x, y);
  }

  const arrowLatLng = map.layerPointToLatLng(foundPoint);
  const deg = bearingDeg(arrowLatLng.lat, arrowLatLng.lng, last.lat, last.lng);

  // SVG sizing: smaller when simplifiedMode
  const SVG_SIZE = simplifiedMode ? 10 : 14;
  const polyWeight = (poly.options && poly.options.weight) || 2;
  const arrowStroke = Math.max(1, Math.round(polyWeight * 1.2));
  const half = simplifiedMode ? 3 : 4;
  const pathColor = color || (poly.options && poly.options.color) || '#00ff00';
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="${-half-2} ${-half-2} ${ (half+2)*2 } ${ (half+2)*2 }">
      <path d="M ${-half} ${half} L 0 0 L ${half} ${half}" stroke="${pathColor}" stroke-width="${arrowStroke}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>`;
  const html = `<div style="width:${SVG_SIZE}px;height:${SVG_SIZE}px;transform:rotate(${deg}deg);display:flex;align-items:center;justify-content:center;pointer-events:none;">${svg}</div>`;
  const icon = L.divIcon({ className:'', html:html, iconSize:[SVG_SIZE, SVG_SIZE], iconAnchor:[Math.floor(SVG_SIZE/2), Math.floor(SVG_SIZE/2)] });
  const arrowMarker = L.marker(arrowLatLng, { icon: icon, interactive: false, pane: 'overlayPane' }).addTo(map);
  if (arrowMarker && arrowMarker._icon) arrowMarker._icon.style.zIndex = 650;
  decorators[id] = arrowMarker;
}

/* ========== END OPTIMIZED SECTION ========== */

/* ---------- Helper: rounding rule (opsi 2) ---------- */
function roundDistance(d){
  if(typeof d !== 'number' || isNaN(d)) return 0;
  return Math.round(d);
}
// ---------- end rounding helper ----------

function removeDecorator(id){
  if(!decorators[id]) return;
  try { if(map.hasLayer(decorators[id])) map.removeLayer(decorators[id]); } catch(e){}
  try { if(decorators[id]._icon && decorators[id]._icon.remove) decorators[id]._icon.remove(); } catch(e){}
  delete decorators[id];
}

function removeTooltip(id){
  if(!lineTooltips[id]) return;
  try { if(map.hasLayer(lineTooltips[id])) map.removeLayer(lineTooltips[id]); } catch(e){}
  delete lineTooltips[id];
}

// ---------- Ensure tooltips created (needed by meter toggle) ----------
function ensureTooltipsCreated(){
  try{
    const wantMeters = (localStorage.getItem('showMeters') === null) ? true : (localStorage.getItem('showMeters') === 'true');
    if(!wantMeters) return;
    let existing = Object.keys(lineTooltips).length;
    for(const id in lines){
      if(existing >= maxTooltips) break;
      if(lineTooltips[id]) continue;
      const poly = lines[id];
      if(!poly) continue;
      const raw = poly.getLatLngs();
      const latlngs = flattenLatLngs(raw);
      if(!latlngs || latlngs.length < 2) continue;
      const first = latlngs[0], last = latlngs[latlngs.length-1];
      if(!isLineInView(first, last)) continue;
      const straightDist = haversineDistance(first.lat, first.lng, last.lat, last.lng);
      const totalLen = polylineTotalLength(latlngs);
      const midPoint = (totalLen > 0) ? pointAtDistanceAlong(latlngs, totalLen / 2) : latlngs[Math.floor(latlngs.length/2)];
      if(midPoint){
        const distanceTooltip = L.tooltip({
          permanent: true,
          direction: 'center',
          className: 'line-distance-tooltip',
          interactive: false
        }).setContent(roundDistance(straightDist) + ' m').setLatLng(midPoint).addTo(map);
        lineTooltips[id] = distanceTooltip;
        existing++;
      }
    }
  }catch(e){
    console.warn('ensureTooltipsCreated error', e);
  }
}

// ---------- Helper to make swatch clickable and show palette ----------
function setupColorSwatch(swatchId, selectId){
  const sw = document.getElementById(swatchId);
  const sel = document.getElementById(selectId);
  if(!sw || !sel) return;

  if(!sel.value) sel.value = 'lime';
  const setColorOnSwatch = (val) => {
    const cssColor = colorNameToHex(val);
    sw.style.background = cssColor;
    sel.style.background = cssColor;
    sel.style.color = getTextColorFor(cssColor);
  };
  setColorOnSwatch(sel.value);
  sel.addEventListener('change', ()=> setColorOnSwatch(sel.value));

  sw.addEventListener('click', (ev) => {
    ev.stopPropagation();
    const existing = document.getElementById(swatchId + '-palette');
    if(existing){ existing.remove(); return; }

    const palette = document.createElement('div');
    palette.id = swatchId + '-palette';
    palette.style.position = 'absolute';
    palette.style.zIndex = 10050;
    palette.style.display = 'grid';
    palette.style.gridAutoFlow = 'row';
    palette.style.gridTemplateColumns = 'repeat(5, 26px)';
    palette.style.gap = '6px';
    palette.style.padding = '8px';
    palette.style.background = '#fff';
    palette.style.border = '1px solid rgba(0,0,0,0.12)';
    palette.style.borderRadius = '8px';
    palette.style.boxShadow = '0 6px 18px rgba(2,6,23,0.12)';

    const opts = Array.from(sel.options);
    opts.forEach(o => {
      const c = o.value;
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.title = o.textContent || c;
      btn.style.width = '26px';
      btn.style.height = '26px';
      btn.style.border = '2px solid #111827';
      btn.style.borderRadius = '6px';
      btn.style.cursor = 'pointer';
      btn.style.padding = '0';
      const cssColor = colorNameToHex(c);
      btn.style.background = cssColor;
      btn.addEventListener('click', (ev2) => {
        ev2.stopPropagation();
        sel.value = c;
        sel.dispatchEvent(new Event('change', { bubbles: true }));
        setColorOnSwatch(c);
        palette.remove();
      });
      palette.appendChild(btn);
    });

    document.body.appendChild(palette);

    const rect = sw.getBoundingClientRect();
    const left = Math.max(8, rect.left);
    const top = rect.bottom + 8;
    palette.style.left = left + 'px';
    palette.style.top = top + 'px';

    const onDocClick = (e) => {
      if(!palette.contains(e.target) && e.target !== sw){
        palette.remove();
        document.removeEventListener('click', onDocClick);
      }
    };
    setTimeout(()=> document.addEventListener('click', onDocClick), 0);
  });
}
// ---------- end swatch helper ----------

/* ... rest of code unchanged (markers, polylines, tooltips, handlers) ... */

// ---------- Marker creation (unchanged logic, keep draggable) ----------
function addMarker(ap){
  const status = getStatus(ap.ip);
  const icon = makeWifiIcon(status);
  const m = L.marker([ap.lat,ap.lng],{icon, draggable:true}).addTo(map);
  allLatLng.push([ap.lat,ap.lng]);

  const sinceText = getSince(ap.ip) || ap.lasttime || '';
  const popupHtml = `<div style="min-width:160px; font-size:12px">
    <b>${escapeHtml(ap.name)}</b><br>
    IP: ${escapeHtml(ap.ip)}<br>
    Status: ${status.toUpperCase()}<br>
    Since: ${escapeHtml(sinceText)}<br>
    <div style="margin-top:6px; display:flex; gap:6px; flex-direction:column;">
      <div style="display:flex; gap:6px;">
        <a class="btn" style="color:#fff" onclick='openEditPopup(${JSON.stringify(ap)})'>Edit</a>
        <a class="btn danger" style="color:#fff" href="delete_ap.php?id=${encodeURIComponent(ap.id)}" onclick="return confirm('Hapus AP ini?')">Hapus</a>
      </div>
      <div style="display:flex; gap:6px; justify-content:flex-start;">
        <a class="btn" style="background:#16a34a;color:#fff" href="http://${encodeURIComponent(ap.ip)}/" target="_blank" rel="noopener" title="Buka IP ${escapeHtml(ap.ip)}">Open</a>
        <a class="btn" style="background:#f59e0b;color:#fff" href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ap.lat + ',' + ap.lng)}" target="_blank" rel="noopener" title="Trace ke ${escapeHtml(ap.name)}">Trace</a>

      </div>
    </div>
  </div>`;
  m.bindPopup(popupHtml);

  markers[ap.id]=m; markersByIp[ap.ip]=m;

  // drag handlers (keputusan: tetap draggable seperti semula)
  m.on('drag', e=>{
    if(ap.line && lines[ap.id]){
      const from = getMarkerForReference(ap.line);
      if(from) {
        const latlngs = buildBentLatLngs(from.getLatLng(), m.getLatLng(), ap.id);
        lines[ap.id].setLatLngs(latlngs);
        // only recreate decorator if line visible (avoids heavy ops)
        createDecoratorForLine(ap.id, lines[ap.id], lineColors[ap.id]);
      }
    }
    // update garis anak-anak (children) saat parent digeser
    AP_LIST.forEach(child=>{
      if(child.line===ap.id && lines[child.id]) {
        const latlngs = buildBentLatLngs(m.getLatLng(), markers[child.id].getLatLng(), child.id);
        lines[child.id].setLatLngs(latlngs);
        createDecoratorForLine(child.id, lines[child.id], lineColors[child.id]);
      }
    });
  });

  m.on('dragstart', e=>{
      m._originalLatLng = m.getLatLng(); // simpan posisi awal
  });

  m.on('dragend', async e=>{
      const pos = m.getLatLng();

      if(!confirm('Anda yakin ingin memindahkan AP "'+ap.name+'" ke koordinat baru?')){
          // rollback posisi marker
          m.setLatLng(m._originalLatLng);

          // rollback line parent
          if(ap.line && lines[ap.id]){
              const from = getMarkerForReference(ap.line);
              if(from) lines[ap.id].setLatLngs(buildBentLatLngs(from.getLatLng(), m._originalLatLng, ap.id));
              createDecoratorForLine(ap.id, lines[ap.id], lineColors[ap.id]);
          }

          // rollback line anak-anak
          AP_LIST.forEach(child=>{
              if(child.line === ap.id && lines[child.id]){
                  lines[child.id].setLatLngs(buildBentLatLngs(m._originalLatLng, markers[child.id].getLatLng(), child.id));
                  createDecoratorForLine(child.id, lines[child.id], lineColors[child.id]);
              }
          });

          showToast('Perubahan dibatalkan'); // toast saat batal
          return;
      }

      try{
          const resp = await fetch('update_coord.php',{
              method:'POST',
              headers:{'Content-Type':'application/json'},
              body: JSON.stringify({id:ap.id, lat:pos.lat, lng:pos.lng, router_ip:MT_IP})
          });
          if(!resp.ok) throw new Error('HTTP '+resp.status);
          const j = await resp.json();

          if(j.ok){
              // update sidebar (jika ada)
              const el=document.querySelector(`.ap-item[data-id="${ap.id}"]`);
              if(el) el.querySelector('.sub').textContent='Koord: '+pos.lat.toFixed(6)+', '+pos.lng.toFixed(6);

              // toast sukses
              showToast('Koordinat AP "'+ap.name+'" berhasil diperbarui!', 3000);
              setTimeout(()=>location.reload(), 800);
          } else {
              showToast('Gagal menyimpan koordinat', 3000);
              m.setLatLng(m._originalLatLng); // rollback
          }
      } catch(err){
          showToast('Gagal menyimpan koordinat: '+err.message, 4000);
          m.setLatLng(m._originalLatLng); // rollback
      }
  });

}

// ---------- create markers & lines (with visibility checks) ----------
AP_LIST.forEach(a=>addMarker(a));

/* ---------- DRAW POLYLINES (OPTIMIZED w/ JSON) ----------
   Sekarang kita pakai LINES_JSON (precomputed) agar client tidak perlu menghitung jarak/midpoint berat di awal.
   Fungsi lain tetap kompatibel karena kita tetap menyimpan lines[...] dan linesMeta[...] seperti semula.
*/

Object.values(LINES_JSON).forEach(ld => {
  const ap = AP_BY_ID[ld.toId];
  if(!ap) return;
  // try find from marker by reference first, fallback to AP_BY_ID map coordinates
  const fromMarker = getMarkerForReference(ld.fromRef);
  let fromLatLng = null;
  if (fromMarker) fromLatLng = fromMarker.getLatLng();
  else {
    const fromAp = AP_BY_ID[ld.fromRef];
    if(fromAp) fromLatLng = L.latLng(fromAp.lat, fromAp.lng);
    else fromLatLng = L.latLng(ld.latlngs[0][0], ld.latlngs[0][1]);
  }
  const toMarker = markers[ld.toId];
  if(!toMarker) {
    console.warn('Skip membuat garis: child marker missing untuk', ld.toId);
    return;
  }
  const toLatLng = toMarker.getLatLng();
  const visible = isLineInView(fromLatLng, toLatLng);

  const targetStatus = getStatus(ap.ip);
  let colorWord;
  if (targetStatus === 'down') { colorWord = 'red'; }
  else if (targetStatus === 'up') { colorWord = normalizeColor(ld.color || ap.lineColor || 'lime'); }
  else { colorWord = normalizeColor(ld.color || ap.lineColor || 'gray'); }

  lineColors[ap.id] = colorWord;

  // create polyline using precomputed endpoints (createPolylineFor will compute curve if needed)
  const poly = createPolylineFor(ap.id, fromLatLng, toLatLng, { color: colorWord, weight: 2, opacity: 1 });
  poly.addTo(map);
  lines[ap.id] = poly;

  // store meta so we can recompute latlngs after zoom/pan if needed
  linesMeta[ap.id] = { fromRef: ld.fromRef, toId: ld.toId };

  // only create decorator if line currently visible and permitted by thresholds
  const zoom = map.getZoom ? map.getZoom() : 0;
  if (visible && ( (!simplifiedMode) || (zoom >= ZOOM_THRESHOLD_FOR_DETAILS) )) {
    createDecoratorForLine(ap.id, poly, colorWord);
  }

  // create distance tooltip using precomputed straight & mid (respect cap)
  const showMetersNow = (localStorage.getItem('showMeters') === null) ? true : (localStorage.getItem('showMeters') === 'true');
  const existingTooltipsCount = Object.keys(lineTooltips).length;
  if (visible && showMetersNow && (existingTooltipsCount < maxTooltips)) {
    const midPoint = L.latLng(ld.mid[0], ld.mid[1]);
    const distanceTooltip = L.tooltip({
      permanent: true,
      direction: 'center',
      className: 'line-distance-tooltip',
      interactive: false
    }).setContent(roundDistance(ld.straight) + ' m').setLatLng(midPoint).addTo(map);
    lineTooltips[ap.id] = distanceTooltip;
  }
});

/* ---------- end draw polylines ---------- */

// tampilkan bounds bila ada
if(allLatLng.length>0) map.fitBounds(L.latLngBounds(allLatLng),{padding:[40,40]});

// Recompute polylines (curves) after interactions — debounced to avoid doing work during animation
const updatePolylinesDebounced = debounce(function(){
  for(const id in linesMeta){
    try{
      const meta = linesMeta[id];
      const poly = lines[id];
      if(!meta || !poly) continue;
      const from = getMarkerForReference(meta.fromRef);
      const to = markers[meta.toId];
      if(!from || !to) continue;
      const newLatlngs = buildBentLatLngs(from.getLatLng(), to.getLatLng(), id);
      poly.setLatLngs(newLatlngs);
      // update decorator position/rotation
      createDecoratorForLine(id, poly, lineColors[id]);

      // ALSO update the distance tooltip position & content so it sits on the curved line midpoint
      try {
        const tt = lineTooltips[id];
        if (tt) {
          const raw = poly.getLatLngs();
          const flat = flattenLatLngs(raw);
          const totalLen = polylineTotalLength(flat);
          const midPoint = (totalLen > 0) ? pointAtDistanceAlong(flat, totalLen / 2) : flat[Math.floor(flat.length/2)];
          if (midPoint) tt.setLatLng(midPoint);
          const dist = (flat && flat.length >= 2) ? haversineDistance(flat[0].lat, flat[0].lng, flat[flat.length-1].lat, flat[flat.length-1].lng) : 0;
          tt.setContent(roundDistance(dist) + ' m');
        }
      } catch(e) {
        // ignore tooltip update errors
      }

    }catch(e){
      console.warn('update poly error', id, e);
    }
  }
}, 120);

// Debounced rebuild of decorators on zoomend/moveend to reduce work
const rebuildDecoratorsDebounced = debounce(function(){
  for(const id in lines){
    try{
      const poly = lines[id];
      const latlngs = poly.getLatLngs();
      const first = latlngs[0], last = latlngs[latlngs.length-1];
      if (!isLineInView(first, last)) {
        removeDecorator(id);
        // also remove tooltip that is offscreen to reduce DOM
        removeTooltip(id);
        continue;
      }
      // recreate decorator to adjust offset/rotation after zoom/resize
      createDecoratorForLine(id, poly, lineColors[id]);
    } catch(e){
      console.warn('decorator rebuild error', id, e);
    }
  }
}, 220);

// updateAllLineTooltips as before, but ensure only visible tooltips are toggled
let showMeters = (localStorage.getItem('showMeters') === null) ? true : (localStorage.getItem('showMeters') === 'true');
function updateAllLineTooltips(){
  // use the same threshold variable so it's consistent with creation
  const zoomOk = map.getZoom() >= ZOOM_THRESHOLD_FOR_DETAILS;
  const shouldShow = zoomOk && showMeters;
  Object.entries(lineTooltips).forEach(([id, t]) => {
    const el = t.getElement();
    if(!el) return;
    // hide/show tooltip based on both user preference and zoom threshold
    el.style.display = shouldShow ? 'block' : 'none';
  });
  const btn = document.getElementById('meter-toggle-btn');
  if(btn){
    btn.textContent = 'Meter: ' + (showMeters ? 'On' : 'Off');
    btn.classList.toggle('on', showMeters);
    btn.classList.toggle('off', !showMeters);
  }
}
updateAllLineTooltips();

// Debounced ensure tooltip creation for zoom changes / user enabling meters
const ensureTooltipsCreatedDebounced = debounce(ensureTooltipsCreated, 250);

// Rebuild decorators and recompute polylines only after animations finish (moveend/zoomend)
map.on('zoomend moveend', function(){
  updatePolylinesDebounced();
  rebuildDecoratorsDebounced();
  ensureTooltipsCreatedDebounced();
  updateAllLineTooltips();
});

// ---------- Ensure decorators/tooltips are created after initial render ----------
setTimeout(() => {
  try {
    if (allLatLng.length > 0) {
      try { map.invalidateSize(); } catch(e) {}
      updatePolylinesDebounced();
      rebuildDecoratorsDebounced();
      ensureTooltipsCreatedDebounced();
      updateAllLineTooltips();
    }
  } catch (e) {
    console.warn('initial decorator rebuild failed', e);
  }
}, 300);

map.whenReady(() => {
  setTimeout(() => {
    try {
      rebuildDecoratorsDebounced();
      ensureTooltipsCreatedDebounced();
      updateAllLineTooltips();
    } catch (e) {}
  }, 400);
});

// attach meter toggle button behavior
document.addEventListener('click', function(e){
  const el = e.target;
  if(!el) return;
  if(el.id === 'meter-toggle-btn'){
    showMeters = !showMeters;
    localStorage.setItem('showMeters', showMeters);
    // if enabling, create any missing tooltips for visible lines (respect cap)
    if(showMeters){
      ensureTooltipsCreated();
    }
    updateAllLineTooltips();
  }
});

// ===== Toast helper (unchanged) =====
function showToast(message, duration=3000){
    let toast = document.createElement('div');
    toast.textContent = message;
    toast.style.position = 'fixed';
    toast.style.bottom = '20px';
    toast.style.right = '20px';
    toast.style.background = 'rgba(0,0,0,0.75)';
    toast.style.color = '#fff';
    toast.style.padding = '8px 12px';
    toast.style.borderRadius = '6px';
    toast.style.zIndex = 5000;
    toast.style.fontSize = '12px';
    toast.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 0.3s';
    document.body.appendChild(toast);
    setTimeout(()=>toast.style.opacity='1', 10);
    setTimeout(()=>{ toast.style.opacity='0'; setTimeout(()=>toast.remove(), 300); }, duration);
}

document.querySelectorAll('.ap-item').forEach(item=>{
  item.addEventListener('click', ()=>{ const marker=markers[item.dataset.id]; if(marker){ map.setView(marker.getLatLng(),18,{animate:true}); marker.openPopup(); } });
});

function openAddPopup(latlng){
  const options=[{id:'', name:'— Tidak ada —'}].concat(AP_LIST.map(x=>({id:x.id,name:x.name})));
  const optsHtml = options.map(o=>`<option value="${o.id}">${o.name}</option>`).join('');

  // dropdown warna garis menggunakan kata-kata saja
  const lineColors = [
    {value:'lime', label:'Hijau'},
    {value:'pink', label:'Pink'},
    {value:'blue', label:'Biru'},
    {value:'orange', label:'Orens'},
    {value:'fuchsia', label:'Ungu'},
    {value:'black', label:'Hitam'},
    {value:'yellow', label:'Kuning'},
    {value:'gray', label:'Abu'},
    {value:'green', label:'Hijau Tua'},
    {value:'gold', label:'Emas'},
    {value:'aqua', label:'Biru Air'},
    {value:'gainsboro', label:'Gainsboro'},
    {value:'chartreuse', label:'Chartreuse'},
    {value:'magenta', label:'Magenta'},
    {value:'brown', label:'Coklat'}
  ];
  const colorOptions = lineColors.map(c=>`<option value="${c.value}">${c.label}</option>`).join('');

  // Tambahkan input pencarian kecil di atas select (memungkinkan mencari source tanpa merubah tampilan besar)
  const formHtml = `<div style="min-width:220px; font-size:12px">
    <h4>Tambah AP</h4>
    <form id="addApForm">
      <label>Nama AP:<br><input type="text" name="name" required style="width:100%;font-size:12px"/></label><br>
      <label>IP AP:<br><input type="text" name="ip" required style="width:100%;font-size:12px"/></label><br>
      <label>Source:<br>
        <input type="text" id="addSourceSearch" placeholder="Cari AP..." style="width:100%;font-size:12px;margin-bottom:4px"/><br>
        <select name="line" style="font-size:12px">${optsHtml}</select>
      </label><br><br>
      <label>Warna Garis:<br>
        <div class="color-picker">
          <div id="swatch-add" class="color-swatch"></div>
          <select id="addLineColor" name="lineColor" class="line-color-select">${colorOptions}</select>
        </div>
      </label><br>
      <input type="hidden" name="lat" value="${latlng.lat}"/>
      <input type="hidden" name="lng" value="${latlng.lng}"/>
      <div style="margin-top:6px; text-align:right">
        <button type="button" id="cancelAdd" class="btn" style="background:#94a3b8">Batal</button>
        <button type="submit" class="btn">Tambah</button>
      </div>
    </form>
  </div>`;
  const popup=L.popup().setLatLng(latlng).setContent(formHtml).openOn(map);
  document.getElementById('cancelAdd').addEventListener('click',()=>map.closePopup());

  // --- fitur pencarian untuk select Source (Add) ---
  try{
    const searchInput = document.getElementById('addSourceSearch');
    const selectEl = document.querySelector('#addApForm select[name="line"]');
    // simpan salinan original options (array of objects)
    const originalOptions = options.slice();
    searchInput.addEventListener('input', function(){
      const q = String(this.value || '').trim().toLowerCase();
      // rebuild options berdasarkan pencarian (cari di name atau id)
      const filtered = originalOptions.filter(o => {
        if(q === '') return true;
        return (String(o.name || '').toLowerCase().indexOf(q) !== -1) || (String(o.id || '').toLowerCase().indexOf(q) !== -1);
      });
      selectEl.innerHTML = filtered.map(o => `<option value="${o.id}">${escapeHtml(o.name)}</option>`).join('');
    });
  }catch(e){
    // jika terjadi error, jangan ganggu alur utama
    console.warn('Search init (add) error', e);
  }

  // ---- init color swatch & select styling (Add) ----
  try{
    // use robust helper to make swatch clickable and show palette
    setupColorSwatch('swatch-add', 'addLineColor');
  }catch(e){ console.warn('init color (add) error', e); }
  // ---- end color init ----

document.getElementById('addApForm').addEventListener('submit', async ev=>{
    ev.preventDefault();
    const form=ev.target;
    const lineColor = form.lineColor.value || 'lime';

    const formData={
        name: form.name.value.trim(),
        ip: form.ip.value.trim(),
        lat: parseFloat(form.lat.value),
        lng: parseFloat(form.lng.value),
        line: form.line.value,
        lineColor: lineColor
    };
    if(!formData.name || !formData.ip){ alert("Nama & IP wajib diisi"); return; }

    try{
        const resp = await fetch('add_ap.php',{
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body:JSON.stringify(formData)
        });
        const j = await resp.json();
        if(j.success){
            addMarker(j.ap);
            AP_LIST.push(j.ap);
            map.closePopup();
            alert('AP berhasil ditambahkan' + (j.mikrotik==='ok'?' & Netwatch OK':' & Netwatch Gagal'));
            location.reload(); // ⬅️ otomatis refresh
        } else alert('Gagal menambahkan AP: '+(j.error||'unknown'));
    }catch(err){ alert('Gagal menambahkan AP: '+err.message); }
  });
}

function openEditPopup(ap){
    const options = [{id:'', name:'— Tidak ada —'}].concat(AP_LIST.filter(x=>x.id!==ap.id).map(x=>({id:x.id,name:x.name})));
    const optsHtml = options.map(o=>`<option value="${o.id}" ${ap.line===o.id?'selected':''}>${o.name}</option>`).join('');

    // pilih warna default: jika ap.lineColor ada dan termasuk accepted list, pilih; jika tidak, default 'lime'
    const currentColor = (ap.lineColor && ACCEPTED_COLORS.indexOf(String(ap.lineColor).toLowerCase()) !== -1) ? String(ap.lineColor).toLowerCase() : 'lime';

    const lineColors = [
      {value:'lime', label:'Hijau'},
      {value:'pink', label:'Pink'},
      {value:'blue', label:'Biru'},
      {value:'orange', label:'Orens'},
      {value:'fuchsia', label:'Ungu'},
      {value:'black', label:'Hitam'},
      {value:'yellow', label:'Kuning'},
      {value:'gray', label:'Abu'},
      {value:'green', label:'Hijau Tua'},
      {value:'gold', label:'Emas'},
      {value:'aqua', label:'Biru Air'},
      {value:'gainsboro', label:'Gainsboro'},
      {value:'chartreuse', label:'Chartreuse'},
      {value:'magenta', label:'Magenta'},
      {value:'brown', label:'Coklat'}
    ];
    const colorOptions = lineColors.map(c=>`<option value="${c.value}" ${currentColor===c.value?'selected':''}>${c.label}</option>`).join('');

    const formHtml = `<div style="min-width:220px; font-size:12px">
        <h4>Edit AP</h4>
        <form id="editApForm">
            <label>Nama AP:<br><input type="text" name="name" value="${escapeHtml(ap.name)}" required style="width:100%;font-size:12px"/></label><br>
            <label>IP AP:<br><input type="text" name="ip" value="${escapeHtml(ap.ip)}" required style="width:100%;font-size:12px"/></label><br>
            <label>Latitude:<br><input type="number" step="any" name="lat" value="${ap.lat}" required style="width:100%;font-size:12px"/></label><br>
            <label>Longitude:<br><input type="number" step="any" name="lng" value="${ap.lng}" required style="width:100%;font-size:12px"/></label><br>
            <label>Source:<br>
              <input type="text" id="editSourceSearch" placeholder="Cari AP..." style="width:100%;font-size:12px;margin-bottom:4px"/><br>
              <select name="line" style="width:100%;font-size:12px">${optsHtml}</select>
            </label><br><br>
<label>Warna Garis:<br>
  <div class="color-picker">
    <div id="swatch-edit" class="color-swatch"></div>
    <select id="editLineColor" name="lineColor" class="line-color-select">${colorOptions}</select>
  </div>
</label><br>
<br>

            <div style="margin-top:6px; text-align:right">
                <button type="button" id="cancelEdit" class="btn" style="background:#94a3b8">Batal</button>
                <button type="submit" class="btn">Simpan</button>
            </div>
        </form>
    </div>`;

    const popup = L.popup().setLatLng([ap.lat, ap.lng]).setContent(formHtml).openOn(map);

    document.getElementById('cancelEdit').addEventListener('click', ()=>map.closePopup());

    // --- fitur pencarian untuk select Source (Edit) ---
    try{
      const searchInput = document.getElementById('editSourceSearch');
      const selectEl = document.querySelector('#editApForm select[name="line"]');
      const originalOptions = options.slice();
      // preset search empty so initial list is full with selection preserved
      searchInput.addEventListener('input', function(){
        const q = String(this.value || '').trim().toLowerCase();
        const filtered = originalOptions.filter(o => {
          if(q === '') return true;
          return (String(o.name || '').toLowerCase().indexOf(q) !== -1) || (String(o.id || '').toLowerCase().indexOf(q) !== -1);
        });
        selectEl.innerHTML = filtered.map(o => `<option value="${o.id}" ${String(ap.line) === String(o.id) ? 'selected' : ''}>${escapeHtml(o.name)}</option>`).join('');
      });
    }catch(e){
      console.warn('Search init (edit) error', e);
    }

    // ---- init color swatch & select styling (Edit) ----
    try{
      setupColorSwatch('swatch-edit', 'editLineColor');
    }catch(e){ console.warn('init color (edit) error', e); }
    // ---- end color init ----

    document.getElementById('editApForm').addEventListener('submit', async ev=>{
        ev.preventDefault();
        const form = ev.target;
        const lineColor = form.lineColor.value || 'lime';

        const formData = {
            id: ap.id,
            name: form.name.value.trim(),
            ip: form.ip.value.trim(),
            lat: parseFloat(form.lat.value),
            lng: parseFloat(form.lng.value),
            line: form.line.value,
            lineColor: lineColor
        };
        try{
            const resp = await fetch('edit_ap_ajax.php', { // endpoint baru untuk AJAX edit
                method:'POST',
                headers:{'Content-Type':'application/json'},
                body: JSON.stringify(formData)
            });
            const j = await resp.json();
            if(j.success){
                // update marker & line di map
                updateMarkerOnMap(j.ap);
                map.closePopup();
                alert('AP berhasil diperbarui');
                location.reload(); // ⬅️ otomatis refresh
            } else alert('Gagal update AP: '+(j.error||'unknown'));
        }catch(err){ alert('Gagal update AP: '+err.message); }
    });
}

function updateMarkerOnMap(ap){
    // pastikan marker ada
    const m = markers[ap.id];
    if(!m) return;

    // update marker posisi & popup
    m.setLatLng([ap.lat, ap.lng]);
    const status = getStatus(ap.ip);
    m.setIcon(makeWifiIcon(status));
    m.getPopup().setContent(`
        <div style="min-width:120px; font-size:12px">
            <b>${escapeHtml(ap.name)}</b><br>
            IP: ${escapeHtml(ap.ip)}<br>
            Status: ${status.toUpperCase()}<br>
            Since: ${escapeHtml(getSince(ap.ip)||ap.lasttime||'')}<br>
            <div style="margin-top:6px; display:flex; gap:6px; flex-direction:column;">
                <div style="display:flex; gap:6px;">
                    <a class="btn" style="color:#fff" onclick="openEditPopup(${JSON.stringify(ap)})">Edit</a>
                    <a class="btn danger" style="color:#fff" onclick="if(confirm('Hapus AP ini?')) deleteAp('${ap.id}')">Hapus</a>
                </div>
                <div style="display:flex; gap:6px; justify-content:flex-end;">
                    <a class="btn" style="background:#f59e0b;color:#fff" href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(String(ap.lat) + ',' + String(ap.lng))}" target="_blank" rel="noopener" title="Trace ke ${escapeHtml(ap.name)}">Trace</a>
                    <a class="btn" style="background:#16a34a;color:#fff" href="http://${encodeURIComponent(ap.ip)}/" target="_blank" rel="noopener" title="Buka IP ${escapeHtml(ap.ip)}">Akses IP</a>
                </div>
            </div>
        </div>
    `);

    // update line jika ada
    if(ap.line && lines[ap.id]){
        const from = getMarkerForReference(ap.line);
        if(from) {
            const latlngs = buildBentLatLngs(from.getLatLng(), m.getLatLng(), ap.id);
            lines[ap.id].setLatLngs(latlngs);
        }
        const newColor = normalizeColor(ap.lineColor || 'lime');
        lines[ap.id].setStyle({ color: newColor, opacity: 1 });
        lineColors[ap.id] = newColor;
        createDecoratorForLine(ap.id, lines[ap.id], newColor);

        // also update tooltip position/content if exists
        try {
          const tt = lineTooltips[ap.id];
          if (tt) {
            const raw = lines[ap.id].getLatLngs();
            const flat = flattenLatLngs(raw);
            const totalLen = polylineTotalLength(flat);
            const midPoint = (totalLen > 0) ? pointAtDistanceAlong(flat, totalLen / 2) : flat[Math.floor(flat.length/2)];
            if(midPoint) tt.setLatLng(midPoint);
            const dist = (flat && flat.length >= 2) ? haversineDistance(flat[0].lat, flat[0].lng, flat[flat.length-1].lat, flat[flat.length-1].lng) : 0;
            tt.setContent(roundDistance(dist) + ' m');
          }
        } catch(e){}
    } else if(!ap.line && lines[ap.id]){
        try { if(map.hasLayer(lines[ap.id])) map.removeLayer(lines[ap.id]); } catch(e){}
        delete lines[ap.id];
        removeDecorator(ap.id);
        try { if(lineTooltips[ap.id] && map.hasLayer(lineTooltips[ap.id])) map.removeLayer(lineTooltips[ap.id]); } catch(e){}
        delete lineTooltips[ap.id];
    }

    // update AP_LIST
    const idx = AP_LIST.findIndex(a=>a.id===ap.id);
    if(idx>=0) AP_LIST[idx] = ap;
}

function focusToAp(id){ const marker=markers[id]; if(marker){ map.setView(marker.getLatLng(),18,{animate:true}); marker.openPopup(); } }

map.on('contextmenu', e=>openAddPopup(e.latlng));
let touchTimer=null;
map.on('touchstart', e=>{ if(e.touches && e.touches.length>1) return; touchTimer=setTimeout(()=>openAddPopup(e.latlng),700); });
map.on('touchend touchmove', ()=>{ if(touchTimer) clearTimeout(touchTimer); touchTimer=null; });
map.on('mousedown', e=>{ if(e.originalEvent && e.originalEvent.ctrlKey) openAddPopup(e.latlng); });

</script>
<script>
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("sidebar-toggle");
const body = document.body;

toggleBtn.addEventListener("click", () => {
    sidebar.classList.toggle("hidden");
    body.classList.toggle("sidebar-hidden");

    if (sidebar.classList.contains("hidden")) {
        toggleBtn.textContent = "❮"; // sidebar sembunyi → tampilkan panah kanan
    } else {
        toggleBtn.textContent = "❯"; // sidebar muncul → tampilkan panah kiri
    }
});

/* ===== Filtering logic untuk badge clickable (offline / online / semua) ===== */
(function(){
    const btnOffline = document.getElementById('filter-offline');
    const btnOnline = document.getElementById('filter-online');
    const btnAll = document.getElementById('filter-all');
    const table = document.getElementById('ap-table');

    let activeFilter = 'all'; // 'all' | 'up' | 'down'

    function clearActiveClasses(){
        btnOffline.classList.remove('active');
        btnOnline.classList.remove('active');
        btnAll.classList.remove('active');
    }

    function applyFilter(status){
        // jika sudah aktif sama, reset ke all
        if(activeFilter === status || status === 'all'){
            activeFilter = 'all';
            clearActiveClasses();
            btnAll.classList.add('active');
        } else {
            activeFilter = status;
            clearActiveClasses();
            if(status === 'down') btnOffline.classList.add('active');
            else if(status === 'up') btnOnline.classList.add('active');
        }

        // iterasi tiap row
        const rows = Array.from(table.querySelectorAll('tbody tr'));
        rows.forEach(row => {
            // parent group rows have data-root-status attribute
            const isParent = row.classList.contains('parent-row');
            const groupId = row.dataset.group;

            if(isParent){
                // find child rows in this group
                const childRows = Array.from(document.querySelectorAll('.'+groupId));
                if(activeFilter === 'all'){
                    // hide children default (they may be closed) — keep parent's visual as originally
                    row.style.display = '';
                    childRows.forEach((cr)=>{ cr.style.display = 'none'; });
                    // set icon to collapsed
                    const icon = row.querySelector('.toggle-icon');
                    if(icon) icon.textContent = '⯈';
                } else {
                    // check if any child match filter
                    const anyChildMatch = childRows.some(cr => cr.dataset.status === activeFilter);
                    if(anyChildMatch){
                        row.style.display = ''; // show parent
                        childRows.forEach(cr=>{
                            if(cr.dataset.status === activeFilter) cr.style.display = 'table-row';
                            else cr.style.display = 'none';
                        });
                        // expand icon
                        const icon = row.querySelector('.toggle-icon');
                        if(icon) icon.textContent = '⯆';
                    } else {
                        // nothing to show in this group
                        row.style.display = 'none';
                        childRows.forEach(cr=>{ cr.style.display = 'none'; });
                    }
                }
            } else {
                // non-group rows have data-status
                const s = row.dataset.status || 'unknown';
                if(activeFilter === 'all') row.style.display = '';
                else row.style.display = (s === activeFilter) ? '' : 'none';
            }
        });
    }

    // initial: set All active
    btnAll.classList.add('active');

    btnOffline.addEventListener('click', ()=> applyFilter('down'));
    btnOnline.addEventListener('click', ()=> applyFilter('up'));
    btnAll.addEventListener('click', ()=> applyFilter('all'));
})();
</script>

<!-- Auto-reload script: gunakan nilai dari settings.json yang disimpan di server (index.php menyimpan) -->
<script>
  (function(){
    // nilai di-inject dari server (menit)
    const AUTO_RELOAD_MINUTES = <?= json_encode(intval($reloadMinutes)) ?>;

    if (AUTO_RELOAD_MINUTES > 0) {
      const ms = AUTO_RELOAD_MINUTES * 60 * 1000;
      console.info('Auto-reload aktif: setiap ' + AUTO_RELOAD_MINUTES + ' menit (' + ms + ' ms)');
      // Gunakan setInterval untuk reload terus menerus
      setInterval(() => {
        try {
          // Pastikan tidak sedang ada popup terbuka yang aktif (opsional)
          const popups = document.querySelectorAll('.leaflet-popup-open, .leaflet-popup');
          if (popups && popups.length > 0) {
            // jika ingin selalu reload meskipun popup terbuka, hapus blok if ini
            console.info('Auto-reload ditunda karena popup terbuka.');
            return;
          }
        } catch(e){}
        location.reload();
      }, ms);
    } else {
      console.info('Auto-reload dinonaktifkan (Disabled)');
    }
  })();
</script>

</body>
</html>