<?php
/**
 * leaderboard.php — aptum.fun/leaderboard
 * Ranks evaluated X accounts by score + niche explorer chart
 */

define('EVAL_DIR', __DIR__ . '/evaluations/');

if (!is_dir(EVAL_DIR)) { $evals = []; }
else {
    $evals = [];
    foreach (glob(EVAL_DIR . '*.json') as $f) {
        if (basename($f) === 'queue.json') continue;
        $d = json_decode(file_get_contents($f), true);
        if (!$d || !isset($d['evaluation']['overall'])) continue;
        $evals[] = [
            'username'     => $d['username'] ?? basename($f, '.json'),
            'scannedAt'    => $d['scannedAt'] ?? null,
            'overall'      => (float)($d['evaluation']['overall'] ?? 0),
            'grade'        => $d['evaluation']['grade'] ?? '',
            'niche'          => $d['evaluation']['niche'] ?? '',
            'niche_category' => $d['evaluation']['niche_category'] ?? $d['evaluation']['niche'] ?? '',
            'account_type' => $d['evaluation']['report']['account_type'] ?? '',
            'followers'    => $d['profile']['followers'] ?? '',
            'pfpUrl'       => $d['profile']['pfpUrl'] ?? '',
            'er_pct'       => $d['evaluation']['report']['weighted_er_pct'] ?? '',
            'verdict'      => $d['evaluation']['report']['verdict'] ?? '',
            'dimensions'   => array_values($d['evaluation']['dimensions'] ?? []),
            'top_mentions' => $d['top_mentions'] ?? [],
        ];
    }
}

// Build mention graph across evaluated accounts only
$evalHandles = array_flip(array_map('strtolower', array_column($evals, 'username')));
$evalByHandle = [];
foreach ($evals as $e) $evalByHandle[strtolower($e['username'])] = $e;

// edges[from][to] = mention count
$edges = [];
foreach ($evals as $e) {
    $from = strtolower($e['username']);
    foreach ($e['top_mentions'] as $m) {
        $to = strtolower($m['handle'] ?? '');
        if (!$to || !isset($evalHandles[$to]) || $to === $from) continue;
        $edges[$from][$to] = ($edges[$from][$to] ?? 0) + ($m['count'] ?? 1);
    }
}

// Find mutual pairs (both mention each other) + one-way connections
$mutualPairs = []; // [a, b, countAtoB, countBtoA]
$seen = [];
foreach ($edges as $a => $targets) {
    foreach ($targets as $b => $cnt) {
        $key = $a < $b ? "$a|$b" : "$b|$a";
        if (isset($seen[$key])) continue;
        $seen[$key] = true;
        $reverse = $edges[$b][$a] ?? 0;
        $mutualPairs[] = [
            'a' => $a, 'b' => $b,
            'a_to_b' => $cnt, 'b_to_a' => $reverse,
            'mutual' => $cnt > 0 && $reverse > 0,
            'strength' => $cnt + $reverse,
        ];
    }
}
// Sort by strength desc
usort($mutualPairs, fn($x, $y) => $y['strength'] <=> $x['strength']);

// Most mentioned evaluated accounts (incoming mention count)
$incomingMentions = [];
foreach ($edges as $from => $targets) {
    foreach ($targets as $to => $cnt) {
        $incomingMentions[$to] = ($incomingMentions[$to] ?? 0) + $cnt;
    }
}
arsort($incomingMentions);

// Build network: evaluated accounts with top_mentions + their mentioned accounts as ghost nodes
$netNodes = [];
$netEdges = [];
$netHandlesAdded = [];

foreach ($evals as $e) {
    if (empty($e['top_mentions'])) continue;
    $from = strtolower($e['username']);
    if (!isset($netHandlesAdded[$from])) {
        $netNodes[] = ['id' => $from, 'label' => $e['username'], 'pfp' => $e['pfpUrl'] ?: '',
                       'score' => $e['overall'], 'niche' => $e['niche_category'],
                       'url' => '/evaluate?u=' . urlencode($e['username']), 'evaluated' => true];
        $netHandlesAdded[$from] = true;
    }
    foreach (array_slice($e['top_mentions'], 0, 6) as $m) {
        $to = strtolower($m['handle'] ?? '');
        if (!$to || $to === $from) continue;
        // Add target node — evaluated (full) or ghost (dim)
        if (!isset($netHandlesAdded[$to])) {
            if (isset($evalByHandle[$to])) {
                $te = $evalByHandle[$to];
                $netNodes[] = ['id' => $to, 'label' => $te['username'], 'pfp' => $te['pfpUrl'] ?: '',
                               'score' => $te['overall'], 'niche' => $te['niche_category'],
                               'url' => '/evaluate?u=' . urlencode($te['username']), 'evaluated' => true];
            } else {
                $netNodes[] = ['id' => $to, 'label' => $to, 'pfp' => '',
                               'score' => 0, 'niche' => '', 'url' => '/evaluate?u=' . urlencode($to), 'evaluated' => false];
            }
            $netHandlesAdded[$to] = true;
        }
        // Edge: check if reverse also exists (mutual)
        $isMutual = isset($edges[$to][$from]);
        $netEdges[] = ['s' => $from, 't' => $to, 'mutual' => $isMutual, 'w' => ($m['count'] ?? 1)];
    }
}
// De-duplicate edges (keep highest weight, prefer mutual)
$edgeMap = [];
foreach ($netEdges as $edge) {
    $key = $edge['s'] < $edge['t'] ? "{$edge['s']}|{$edge['t']}" : "{$edge['t']}|{$edge['s']}";
    if (!isset($edgeMap[$key]) || $edge['w'] > $edgeMap[$key]['w']) $edgeMap[$key] = $edge;
    if ($edge['mutual']) $edgeMap[$key]['mutual'] = true;
}
$netEdges = array_values($edgeMap);

// Sort by score desc
usort($evals, fn($a, $b) => $b['overall'] <=> $a['overall']);

// Load AI clusters if available
$nsiClusters = null;
$nsiClustersFile = EVAL_DIR . 'nsi_clusters.json';
if (file_exists($nsiClustersFile)) {
    $raw = json_decode(file_get_contents($nsiClustersFile), true);
    if (!empty($raw['clusters'])) $nsiClusters = $raw;
}

// Build $niches: prefer AI clusters, fall back to niche_category grouping
$niches = [];        // [name => members[]]
$nicheDesc = [];     // [name => description string] (AI clusters only)

if ($nsiClusters) {
    foreach ($nsiClusters['clusters'] as $cl) {
        $name = $cl['name'];
        $nicheDesc[$name] = $cl['description'] ?? '';
        $members = [];
        foreach (($cl['members_data'] ?? []) as $m) {
            $handle = strtolower($m['username'] ?? '');
            if (isset($evalByHandle[$handle])) $members[] = $evalByHandle[$handle];
        }
        // Also match by username list if members_data missing
        if (empty($members)) {
            foreach (($cl['members'] ?? []) as $u) {
                $handle = strtolower($u);
                if (isset($evalByHandle[$handle])) $members[] = $evalByHandle[$handle];
            }
        }
        if (empty($members)) continue;
        usort($members, fn($a, $b) => $b['overall'] <=> $a['overall']);
        $niches[$name] = array_slice($members, 0, 5);
    }
} else {
    // Fallback: bucket by niche_category
    foreach ($evals as $e) {
        $n = trim($e['niche_category']);
        if (!$n) continue;
        $niches[$n][] = $e;
    }
    foreach ($niches as $n => $members) {
        usort($niches[$n], fn($a, $b) => $b['overall'] <=> $a['overall']);
        $niches[$n] = array_slice($niches[$n], 0, 5);
    }
    uasort($niches, fn($a, $b) => ($b[0]['overall'] ?? 0) <=> ($a[0]['overall'] ?? 0));
}

function scoreColor(float $s): string {
    if ($s >= 7) return '#0B7A42';
    if ($s >= 5) return 'rgba(10,12,18,0.55)';
    return '#B91C1C';
}
function barPct(float $s): int { return (int)(($s / 10) * 100); }
function relTime(?string $iso): string {
    if (!$iso) return '';
    $d = time() - strtotime($iso);
    if ($d < 60)    return $d . 's ago';
    if ($d < 3600)  return floor($d/60) . 'm ago';
    if ($d < 86400) return floor($d/3600) . 'h ago';
    return floor($d/86400) . 'd ago';
}
// Parse followers string like "11.5K", "1.2M" → numeric for display sorting
function followersNum(string $f): float {
    $f = strtoupper(trim($f));
    if (preg_match('/^([\d,.]+)([KM]?)$/', $f, $m)) {
        $n = (float)str_replace(',', '', $m[1]);
        if ($m[2] === 'K') return $n * 1000;
        if ($m[2] === 'M') return $n * 1000000;
        return $n;
    }
    return 0;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Leaderboard — aptum_</title>
<meta property="og:title" content="X Account Leaderboard — aptum_">
<meta property="og:description" content="Top-ranked X accounts evaluated by aptum AI. Sorted by score across 5 dimensions.">
<meta property="og:image" content="https://aptum.fun/eva.png">
<meta name="twitter:card" content="summary_large_image">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=IBM+Plex+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#F4F5F8;--bg1:#ECEEF3;--bg2:#FFFFFF;--bg3:#F8F9FB;
  --bdr:rgba(9,11,16,0.12);--bdr2:rgba(9,11,16,0.22);--bdr3:rgba(9,11,16,0.30);
  --ink:#0A0C12;--ink2:rgba(10,12,18,0.80);--ink3:rgba(10,12,18,0.62);
  --ink4:rgba(10,12,18,0.45);--ink5:rgba(10,12,18,0.20);
  --green:#0B7A42;--green-bg:rgba(11,122,66,0.08);--red:#B91C1C;
  --gold:#B8860B;--gold-bg:rgba(184,134,11,0.08);
  --mono:'IBM Plex Mono',monospace;--disp:'Syne',sans-serif;
}
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}

html,body{height:100%;scrollbar-width:none;}
html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}
body{background:var(--bg);color:var(--ink);font-family:var(--mono);-webkit-font-smoothing:antialiased;overflow-x:hidden;}
body::after{content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
  background-image:linear-gradient(rgba(9,11,16,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(9,11,16,.05) 1px,transparent 1px);
  background-size:48px 48px;}

/* TOP BAR */
.topbar{position:fixed;top:0;left:0;right:0;z-index:300;height:32px;background:var(--ink);display:flex;align-items:center;}
.tb-item{display:flex;align-items:center;gap:6px;padding:0 16px;height:32px;border-right:1px solid rgba(255,255,255,.07);font-size:9.5px;letter-spacing:.1em;color:rgba(255,255,255,.35);}
.tb-val{font-weight:700;color:rgba(255,255,255,.80);}
.tb-live-dot{width:5px;height:5px;border-radius:50%;background:#34D399;animation:blink 2.5s infinite;flex-shrink:0;}
#tb-clock{font-size:9.5px;color:rgba(255,255,255,.22);letter-spacing:.1em;margin-left:auto;padding-right:16px;}

/* NAVBAR */
.navbar{position:fixed;top:32px;left:0;right:0;z-index:200;height:52px;background:rgba(255,255,255,.94);backdrop-filter:blur(14px);border-bottom:1.5px solid var(--bdr2);display:flex;align-items:stretch;}
.brand{display:flex;align-items:center;gap:10px;padding:0 24px;border-right:1px solid var(--bdr2);text-decoration:none;}
.brand-name{font-family:var(--disp);font-size:15px;font-weight:800;letter-spacing:.16em;color:var(--ink);}
.brand-pill{font-size:8.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;padding:2px 9px;background:var(--ink);color:#fff;}
.nav-end{margin-left:auto;display:flex;align-items:center;padding:0 24px;gap:22px;}
.nav-a{font-size:9.5px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--ink4);text-decoration:none;transition:color .12s;}
.nav-a:hover{color:var(--ink);}
.nav-a.active{color:var(--ink);}

/* PAGE */
.page{position:relative;z-index:10;max-width:920px;margin:0 auto;padding:104px 18px 80px;overflow-x:hidden;}

/* HERO */
.hero{margin-bottom:32px;animation:fadeUp .4s both;}
.hero-eyebrow{font-size:9px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:var(--ink4);margin-bottom:10px;display:flex;align-items:center;gap:8px;}
.hero-line{width:28px;height:1px;background:var(--bdr2);}
.hero-title{font-family:var(--disp);font-size:clamp(22px,3vw,30px);font-weight:800;letter-spacing:-.01em;color:var(--ink);margin-bottom:5px;}
.hero-sub{font-size:11px;color:var(--ink4);letter-spacing:.04em;font-weight:500;}

/* PANEL */
.panel{background:var(--bg2);border:1px solid var(--bdr2);margin-bottom:12px;animation:fadeUp .4s both;}
.sec-hd{padding:9px 18px;border-bottom:1px solid var(--bdr2);display:flex;align-items:center;gap:10px;background:var(--bg1);}
.sec-num{font-size:8px;color:var(--ink4);letter-spacing:.1em;font-weight:700;border:1px solid var(--bdr);padding:2px 6px;background:var(--bg3);}
.sec-pipe{color:var(--bdr2);}
.sec-title{font-size:10.5px;color:var(--ink);letter-spacing:.12em;text-transform:uppercase;font-weight:700;}
.sec-sub{font-size:10px;color:var(--ink4);flex:1;font-weight:500;}
.sec-status{font-size:8.5px;color:var(--ink4);margin-left:auto;letter-spacing:.08em;font-weight:600;white-space:nowrap;}
.sec-status.ok{color:var(--green);}

/* ── LEADERBOARD TABLE ── */
.lb-table{width:100%;border-collapse:collapse;}
.lb-head th{padding:8px 14px;font-size:8px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink4);font-weight:700;text-align:left;border-bottom:1px solid var(--bdr2);background:var(--bg1);white-space:nowrap;}
.lb-head th.r{text-align:right;}
.lb-row{border-bottom:1px solid var(--bdr);transition:background .1s;cursor:pointer;}
.lb-row:last-child{border-bottom:none;}
.lb-row:hover{background:var(--bg3);}
.lb-row td{padding:11px 14px;vertical-align:middle;}
.lb-row td.r{text-align:right;}

/* rank cell */
.lb-rank{font-size:11px;font-weight:700;color:var(--ink4);letter-spacing:.06em;min-width:32px;text-align:center;}
.lb-rank.gold{color:#B8860B;}
.lb-rank.silver{color:rgba(10,12,18,0.55);}
.lb-rank.bronze{color:#7C4A00;}

/* account cell */
.lb-acc{display:flex;align-items:center;gap:10px;min-width:160px;}
.lb-pfp{width:32px;height:32px;border-radius:50%;object-fit:cover;background:var(--bg3);flex-shrink:0;border:1px solid var(--bdr);}
.lb-handle{font-size:12px;font-weight:700;color:var(--ink);letter-spacing:.01em;}
.lb-niche{font-size:8.5px;color:var(--ink4);letter-spacing:.04em;margin-top:2px;}

/* score cell */
.lb-score-wrap{display:flex;align-items:center;gap:8px;min-width:110px;}
.lb-score-num{font-family:var(--disp);font-size:18px;font-weight:800;line-height:1;min-width:38px;text-align:right;}
.lb-bar-col{flex:1;}
.lb-bar{height:3px;background:var(--bdr);width:100%;min-width:48px;}
.lb-bar-fill{height:100%;}

/* grade cell */
.lb-grade{font-size:9.5px;font-weight:700;letter-spacing:.1em;color:var(--ink3);white-space:nowrap;}

/* followers cell */
.lb-followers{font-size:11.5px;font-weight:600;color:var(--ink2);white-space:nowrap;text-align:right;}

/* ER cell */
.lb-er{font-size:10px;font-weight:600;color:var(--ink3);white-space:nowrap;text-align:right;}

/* time cell */
.lb-time{font-size:9px;color:var(--ink4);white-space:nowrap;text-align:right;}

/* top-3 medal rows */
.lb-row.rank-1{background:rgba(184,134,11,0.04);}
.lb-row.rank-2{background:rgba(160,160,160,0.04);}
.lb-row.rank-3{background:rgba(124,74,0,0.04);}

/* ── NICHE CHART ── */
.niche-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1px;background:var(--bdr);border:1px solid var(--bdr);}
.niche-card{background:var(--bg2);padding:0;transition:background .12s;}
.niche-card:hover{background:var(--bg3);}
.niche-card-hd{padding:10px 14px;border-bottom:1px solid var(--bdr);display:flex;align-items:center;justify-content:space-between;gap:10px;}
.niche-name{font-size:9.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--ink);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.niche-count{font-size:8px;color:var(--ink4);letter-spacing:.06em;white-space:nowrap;}
.niche-members{display:flex;flex-direction:column;}
.niche-member{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--bdr);text-decoration:none;}
.niche-member:last-child{border-bottom:none;}
.niche-member:hover{background:rgba(9,11,16,.04);}
.niche-m-pfp{width:22px;height:22px;border-radius:50%;object-fit:cover;background:var(--bg3);flex-shrink:0;border:1px solid var(--bdr);}
.niche-m-handle{font-size:10.5px;font-weight:700;color:var(--ink);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.niche-m-score{font-size:10.5px;font-weight:700;white-space:nowrap;}
.niche-m-bar-wrap{width:44px;height:3px;background:var(--bdr);flex-shrink:0;}
.niche-m-bar{height:100%;}

/* ── NICHE BAR CHART ── */
.niche-bar-chart{display:flex;flex-direction:column;gap:0;}
.nbc-row{display:flex;align-items:center;gap:12px;padding:10px 18px;border-bottom:1px solid var(--bdr);transition:background .1s;}
.nbc-row:last-child{border-bottom:none;}
.nbc-row:hover{background:var(--bg3);}
.nbc-label{font-size:9.5px;font-weight:700;color:var(--ink);letter-spacing:.04em;width:200px;flex-shrink:0;overflow:hidden;}
.nbc-label-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.nbc-label-desc{font-size:8.5px;color:var(--ink4);font-weight:400;letter-spacing:.01em;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.nbc-bar-wrap{flex:1;height:6px;background:var(--bdr);position:relative;}
.nbc-bar{height:100%;transition:width .4s;}
.nbc-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;min-width:100px;justify-content:flex-end;}
.nbc-avg{font-size:10px;font-weight:700;color:var(--ink2);}
.nbc-count{font-size:9px;color:var(--ink4);}

/* empty */
.empty{padding:32px 18px;text-align:center;font-size:12px;color:var(--ink4);}

/* ── NETWORK (canvas, no extra CSS needed) ── */
.hub-count{font-size:9.5px;font-weight:700;color:var(--ink3);min-width:28px;text-align:right;}

/* ── RESPONSIVE ── */
@media(max-width:640px){
  .tb-item:nth-child(n+3){display:none;}
  #tb-clock{display:none;}
  .nav-end{padding:0 10px;gap:10px;}
  .brand{padding:0 14px;}
  .brand-name{font-size:13px;}
  .page{padding:84px 10px 52px;}
  .hero{margin-bottom:20px;}
  .sec-sub{display:none;}
  /* Table: hide grade/followers/er/scanned on mobile, show rank+account+score */
  .lb-table th:nth-child(4),.lb-table td:nth-child(4),
  .lb-table th:nth-child(5),.lb-table td:nth-child(5),
  .lb-table th:nth-child(6),.lb-table td:nth-child(6),
  .lb-table th:nth-child(7),.lb-table td:nth-child(7){display:none;}
  .lb-row td{padding:9px 10px;}
  .lb-row th{padding:7px 10px;}
  /* NSI bar chart label */
  .nbc-label{width:130px;}
  /* Follow cards: 1-col */
  .niche-grid{grid-template-columns:1fr;}
}
@media(max-width:420px){
  .brand-pill{display:none;}
  .nav-a{font-size:8.5px;letter-spacing:.08em;}
  .nbc-label{width:100px;}
  .lb-score-num{font-size:15px;min-width:28px;}
  .lb-acc{gap:7px;}
  .lb-pfp{width:26px;height:26px;}
  .lb-handle{font-size:11px;}
}
</style>
</head>
<body>

<div class="topbar">
  <div class="tb-item"><span class="tb-live-dot"></span><span>LEADERBOARD</span><span class="tb-val"><?= count($evals) ?> ACCOUNTS</span></div>
  <div class="tb-item"><span>NICHES</span><span class="tb-val"><?= count($niches) ?></span></div>
  <span id="tb-clock"></span>
</div>

<div class="navbar">
  <a class="brand" href="/evaluate">
    <span class="brand-name">APTUM</span>
    <span class="brand-pill">LB</span>
  </a>
  <div class="nav-end">
    <a href="/evaluate" class="nav-a">Evaluate</a>
    <a href="/leaderboard" class="nav-a active">Leaderboard</a>
    <a href="/nsi" class="nav-a">NSI</a>
    <a href="https://x.com/aptum_" target="_blank" class="nav-a">@aptum_</a>
  </div>
</div>

<div class="page">

  <div class="hero">
    <div class="hero-eyebrow"><span class="hero-line"></span>AI-RANKED ACCOUNTS<span class="hero-line"></span></div>
    <div class="hero-title">Leaderboard</div>
    <div class="hero-sub">Top X accounts ranked by aptum AI score across 5 dimensions.</div>
  </div>

  <?php if (empty($evals)): ?>
  <div class="panel"><div class="empty">No evaluations yet. <a href="/evaluate" style="color:var(--ink);font-weight:700;">Run one →</a></div></div>
  <?php else: ?>

  <!-- ── §01 RANKINGS ── -->
  <div class="panel">
    <div class="sec-hd">
      <span class="sec-num">01</span><span class="sec-pipe">|</span>
      <span class="sec-title">Rankings</span>
      <span class="sec-sub">Sorted by overall AI score</span>
      <span class="sec-status ok"><?= count($evals) ?> accounts</span>
    </div>
    <div style="overflow-x:auto;">
    <table class="lb-table">
      <thead class="lb-head">
        <tr>
          <th style="width:42px;text-align:center;">#</th>
          <th>Account</th>
          <th>Score</th>
          <th>Grade</th>
          <th class="r">Followers</th>
          <th class="r">ER</th>
          <th class="r">Scanned</th>
        </tr>
      </thead>
      <tbody>
        <?php foreach ($evals as $i => $e):
          $rank = $i + 1;
          $rankClass = $rank === 1 ? 'rank-1' : ($rank === 2 ? 'rank-2' : ($rank === 3 ? 'rank-3' : ''));
          $medalClass = $rank === 1 ? 'gold' : ($rank === 2 ? 'silver' : ($rank === 3 ? 'bronze' : ''));
          $sc = scoreColor($e['overall']);
          $pct = barPct($e['overall']);
          $medal = $rank === 1 ? '①' : ($rank === 2 ? '②' : ($rank === 3 ? '③' : $rank));
        ?>
        <tr class="lb-row <?= $rankClass ?>" onclick="location.href='/evaluate?u=<?= urlencode($e['username']) ?>'">
          <td><div class="lb-rank <?= $medalClass ?>"><?= $medal ?></div></td>
          <td>
            <div class="lb-acc">
              <img class="lb-pfp" src="<?= htmlspecialchars($e['pfpUrl'] ?: 'https://unavatar.io/x/'.urlencode($e['username'])) ?>"
                   onerror="this.src='https://unavatar.io/x/<?= urlencode($e['username']) ?>';this.onerror=null" alt="">
              <div>
                <div class="lb-handle">@<?= htmlspecialchars($e['username']) ?></div>
                <?php if ($e['niche']): ?><div class="lb-niche"><?= htmlspecialchars($e['niche']) ?></div><?php endif; ?>
              </div>
            </div>
          </td>
          <td>
            <div class="lb-score-wrap">
              <div class="lb-score-num" style="color:<?= $sc ?>"><?= number_format($e['overall'], 1) ?></div>
              <div class="lb-bar-col">
                <div class="lb-bar"><div class="lb-bar-fill" style="width:<?= $pct ?>%;background:<?= $sc ?>"></div></div>
              </div>
            </div>
          </td>
          <td><span class="lb-grade"><?= htmlspecialchars($e['grade']) ?></span></td>
          <td class="r"><span class="lb-followers"><?= htmlspecialchars($e['followers']) ?></span></td>
          <td class="r"><span class="lb-er"><?= htmlspecialchars($e['er_pct']) ?></span></td>
          <td class="r"><span class="lb-time"><?= relTime($e['scannedAt']) ?></span></td>
        </tr>
        <?php endforeach; ?>
      </tbody>
    </table>
    </div>
  </div>

  <!-- ── §02 NICHE BAR CHART ── -->
  <?php if (count($niches) >= 2):
    // Compute avg score per niche
    $nicheStats = [];
    foreach ($niches as $n => $members) {
        $avg = array_sum(array_column($members, 'overall')) / count($members);
        $nicheStats[$n] = ['avg' => $avg, 'count' => count($members), 'top' => $members[0]];
    }
    uasort($nicheStats, fn($a, $b) => $b['avg'] <=> $a['avg']);
    $maxAvg = max(array_column($nicheStats, 'avg'));
  ?>
  <div class="panel">
    <div class="sec-hd">
      <span class="sec-num">02</span><span class="sec-pipe">|</span>
      <span class="sec-title">Niche Strength Index</span>
      <span class="sec-sub"><?= $nsiClusters ? 'AI-clustered · avg score per group' : 'Average score by content category' ?></span>
      <a href="/nsi" class="sec-status" style="color:var(--ink3);text-decoration:none;letter-spacing:.08em;font-size:8.5px;">Full NSI →</a>
    </div>
    <div class="niche-bar-chart">
      <?php foreach ($nicheStats as $n => $ns):
        $pct  = $maxAvg > 0 ? round(($ns['avg'] / $maxAvg) * 100) : 0;
        $sc   = scoreColor($ns['avg']);
        $desc = $nicheDesc[$n] ?? '';
      ?>
      <div class="nbc-row" onclick="location.href='/evaluate?u=<?= urlencode($ns['top']['username']) ?>'" style="cursor:pointer;">
        <div class="nbc-label">
          <div class="nbc-label-name"><?= htmlspecialchars($n) ?></div>
          <?php if ($desc): ?><div class="nbc-label-desc"><?= htmlspecialchars($desc) ?></div><?php endif; ?>
        </div>
        <div class="nbc-bar-wrap">
          <div class="nbc-bar" style="width:<?= $pct ?>%;background:<?= $sc ?>;"></div>
        </div>
        <div class="nbc-meta">
          <span class="nbc-avg" style="color:<?= $sc ?>"><?= number_format($ns['avg'], 1) ?></span>
          <span class="nbc-count"><?= $ns['count'] ?> acct<?= $ns['count'] !== 1 ? 's' : '' ?></span>
        </div>
      </div>
      <?php endforeach; ?>
    </div>
  </div>
  <?php endif; ?>

  <!-- ── §03 BEST TO FOLLOW BY NICHE ── -->
  <div class="panel">
    <div class="sec-hd">
      <span class="sec-num">03</span><span class="sec-pipe">|</span>
      <span class="sec-title">Best To Follow</span>
      <span class="sec-sub"><?= $nsiClusters ? 'Top accounts per AI cluster' : 'Top accounts per content niche' ?></span>
    </div>
    <?php if (empty($niches)): ?>
    <div class="empty">No niche data yet.</div>
    <?php else: ?>
    <div class="niche-grid">
      <?php foreach ($niches as $niche => $members): ?>
      <div class="niche-card">
        <div class="niche-card-hd">
          <span class="niche-name"><?= htmlspecialchars($niche) ?></span>
          <span class="niche-count"><?= count($members) ?> ranked</span>
        </div>
        <div class="niche-members">
          <?php foreach ($members as $j => $m):
            $msc = scoreColor($m['overall']);
            $mpct = barPct($m['overall']);
          ?>
          <a class="niche-member" href="/evaluate?u=<?= urlencode($m['username']) ?>">
            <img class="niche-m-pfp" src="<?= htmlspecialchars($m['pfpUrl'] ?: 'https://unavatar.io/x/'.urlencode($m['username'])) ?>"
                 onerror="this.src='https://unavatar.io/x/<?= urlencode($m['username']) ?>';this.onerror=null" alt="">
            <span class="niche-m-handle">@<?= htmlspecialchars($m['username']) ?></span>
            <?php if ($m['followers']): ?><span style="font-size:9px;color:var(--ink4);white-space:nowrap;"><?= htmlspecialchars($m['followers']) ?></span><?php endif; ?>
            <span class="niche-m-score" style="color:<?= $msc ?>"><?= number_format($m['overall'], 1) ?></span>
            <div class="niche-m-bar-wrap"><div class="niche-m-bar" style="width:<?= $mpct ?>%;background:<?= $msc ?>"></div></div>
          </a>
          <?php endforeach; ?>
        </div>
      </div>
      <?php endforeach; ?>
    </div>
    <?php endif; ?>
  </div>

  <!-- ── §04 ENGAGEMENT NETWORK (3D WebGL) ── -->
  <div class="panel">
    <div class="sec-hd">
      <span class="sec-num">04</span><span class="sec-pipe">|</span>
      <span class="sec-title">Engagement Network</span>
      <span class="sec-sub">3D mention graph — drag to orbit · scroll to zoom</span>
      <?php $evaledInNet = count(array_filter($netNodes, fn($n) => $n['evaluated'])); ?>
      <span class="sec-status <?= !empty($netNodes) ? 'ok' : '' ?>"><?= !empty($netNodes) ? $evaledInNet.' node'.($evaledInNet!==1?'s':'').', '.count($netEdges).' edge'.(count($netEdges)!==1?'s':'') : 'AWAITING DATA' ?></span>
    </div>

    <?php if (empty($netEdges)): ?>
    <div class="empty" style="padding:40px 18px;">
      <div style="font-size:11px;font-weight:700;color:var(--ink3);margin-bottom:8px;">No engagement data yet</div>
      <div style="font-size:10px;color:var(--ink4);max-width:360px;margin:0 auto;line-height:1.7;">Re-evaluate accounts to populate mention &amp; reply graphs.</div>
    </div>
    <?php else: ?>

    <?php $netHeight = max(520, min(1400, 380 + count($netNodes) * 28)); ?>
    <div id="ng-wrap" style="position:relative;width:100%;height:<?= $netHeight ?>px;background:#07080F;overflow:hidden;border-top:1px solid rgba(255,255,255,0.05);">
      <canvas id="ng-canvas" style="display:block;width:100%;height:100%;outline:none;cursor:grab;"></canvas>

      <!-- Legend -->
      <div style="position:absolute;top:14px;left:16px;display:flex;flex-direction:column;gap:6px;pointer-events:none;">
        <div style="display:flex;align-items:center;gap:8px;font-size:7.5px;color:rgba(255,255,255,.4);letter-spacing:.13em;font-family:'IBM Plex Mono',monospace;">
          <span style="width:24px;height:2.5px;background:#00FF99;box-shadow:0 0 6px #00FF99;display:inline-block;border-radius:2px;flex-shrink:0;"></span>MUTUAL
        </div>
        <div style="display:flex;align-items:center;gap:8px;font-size:7.5px;color:rgba(255,255,255,.4);letter-spacing:.13em;font-family:'IBM Plex Mono',monospace;">
          <span style="width:24px;height:1px;background:rgba(255,255,255,.3);display:inline-block;flex-shrink:0;"></span>ONE-WAY
        </div>
        <div style="display:flex;align-items:center;gap:8px;font-size:7.5px;color:rgba(255,255,255,.22);letter-spacing:.13em;font-family:'IBM Plex Mono',monospace;">
          <span style="width:10px;height:10px;border:1px dashed rgba(255,255,255,.25);border-radius:50%;display:inline-block;flex-shrink:0;"></span>NOT EVALUATED
        </div>
      </div>

      <!-- Controls -->
      <div style="position:absolute;top:14px;right:14px;display:flex;flex-direction:column;gap:5px;z-index:10;">
        <?php $btnStyle = 'width:30px;height:30px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);color:rgba(255,255,255,0.55);cursor:pointer;display:flex;align-items:center;justify-content:center;font-family:monospace;transition:background .12s;'; ?>
        <button id="ng-fit" title="Fit to view" style="<?= $btnStyle ?>font-size:14px;" onmouseover="this.style.background='rgba(255,255,255,0.12)'" onmouseout="this.style.background='rgba(255,255,255,0.06)'">⊡</button>
        <button id="ng-zi"  title="Zoom in"     style="<?= $btnStyle ?>font-size:19px;line-height:1;" onmouseover="this.style.background='rgba(255,255,255,0.12)'" onmouseout="this.style.background='rgba(255,255,255,0.06)'">+</button>
        <button id="ng-zo"  title="Zoom out"    style="<?= $btnStyle ?>font-size:19px;line-height:1;" onmouseover="this.style.background='rgba(255,255,255,0.12)'" onmouseout="this.style.background='rgba(255,255,255,0.06)'">−</button>
      </div>

      <!-- Hint -->
      <div style="position:absolute;bottom:12px;left:50%;transform:translateX(-50%);font-size:7.5px;color:rgba(255,255,255,.13);letter-spacing:.13em;font-family:'IBM Plex Mono',monospace;pointer-events:none;white-space:nowrap;">
        SCROLL TO ZOOM &nbsp;·&nbsp; DRAG TO PAN &nbsp;·&nbsp; CLICK NODE TO EVALUATE
      </div>

      <!-- Tooltip -->
      <div id="ng-tip" style="position:absolute;pointer-events:none;display:none;background:rgba(7,8,15,0.94);color:rgba(255,255,255,.9);padding:10px 14px;font-size:9.5px;font-family:'IBM Plex Mono',monospace;line-height:1.9;z-index:20;border:1px solid rgba(255,255,255,.1);max-width:200px;"></div>
    </div>

    <script>
    (function(){
      const NODES = <?= json_encode(array_values($netNodes)) ?>;
      const EDGES = <?= json_encode(array_values($netEdges)) ?>;
      if (!NODES.length) return;

      const wrap   = document.getElementById('ng-wrap');
      const canvas = document.getElementById('ng-canvas');
      const tip    = document.getElementById('ng-tip');
      const DPR    = Math.min(devicePixelRatio, 2);
      const W = wrap.offsetWidth, H = <?= $netHeight ?>;
      canvas.width  = W * DPR;
      canvas.height = H * DPR;
      const ctx = canvas.getContext('2d');
      ctx.scale(DPR, DPR);

      // ── Color palette
      const NCM = {};
      const PAL = ['#FF3FA4','#00FF99','#FBBC04','#A78BFA','#38C4F8','#FB923C','#34D399','#F87171','#60A5FA','#E879F9','#2DD4BF','#FBBF24'];
      let ci = 0;
      function nc(niche) {
        if (!niche) return '#3A5070';
        if (!NCM[niche]) NCM[niche] = PAL[ci++ % PAL.length];
        return NCM[niche];
      }
      function rgba(hex, a) {
        const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
        return `rgba(${r},${g},${b},${a})`;
      }

      // ── View transform
      let vx=0, vy=0, vz=1;
      function toWorld(cx,cy) { return {x:cx/vz-vx, y:cy/vz-vy}; }

      // ── Assign radii and colors first
      const nodeMap = {};
      NODES.forEach(n => {
        n.color = n.evaluated ? nc(n.niche) : '#3A5070';
        n.r = n.evaluated ? 15 + Math.min(n.score,10)*1.7 : 10;
        n.vx = 0; n.vy = 0;
        n.img = null;
        if (n.pfp && n.evaluated) {
          const img = new Image();
          img.crossOrigin = 'anonymous';
          img.onload = () => { n.img = img; };
          img.src = n.pfp;
        }
        nodeMap[n.id] = n;
      });

      // ── Structured initial placement: group by niche, place clusters in a ring
      (function initPositions() {
        const groups = {};
        NODES.forEach(n => {
          const k = n.evaluated ? (n.niche||'__eval__') : '__ghost__';
          if (!groups[k]) groups[k] = [];
          groups[k].push(n);
        });
        const keys = Object.keys(groups);
        const clusterR = Math.min(W,H) * 0.30;
        keys.forEach((k,ki) => {
          const angle = (ki/keys.length)*Math.PI*2 - Math.PI/2;
          const cx = W/2 + Math.cos(angle)*clusterR;
          const cy = H/2 + Math.sin(angle)*clusterR;
          const members = groups[k];
          members.forEach((n,mi) => {
            if (members.length===1) {
              n.x=cx; n.y=cy;
            } else {
              const a2 = (mi/members.length)*Math.PI*2;
              const r2 = Math.min(50, (n.r+10)*members.length*0.4);
              n.x = cx + Math.cos(a2)*r2;
              n.y = cy + Math.sin(a2)*r2;
            }
          });
        });
      })();

      // ── Force simulation with collision resolution
      const REPULSE=5000, SPRING_K=0.016, SPRING_L=0, GRAVITY=0.008, DAMP=0.82;
      let settled=false, ticks=0;

      function simulate() {
        if (settled) return;
        const cx=W/2, cy=H/2;

        // Center gravity
        NODES.forEach(a => {
          a.vx += (cx-a.x)*GRAVITY;
          a.vy += (cy-a.y)*GRAVITY;
        });

        // Repulsion — scale by summed radii so bigger nodes push more
        for (let i=0;i<NODES.length;i++) {
          const a=NODES[i];
          for (let j=i+1;j<NODES.length;j++) {
            const b=NODES[j];
            const dx=a.x-b.x, dy=a.y-b.y;
            const dist2=dx*dx+dy*dy+0.01;
            const minSep=a.r+b.r+18;
            const f=REPULSE*(minSep*minSep)/dist2;
            const dist=Math.sqrt(dist2);
            a.vx+=dx/dist*f*0.001; a.vy+=dy/dist*f*0.001;
            b.vx-=dx/dist*f*0.001; b.vy-=dy/dist*f*0.001;
          }
        }

        // Spring attraction along edges — natural length = sum of radii + gap
        EDGES.forEach(e => {
          const a=nodeMap[e.s], b=nodeMap[e.t]; if(!a||!b) return;
          const dx=b.x-a.x, dy=b.y-a.y;
          const d=Math.sqrt(dx*dx+dy*dy)||1;
          const natLen = a.r+b.r + (SPRING_L||160);
          const f=(d-natLen)*SPRING_K;
          const fx=dx/d*f, fy=dy/d*f;
          a.vx+=fx; a.vy+=fy; b.vx-=fx; b.vy-=fy;
        });

        // Integrate + boundary (soft padding = node radius + 24px)
        NODES.forEach(n => {
          n.vx*=DAMP; n.vy*=DAMP;
          n.x+=n.vx; n.y+=n.vy;
          const pad=n.r+24;
          n.x=Math.max(pad, Math.min(W-pad, n.x));
          n.y=Math.max(pad, Math.min(H-pad, n.y));
        });

        // ── Collision resolution: push overlapping nodes apart
        for (let i=0;i<NODES.length;i++) {
          for (let j=i+1;j<NODES.length;j++) {
            const a=NODES[i], b=NODES[j];
            const dx=a.x-b.x, dy=a.y-b.y;
            const dist=Math.sqrt(dx*dx+dy*dy)||0.01;
            const minD=a.r+b.r+12;
            if (dist<minD) {
              const push=(minD-dist)*0.52;
              const nx=dx/dist, ny=dy/dist;
              a.x+=nx*push; a.y+=ny*push;
              b.x-=nx*push; b.y-=ny*push;
              // Dampen velocity along collision axis
              const vRelN=(a.vx-b.vx)*nx+(a.vy-b.vy)*ny;
              if (vRelN<0) {
                a.vx-=0.45*vRelN*nx; a.vy-=0.45*vRelN*ny;
                b.vx+=0.45*vRelN*nx; b.vy+=0.45*vRelN*ny;
              }
            }
          }
        }

        let maxV=0;
        NODES.forEach(n=>{ maxV=Math.max(maxV,Math.abs(n.vx)+Math.abs(n.vy)); });
        ticks++;
        if (ticks>450 && maxV<0.15) settled=true;
      }

      // ── Draw
      let hoveredNode=null;
      function draw() {
        ctx.clearRect(0,0,W,H);
        ctx.fillStyle='#07080F'; ctx.fillRect(0,0,W,H);

        // Subtle dot grid (fixed, not affected by pan/zoom)
        ctx.fillStyle='rgba(255,255,255,0.02)';
        for (let gx=0;gx<W;gx+=48) for(let gy=0;gy<H;gy+=48) {
          ctx.beginPath(); ctx.arc(gx,gy,1,0,Math.PI*2); ctx.fill();
        }

        ctx.save();
        ctx.translate(vx*vz, vy*vz);
        ctx.scale(vz,vz);

        // ── Edges (draw before nodes so nodes sit on top)
        EDGES.forEach(e => {
          const a=nodeMap[e.s],b=nodeMap[e.t]; if(!a||!b) return;
          const hi=hoveredNode===a||hoveredNode===b;
          if (e.mutual) {
            ctx.strokeStyle=rgba('#00FF99',hi?0.3:0.1); ctx.lineWidth=8;
            ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
            ctx.strokeStyle=rgba('#00FF99',hi?0.92:0.6); ctx.lineWidth=2;
            ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
          } else {
            ctx.strokeStyle=rgba(a.color,hi?0.6:0.25); ctx.lineWidth=1.2;
            ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
          }
        });

        // ── Nodes
        NODES.forEach(n => {
          const hov=n===hoveredNode;
          const r=n.r*(hov?1.08:1);

          // Radial glow
          if (n.evaluated) {
            const g=ctx.createRadialGradient(n.x,n.y,r*0.4,n.x,n.y,r*2.8);
            g.addColorStop(0,rgba(n.color,hov?0.28:0.16));
            g.addColorStop(1,'rgba(0,0,0,0)');
            ctx.fillStyle=g;
            ctx.beginPath(); ctx.arc(n.x,n.y,r*2.8,0,Math.PI*2); ctx.fill();
          }

          // Clip + fill node circle
          ctx.save();
          ctx.beginPath(); ctx.arc(n.x,n.y,r,0,Math.PI*2); ctx.clip();
          if (n.img) {
            ctx.drawImage(n.img,n.x-r,n.y-r,r*2,r*2);
            ctx.fillStyle=rgba(n.color,0.15); ctx.fillRect(n.x-r,n.y-r,r*2,r*2);
          } else {
            ctx.fillStyle=n.evaluated?rgba(n.color,0.22):rgba(n.color,0.10);
            ctx.fillRect(n.x-r,n.y-r,r*2,r*2);
            ctx.fillStyle=n.evaluated?n.color:rgba(n.color,0.55);
            ctx.font=`bold ${Math.round(r*0.72)}px "IBM Plex Mono",monospace`;
            ctx.textAlign='center'; ctx.textBaseline='middle';
            ctx.fillText((n.label[0]||'?').toUpperCase(),n.x,n.y+1);
          }
          ctx.restore();

          // Border ring
          ctx.beginPath(); ctx.arc(n.x,n.y,r,0,Math.PI*2);
          ctx.strokeStyle=n.evaluated?n.color:rgba(n.color,0.45);
          ctx.lineWidth=hov?3:(n.evaluated?2.2:1.4); ctx.stroke();

          // Ghost dashed outer ring
          if (!n.evaluated) {
            ctx.beginPath(); ctx.arc(n.x,n.y,r+5,0,Math.PI*2);
            ctx.strokeStyle=rgba(n.color,0.18); ctx.lineWidth=1;
            ctx.setLineDash([3,4]); ctx.stroke(); ctx.setLineDash([]);
          }

          // Label — positioned to avoid canvas edges; right side by default
          const lx=n.x+r+8, ly=n.y;
          ctx.shadowColor='#07080F'; ctx.shadowBlur=5;
          ctx.font=`${hov?'600':'500'} ${n.evaluated?10.5:8.5}px "IBM Plex Mono",monospace`;
          ctx.textAlign='left'; ctx.textBaseline='middle';
          ctx.fillStyle=hov?'#fff':(n.evaluated?rgba('#fff',0.80):rgba('#fff',0.28));
          ctx.fillText('@'+n.label, lx, ly);
          if (hov && n.evaluated) {
            ctx.font='600 9px "IBM Plex Mono",monospace';
            ctx.fillStyle=n.color;
            ctx.fillText(n.score.toFixed(1), lx, ly+13);
          }
          ctx.shadowBlur=0;
        });

        ctx.restore();
      }

      // ── Hit test in world space
      function getNode(mx,my) {
        const w=toWorld(mx,my);
        for (const n of NODES)
          if (Math.hypot(w.x-n.x,w.y-n.y)<n.r+8) return n;
        return null;
      }

      // ── Pan
      let isPanning=false,panSX=0,panSY=0,panVX0=0,panVY0=0;
      canvas.addEventListener('mousedown',ev=>{
        if(getNode(ev.offsetX,ev.offsetY)) return;
        isPanning=true; canvas.style.cursor='grabbing';
        panSX=ev.clientX; panSY=ev.clientY; panVX0=vx; panVY0=vy;
      });
      window.addEventListener('mouseup',()=>{ isPanning=false; canvas.style.cursor='grab'; });
      canvas.addEventListener('mousemove',ev=>{
        if(isPanning){ vx=panVX0+(ev.clientX-panSX)/vz; vy=panVY0+(ev.clientY-panSY)/vz; return; }
        const node=getNode(ev.offsetX,ev.offsetY);
        hoveredNode=node; canvas.style.cursor=node?'pointer':'grab';
        if(node){
          tip.innerHTML=node.evaluated
            ?`<b>@${node.label}</b><br><span style="opacity:.5;font-size:8.5px">${node.niche||'—'}</span><br><span style="color:${node.color};font-weight:700">Score ${node.score.toFixed(1)}</span>`
            :`<b>@${node.label}</b><br><span style="opacity:.38">not evaluated</span><br><span style="opacity:.3;font-size:8px;letter-spacing:.1em">CLICK TO EVALUATE →</span>`;
          tip.style.left=Math.min(ev.offsetX+16,W-185)+'px';
          tip.style.top=Math.max(ev.offsetY-70,8)+'px';
          tip.style.display='block';
        } else tip.style.display='none';
      });
      canvas.addEventListener('mouseleave',()=>{ hoveredNode=null; tip.style.display='none'; isPanning=false; });
      canvas.addEventListener('click',ev=>{ const n=getNode(ev.offsetX,ev.offsetY); if(n) location.href=n.url; });

      // ── Scroll zoom toward cursor
      canvas.addEventListener('wheel',ev=>{
        ev.preventDefault();
        const f=ev.deltaY<0?1.10:0.91;
        const wx=ev.offsetX/vz-vx, wy=ev.offsetY/vz-vy;
        vz=Math.max(0.2,Math.min(5,vz*f));
        vx=ev.offsetX/vz-wx; vy=ev.offsetY/vz-wy;
      },{passive:false});

      // ── Buttons
      function doZoom(f) {
        const wx=W/(2*vz)-vx/1, wy=H/(2*vz)-vy/1;
        const mx=W/2,my=H/2;
        const px=mx/vz-vx, py=my/vz-vy;
        vz=Math.max(0.2,Math.min(5,vz*f));
        vx=mx/vz-px; vy=my/vz-py;
      }
      document.getElementById('ng-zi').addEventListener('click',()=>doZoom(1.25));
      document.getElementById('ng-zo').addEventListener('click',()=>doZoom(0.80));
      document.getElementById('ng-fit').addEventListener('click',()=>{
        if(!NODES.length) return;
        let x0=Infinity,x1=-Infinity,y0=Infinity,y1=-Infinity;
        NODES.forEach(n=>{ x0=Math.min(x0,n.x-n.r); x1=Math.max(x1,n.x+n.r); y0=Math.min(y0,n.y-n.r); y1=Math.max(y1,n.y+n.r); });
        const pad=44;
        vz=Math.max(0.2,Math.min(3,Math.min(W/(x1-x0+pad*2),H/(y1-y0+pad*2))));
        vx=W/(2*vz)-(x0+x1)/2; vy=H/(2*vz)-(y0+y1)/2;
      });

      // ── Loop
      function loop(){ simulate(); draw(); requestAnimationFrame(loop); }
      loop();

      window.addEventListener('resize',()=>{
        const nW=wrap.offsetWidth;
        canvas.width=nW*DPR; canvas.height=H*DPR;
        ctx.scale(DPR,DPR);
      });
    })();
    </script>
    <?php endif; ?>
  </div>

  <?php endif; ?>

</div>

<script>
(function(){
  function clock(){var d=new Date(),el=document.getElementById('tb-clock');
    if(el)el.textContent=d.toLocaleTimeString('en-US',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});}
  clock();setInterval(clock,1000);
})();
</script>
</body>
</html>
