<!DOCTYPE html>
<?php
/* Owner-only visitor analytics console.
   Vars: $baseUrl, $assetsUrl, $user, $summary, $countries, $daily, $hourly, $devices,
         $browsers, $systems, $refTypes, $referrers, $campaigns, $topPages, $entryPages,
         $exitPages, $liveNow, $visitors, $vSettings, $geoSettings, $geoStatus, $showBots */
if (!isset($baseUrl)) $baseUrl = '';
if (!isset($assetsUrl)) $assetsUrl = rtrim((string) $baseUrl, '/') . '/assets';
$V = \PpPhp\Core\Analytics\VisitorLog::class;
$csrf = \PpPhp\Core\Security::CSRF()->Token();
$u = static fn(string $p): string => rtrim((string) $baseUrl, '/') . $p;
foreach (['summary','countries','daily','hourly','devices','browsers','systems','refTypes',
          'referrers','campaigns','topPages','entryPages','exitPages','liveNow','visitors',
          'vSettings','geoSettings','geoStatus','channels','apiSummary','apiClients','apiActions','sites',
          'servers','networks'] as $__v) {
    if (!isset($$__v) || !is_array($$__v)) $$__v = [];
}
$serverLabel = (string) ($serverLabel ?? '');
$collectorUrl = (string) ($collectorUrl ?? '');
$analyticsSnippetBase = (string) ($analyticsSnippetBase ?? '');
$geoCronUrl = (string) ($geoCronUrl ?? '');
$chanIcon = static fn(string $c): string => [
    'console' => 'bi-window-desktop', 'marketing' => 'bi-megaphone', 'community' => 'bi-people',
    'license' => 'bi-key', 'update' => 'bi-arrow-repeat', 'app' => 'bi-box', 'api' => 'bi-plug',
][$c] ?? 'bi-diagram-3';
$maxDay = 0; foreach ($daily as $d) $maxDay = max($maxDay, (int) $d['visits']);
$maxHour = 0; foreach ($hourly as $h) $maxHour = max($maxHour, (int) $h);

$ago = static function (string $ts): string {
    $s = time() - (strtotime($ts) ?: time());
    if ($s < 60) return 'just now';
    if ($s < 3600) return intdiv($s, 60) . 'm ago';
    if ($s < 86400) return intdiv($s, 3600) . 'h ago';
    return intdiv($s, 86400) . 'd ago';
};
$dur = static function (int $s): string {
    if ($s <= 0) return '—';
    if ($s < 60) return $s . 's';
    if ($s < 3600) return intdiv($s, 60) . 'm ' . ($s % 60) . 's';
    return intdiv($s, 3600) . 'h ' . intdiv($s % 3600, 60) . 'm';
};
$devIcon = static fn(string $d): string => ['mobile' => 'bi-phone', 'tablet' => 'bi-tablet', 'desktop' => 'bi-display'][$d] ?? 'bi-question-circle';
$srcLabel = ['db' => 'IP database', 'cdn' => 'CDN header', 'api' => 'IP lookup', 'timezone' => 'Timezone', 'none' => 'Unknown', 'private' => 'Local network'];

/** Render a labelled horizontal-bar breakdown. */
$bars = static function (array $rows, callable $label, string $valField = 'visits') use ($V): string {
    if (!$rows) return '<div class="vs-empty sm">No data yet.</div>';
    $top = 1; foreach ($rows as $r) $top = max($top, (int) ($r[$valField] ?? 0));
    $html = '';
    foreach ($rows as $r) {
        $v = (int) ($r[$valField] ?? 0);
        $html .= '<div class="vs-crow"><span class="nm">' . $label($r) . '</span>'
              . '<span class="track"><span class="fill" style="width:' . (int) round($v / $top * 100) . '%"></span></span>'
              . '<span class="n">' . $v . '</span></div>';
    }
    return $html;
};
$h = static fn($s): string => htmlspecialchars((string) $s, ENT_QUOTES);
?>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> · Visitors</title>
    <?php include constant('BASE_PATH') . '/core/views/favicon.php'; ?>
    <link href="<?= $assetsUrl ?>/bootstrap/css/bootstrap.min.css" rel="stylesheet">
    <link href="<?= $assetsUrl ?>/icons/bootstrap-icons.css" rel="stylesheet">
    <?php include constant('BASE_PATH') . '/core/views/theme-head.php'; ?>
    <link href="<?= $assetsUrl ?>/sweetalert2/sweetalert2.min.css" rel="stylesheet">
    <script src="<?= $assetsUrl ?>/sweetalert2/sweetalert2.min.js"></script>
    <style>
        body { background: var(--pp-app-bg, #f5f6fa); color: var(--pp-text, #172033); }
        .vs-top { background: var(--pp-navbar-bg, #1f2328); color: #f4f6fb; }
        .vs-top a { color: #cbd5e1; text-decoration: none; } .vs-top a:hover { color: #fff; }
        .vs-wrap { max-width: 1240px; margin: 1.6rem auto; padding: 0 1rem; }
        h1 { font-size: 1.4rem; font-weight: 800; margin: 0; letter-spacing: -.02em; }
        .vs-sub { color: var(--pp-text-muted, #64748b); font-size: .88rem; margin: .3rem 0 0; }
        .vs-live-pill { display: inline-flex; align-items: center; gap: .4rem; font-weight: 700; font-size: .8rem;
            background: color-mix(in srgb, #16a34a 16%, transparent); color: color-mix(in srgb, #16a34a 70%, var(--pp-text, #fff));
            border: 1px solid color-mix(in srgb, #16a34a 40%, transparent); border-radius: 999px; padding: .18rem .7rem; }
        .vs-live-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 0 rgba(22,163,74,.6); animation: vspulse 1.8s infinite; }
        @keyframes vspulse { 0% { box-shadow: 0 0 0 0 rgba(22,163,74,.55); } 70% { box-shadow: 0 0 0 7px rgba(22,163,74,0); } 100% { box-shadow: 0 0 0 0 rgba(22,163,74,0); } }
        .vs-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(116px, 1fr)); gap: .6rem; margin: 1.1rem 0; }
        .vs-stat { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 12px; padding: .8rem 1rem; }
        .vs-stat b { display: block; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; }
        .vs-stat span { font-size: .66rem; color: var(--pp-text-muted, #64748b); text-transform: uppercase; letter-spacing: .05em; }
        .vs-stat.accent b { color: var(--pp-accent, #4f46e5); }
        .vs-sec { background: var(--pp-surface, #fff); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 14px; margin-bottom: 1.2rem; overflow: hidden; }
        .vs-h { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
            padding: .8rem 1.1rem; border-bottom: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface-soft, #f8fafc); }
        .vs-h h2 { font-size: .95rem; font-weight: 800; margin: 0; display: flex; align-items: center; gap: .5rem; }
        .vs-h .chip { font-size: .68rem; font-weight: 700; color: var(--pp-text-muted, #64748b);
            background: var(--pp-surface-muted, #f1f5f9); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 999px; padding: .12rem .55rem; }
        .vs-body { padding: 1rem 1.1rem; }
        .vs-grid2 { display: grid; grid-template-columns: 1.15fr .85fr; gap: 1.2rem; }
        .vs-grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.2rem; }
        .vs-grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.2rem; }
        @media (max-width: 980px) { .vs-grid2, .vs-grid3, .vs-grid4 { grid-template-columns: 1fr 1fr; } }
        @media (max-width: 620px) { .vs-grid2, .vs-grid3, .vs-grid4 { grid-template-columns: 1fr; } }
        table.vs-t { width: 100%; border-collapse: collapse; font-size: .85rem; }
        .vs-t th { text-align: left; font-size: .62rem; text-transform: uppercase; letter-spacing: .05em;
            color: var(--pp-text-muted, #64748b); font-weight: 800; padding: .55rem 1.1rem;
            background: var(--pp-surface-soft, #f8fafc); border-bottom: 1px solid var(--pp-border, #e6e9f0); white-space: nowrap; }
        .vs-t td { padding: .5rem 1.1rem; border-bottom: 1px solid var(--pp-border, #eef1f6); vertical-align: middle; }
        .vs-t tbody tr[data-row] { cursor: pointer; }
        .vs-t tbody tr:hover td { background: color-mix(in srgb, var(--pp-accent, #2563eb) 5%, var(--pp-surface, #fff)); }
        .vs-t code { background: var(--pp-surface-muted, #f1f5f9); border-radius: 5px; padding: 1px 6px; font-size: .78rem; }
        .vs-tag { font-size: .6rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
            padding: 2px 7px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; }
        .vs-tag.bot { background: color-mix(in srgb, #f59e0b 20%, var(--pp-surface, #fff)); color: color-mix(in srgb, #f59e0b 66%, var(--pp-text)); border-color: color-mix(in srgb, #f59e0b 40%, transparent); }
        .vs-tag.user { background: color-mix(in srgb, #16a34a 20%, var(--pp-surface, #fff)); color: color-mix(in srgb, #16a34a 62%, var(--pp-text)); border-color: color-mix(in srgb, #16a34a 40%, transparent); }
        .vs-tag.ret { background: color-mix(in srgb, #6366f1 18%, var(--pp-surface, #fff)); color: color-mix(in srgb, #6366f1 70%, var(--pp-text)); border-color: color-mix(in srgb, #6366f1 40%, transparent); }
        .vs-bars { display: flex; align-items: flex-end; gap: 3px; height: 96px; }
        .vs-bar { flex: 1; min-width: 4px; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--pp-accent, #4f46e5), color-mix(in srgb, var(--pp-accent, #4f46e5) 45%, transparent)); }
        .vs-chan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.6rem; }
        @media (max-width: 620px) { .vs-chan-grid { grid-template-columns: 1fr; } }
        .vs-snippet { background: var(--pp-surface-muted, #0d1117); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 8px;
            padding: .7rem .8rem; font-family: ui-monospace, Menlo, monospace; font-size: .76rem; white-space: pre-wrap; word-break: break-all; text-align: left; }
        .vs-hbars { display: flex; align-items: flex-end; gap: 2px; height: 46px; }
        .vs-hbar { flex: 1; border-radius: 2px 2px 0 0; background: color-mix(in srgb, var(--pp-accent, #4f46e5) 60%, transparent); min-height: 2px; }
        .vs-crow { display: flex; align-items: center; gap: .6rem; padding: .32rem 0; }
        .vs-crow .fl { font-size: 1.05rem; width: 1.5rem; text-align: center; }
        .vs-crow .nm { flex: 1; font-size: .84rem; display: flex; align-items: center; gap: .4rem; min-width: 0; }
        .vs-crow .nm span.t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .vs-crow .track { flex: 1.1; height: 7px; border-radius: 99px; background: var(--pp-surface-muted, #f1f5f9); overflow: hidden; }
        .vs-crow .fill { display: block; height: 100%; min-width: 2px; border-radius: 99px; background: var(--pp-accent, #4f46e5); }
        .vs-crow .n { font-size: .8rem; font-weight: 700; min-width: 2.2rem; text-align: right; font-variant-numeric: tabular-nums; }
        .vs-del { border: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface, #fff); color: #dc2626;
            border-radius: 7px; padding: .2rem .45rem; font-size: .74rem; cursor: pointer; }
        .vs-del:hover { background: color-mix(in srgb, #ef4444 10%, transparent); border-color: #ef4444; }
        .vs-empty { text-align: center; color: var(--pp-text-muted, #64748b); padding: 2.2rem; font-size: .88rem; }
        .vs-empty.sm { padding: 1.1rem; font-size: .82rem; }
        .vs-note { display: flex; gap: .55rem; align-items: flex-start; font-size: .8rem; color: var(--pp-text-muted, #64748b);
            background: var(--pp-surface-soft, #f8fafc); border: 1px solid var(--pp-border, #e6e9f0);
            border-radius: 11px; padding: .7rem .9rem; margin-top: 1rem; }
        .vs-live { display: flex; flex-direction: column; gap: .45rem; }
        .vs-liverow { display: flex; align-items: center; gap: .7rem; padding: .55rem .65rem; border-radius: 12px;
            border: 1px solid var(--pp-border, #eef1f6); background: var(--pp-surface-soft, #f8fafc);
            transition: background .15s, border-color .15s, transform .15s; animation: vs-fadein .3s ease; }
        @keyframes vs-fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
        .vs-liverow:hover { background: var(--pp-surface, #fff); border-color: var(--pp-border-strong, #cbd5e1); transform: translateX(2px); }
        .vs-liverow .av { position: relative; flex: 0 0 auto; width: 38px; height: 38px; border-radius: 50%;
            display: grid; place-items: center; font-size: 1rem; color: var(--pp-accent, #4f46e5);
            background: color-mix(in srgb, var(--pp-accent, #4f46e5) 12%, var(--pp-surface, #fff));
            border: 1px solid color-mix(in srgb, var(--pp-accent, #4f46e5) 25%, var(--pp-border)); }
        .vs-liverow .av .ping { position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%;
            background: #10b981; border: 2px solid var(--pp-surface, #fff); animation: vs-ping 1.9s ease-out infinite; }
        @keyframes vs-ping { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,.55); } 70% { box-shadow: 0 0 0 7px rgba(16,185,129,0); } 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } }
        .vs-liverow .who { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .12rem; }
        .vs-liverow .l1 { display: flex; align-items: center; gap: .4rem; font-size: .86rem; font-weight: 700; }
        .vs-liverow .l1 .cc { color: var(--pp-text-muted, #64748b); font-weight: 600; font-size: .74rem; }
        .vs-liverow .l2 { display: flex; align-items: center; gap: .4rem; font-size: .74rem; color: var(--pp-text-muted, #64748b); min-width: 0; }
        .vs-liverow .l2 .pth { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 190px;
            font-family: var(--pp-font-mono, ui-monospace, monospace); font-size: .7rem;
            background: color-mix(in srgb, var(--pp-text, #0f172a) 6%, transparent); padding: .05rem .38rem; border-radius: 5px; }
        .vs-liverow .when { flex: 0 0 auto; font-size: .72rem; color: var(--pp-text-muted, #64748b); font-variant-numeric: tabular-nums; white-space: nowrap; }
        /* Drawer */
        .vs-drawer-back { position: fixed; inset: 0; background: rgba(15,23,42,.5); opacity: 0; visibility: hidden; transition: opacity .18s; z-index: 1080; }
        .vs-drawer-back.on { opacity: 1; visibility: visible; }
        .vs-drawer { position: fixed; top: 0; right: 0; height: 100%; width: 460px; max-width: 94vw; background: var(--pp-surface, #fff);
            border-left: 1px solid var(--pp-border, #e6e9f0); box-shadow: -18px 0 46px rgba(15,23,42,.22); transform: translateX(100%);
            transition: transform .22s cubic-bezier(.4,0,.2,1); z-index: 1090; display: flex; flex-direction: column; }
        .vs-drawer.on { transform: translateX(0); }
        .vs-drawer .dh { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem 1.1rem; border-bottom: 1px solid var(--pp-border, #e6e9f0); }
        .vs-drawer .db0 { padding: 1rem 1.1rem; overflow-y: auto; }
        .vs-kv { display: grid; grid-template-columns: 108px 1fr; gap: .3rem .7rem; font-size: .84rem; margin-bottom: .3rem; }
        .vs-kv dt { color: var(--pp-text-muted, #64748b); }
        .vs-kv dd { margin: 0; font-weight: 600; word-break: break-word; }
        .vs-sub2 { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--pp-text-muted, #64748b); margin: 1.1rem 0 .5rem; }
        .vs-journey { list-style: none; margin: 0; padding: 0; position: relative; }
        .vs-journey li { position: relative; padding: .25rem 0 .55rem 1.2rem; border-left: 2px solid var(--pp-border, #e6e9f0); }
        .vs-journey li:last-child { border-left-color: transparent; }
        .vs-journey li::before { content: ''; position: absolute; left: -5px; top: .5rem; width: 8px; height: 8px; border-radius: 50%; background: var(--pp-accent, #4f46e5); }
        .vs-journey .p { font-size: .82rem; font-weight: 600; word-break: break-all; }
        .vs-journey .m { font-size: .72rem; color: var(--pp-text-muted, #64748b); }
        .swal2-container { z-index: 100000 !important; }

        /* ── Page header + unified button toolbar ── */
        .vs-pagehead { display: flex; align-items: flex-start; gap: .85rem; margin: .2rem 0 1.1rem; }
        .vs-pagehead .head-main { flex: 1; min-width: 0; }
        .vs-pagehead .head-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem 1rem; flex-wrap: wrap; }
        .vs-pagehead .head-row h1 { line-height: 1.15; }
        .vs-pagehead .head-main .vs-sub { margin-top: .35rem; }
        .vs-pagehead .badge-ico { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; font-size: 1.25rem; flex: none; margin-top: 2px;
            color: var(--pp-accent, #4f46e5); background: color-mix(in srgb, var(--pp-accent, #4f46e5) 13%, transparent);
            border: 1px solid color-mix(in srgb, var(--pp-accent, #4f46e5) 24%, transparent); }
        .vs-toolbar { display: flex; gap: .4rem; flex-wrap: wrap; }
        .vs-btn { display: inline-flex; align-items: center; gap: .42rem; font-size: .82rem; font-weight: 600; line-height: 1;
            padding: .5rem .8rem; border-radius: 9px; border: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface, #fff);
            color: var(--pp-text, #172033); cursor: pointer; text-decoration: none; transition: border-color .14s, background .14s, transform .14s, box-shadow .14s; white-space: nowrap; }
        .vs-btn:hover { border-color: color-mix(in srgb, var(--pp-accent, #4f46e5) 45%, var(--pp-border, #e6e9f0));
            background: var(--pp-surface-soft, #f8fafc); color: var(--pp-text, #172033); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(15,23,42,.06); }
        .vs-btn i { font-size: .92rem; opacity: .85; }
        .vs-btn.primary { background: var(--pp-accent, #4f46e5); border-color: var(--pp-accent, #4f46e5); color: #fff; }
        .vs-btn.primary:hover { filter: brightness(1.06); background: var(--pp-accent, #4f46e5); color: #fff; }
        .vs-btn.primary i { opacity: 1; }
        .vs-btn.danger { color: #dc2626; }
        .vs-btn.danger:hover { border-color: #ef4444; background: color-mix(in srgb, #ef4444 10%, transparent); color: #dc2626; }

        /* ── Tidy path / breakdown rows ── */
        .vs-crow .nm .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .vs-crow code.t { background: transparent; padding: 0; font-size: .8rem; color: var(--pp-text, #172033);
            font-family: ui-monospace, Menlo, monospace; }
        .vs-pathlist { counter-reset: pth; }
        .vs-pathlist .vs-crow { counter-increment: pth; gap: .55rem; }
        .vs-pathlist .vs-crow .rk { flex: none; width: 1.4rem; text-align: right; font-size: .72rem; font-weight: 800;
            color: var(--pp-text-muted, #94a3b8); font-variant-numeric: tabular-nums; }
        .vs-pathlist .vs-crow .rk::before { content: counter(pth); }
        .vs-split2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.4rem; }
        @media (max-width: 520px) { .vs-split2 { grid-template-columns: 1fr; } }

        /* ── Mini stat tiles (Clients & API summary) ── */
        .vs-mini-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
        @media (max-width: 560px) { .vs-mini-grid { grid-template-columns: repeat(2, 1fr); } }
        .vs-mini { background: var(--pp-surface-soft, #f8fafc); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 11px; padding: .6rem .7rem; }
        .vs-mini b { display: block; font-size: 1.1rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.1; }
        .vs-mini span { font-size: .64rem; text-transform: uppercase; letter-spacing: .045em; color: var(--pp-text-muted, #64748b); font-weight: 700; }
        .vs-mini .sub { display: block; font-size: .7rem; color: var(--pp-text-muted, #94a3b8); text-transform: none; letter-spacing: 0; font-weight: 500; margin-top: .05rem; }
        .vs-mini.warn b { color: #dc2626; }
        .vs-mini.good b { color: #16a34a; }

        /* ── Recent-visits toolbar + secondary lines + pagination ── */
        .vs-listbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; padding: .7rem 1.1rem; border-bottom: 1px solid var(--pp-border, #eef1f6); }
        .vs-listbar .search { position: relative; flex: 1; min-width: 200px; }
        .vs-listbar .search i { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); color: var(--pp-text-muted, #94a3b8); font-size: .9rem; pointer-events: none; }
        .vs-listbar .search input { padding-left: 2rem; }
        .vs-perpage { display: inline-flex; align-items: center; gap: .4rem; font-size: .8rem; color: var(--pp-text-muted, #64748b); white-space: nowrap; }
        .vs-perpage select { width: auto; }
        .vs-t td .sub, .vs-t td .vs-where .sub { font-size: .75rem; color: var(--pp-text-muted, #64748b); }
        .vs-t td .vs-fl { font-size: 1.15rem; margin-right: .35rem; vertical-align: -2px; }
        .vs-where { display: inline-block; vertical-align: middle; min-width: 0; }
        .vs-where .nm { display: block; font-weight: 600; }
        .vs-where .sub { display: block; }
        .vs-t td .vs-devline { display: inline-flex; align-items: center; gap: .35rem; }
        #vsTable.loading tbody { opacity: .45; transition: opacity .15s; pointer-events: none; }
        .vs-pager { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
            padding: .7rem 1.1rem; border-top: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface-soft, #f8fafc); }
        .vs-pager .showing { font-size: .8rem; color: var(--pp-text-muted, #64748b); font-variant-numeric: tabular-nums; }
        .vs-pages { display: flex; gap: .25rem; align-items: center; flex-wrap: wrap; }
        .vs-pg { min-width: 32px; height: 32px; padding: 0 .5rem; display: inline-flex; align-items: center; justify-content: center;
            border: 1px solid var(--pp-border, #e6e9f0); background: var(--pp-surface, #fff); color: var(--pp-text, #172033);
            border-radius: 8px; font-size: .82rem; font-weight: 600; cursor: pointer; font-variant-numeric: tabular-nums; transition: border-color .12s, background .12s; }
        .vs-pg:hover:not(:disabled):not(.active) { border-color: color-mix(in srgb, var(--pp-accent, #4f46e5) 55%, var(--pp-border, #e6e9f0)); }
        .vs-pg.active { background: var(--pp-accent, #4f46e5); border-color: var(--pp-accent, #4f46e5); color: #fff; cursor: default; }
        .vs-pg:disabled { opacity: .4; cursor: default; }
        .vs-pg.gap { border: 0; background: transparent; cursor: default; min-width: 1.2rem; color: var(--pp-text-muted, #94a3b8); }

        /* ── Interactive charts (hand-rolled Canvas/SVG — no library) ── */
        .vs-chartbox { position: relative; }
        #vsTrend { width: 100%; height: 200px; display: block; }
        .vs-tt { position: absolute; pointer-events: none; z-index: 6; background: var(--pp-surface, #fff);
            border: 1px solid var(--pp-border, #e6e9f0); border-radius: 9px; padding: .35rem .55rem; font-size: .75rem;
            box-shadow: 0 8px 24px rgba(0,0,0,.16); opacity: 0; transition: opacity .1s; white-space: nowrap; }
        .vs-tt b { font-size: .82rem; }
        .vs-metricseg { display: inline-flex; background: var(--pp-surface-muted, #eef1f6); border: 1px solid var(--pp-border, #e6e9f0); border-radius: 8px; padding: 2px; }
        .vs-metricseg button { border: 0; background: transparent; color: var(--pp-text-muted, #64748b); font-size: .72rem; font-weight: 700; padding: .2rem .55rem; border-radius: 6px; cursor: pointer; }
        .vs-metricseg button.on { background: var(--pp-surface, #fff); color: var(--pp-accent, #4f46e5); box-shadow: 0 1px 2px rgba(0,0,0,.12); }
        .vs-trendhead { flex-wrap: wrap; gap: .4rem .5rem; }
        .vs-trendhead h2 { margin-right: auto; }
        .vs-livebtn { display: inline-flex; align-items: center; gap: .35rem; font-size: .72rem; font-weight: 700; color: var(--pp-text-muted, #64748b);
            border: 1px solid var(--pp-border, #e6e9f0); border-radius: 8px; padding: .22rem .55rem; cursor: pointer; background: var(--pp-surface, #fff); }
        .vs-livebtn .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pp-border-strong, #cbd5e1); }
        .vs-livebtn.on { color: var(--pp-accent, #4f46e5); border-color: color-mix(in srgb, var(--pp-accent,#4f46e5) 45%, var(--pp-border)); }
        .vs-livebtn.on .dot { background: #10b981; box-shadow: 0 0 0 3px color-mix(in srgb, #10b981 25%, transparent); animation: vs-pulse 1.8s ease-in-out infinite; }
        @keyframes vs-pulse { 50% { opacity: .5; } }
        /* ── 3D globe ── */
        .vs-geo2 { display: grid; grid-template-columns: 300px 1fr; gap: 1rem; align-items: start; }
        @media (max-width: 720px) { .vs-geo2 { grid-template-columns: 1fr; } }
        .vs-globe-wrap { position: relative; display: flex; align-items: center; justify-content: center; }
        #vsGlobe { width: 280px; height: 280px; max-width: 100%; cursor: grab; touch-action: none; }
        #vsGlobe:active { cursor: grabbing; }
        .vs-globe-cap { position: absolute; bottom: -2px; left: 0; right: 0; text-align: center; font-size: .68rem; color: var(--pp-text-muted, #94a3b8); pointer-events: none; }
        .vs-colist { max-height: 300px; overflow: auto; }
        /* ── 3D donut ── */
        .vs-donutbox { display: flex; flex-direction: column; align-items: center; gap: .7rem; }
        .vs-donutbox svg { flex: 0 0 auto; overflow: visible; }
        .vs-donutbox .dseg { cursor: pointer; transition: opacity .14s ease; }
        .vs-dlegend { display: flex; flex-direction: column; gap: .15rem; font-size: .8rem; width: 100%; }
        .vs-dlegend .it { display: flex; align-items: center; gap: .45rem; padding: .26rem .4rem; border-radius: 8px;
            cursor: default; transition: background .12s ease; }
        .vs-dlegend .it.on { background: color-mix(in srgb, var(--pp-accent, #4f46e5) 12%, transparent); }
        .vs-dlegend .sw { width: 11px; height: 11px; border-radius: 4px; flex: none; box-shadow: inset 0 -2px 3px rgba(0,0,0,.18); }
        .vs-dlegend .t { line-height: 1.2; word-break: break-word; }
        .vs-dlegend .v { margin-left: auto; padding-left: .5rem; font-weight: 700; font-variant-numeric: tabular-nums;
            color: var(--pp-text-muted, #64748b); white-space: nowrap; }
        .vs-dlegend .it.pinned { background: color-mix(in srgb, var(--pp-accent, #4f46e5) 18%, transparent);
            box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--pp-accent, #4f46e5) 40%, transparent); }
        .vs-ddetail { width: 100%; margin-top: .1rem; padding: .55rem .65rem; border-radius: 10px; animation: vs-fadein .2s ease;
            background: var(--pp-surface-soft, #f8fafc); border: 1px solid var(--pp-border, #e6e9f0); }
        .vs-ddetail[hidden] { display: none; }
        .vs-ddetail .dd-top { display: flex; align-items: baseline; gap: .4rem; }
        .vs-ddetail .dd-top .sw { width: 11px; height: 11px; border-radius: 4px; flex: none; align-self: center; }
        .vs-ddetail .dd-val { font-size: 1.02rem; font-weight: 800; font-variant-numeric: tabular-nums; }
        .vs-ddetail .dd-lab { font-size: .82rem; font-weight: 600; }
        .vs-ddetail .dd-pct { margin-left: auto; font-size: .78rem; font-weight: 700; color: var(--pp-accent, #4f46e5); }
        .vs-ddetail .dd-sub { font-size: .72rem; color: var(--pp-text-muted, #64748b); margin-top: .15rem; }
        .vs-ddetail .dd-bar { height: 6px; border-radius: 99px; margin-top: .45rem; overflow: hidden;
            background: var(--pp-surface-muted, #eef1f6); }
        .vs-ddetail .dd-bar span { display: block; height: 100%; border-radius: 99px; background: var(--pp-accent, #4f46e5);
            transition: width .25s ease; }
        /* ── Weekday × hour heatmap ── */
        .vs-heat { display: grid; grid-template-columns: 34px repeat(24, 1fr); gap: 2px; font-size: .58rem; align-items: center; }
        .vs-heat .hd { color: var(--pp-text-muted, #94a3b8); text-align: center; }
        .vs-heat .rl { color: var(--pp-text-muted, #94a3b8); }
        .vs-heat .cell { aspect-ratio: 1 / 1; border-radius: 3px; background: var(--pp-surface-muted, #eef1f6); min-height: 12px; }
    </style>
</head>
<body>
    <div class="vs-top">
        <div class="vs-wrap" style="margin:0 auto;display:flex;align-items:center;justify-content:space-between;padding-top:.7rem;padding-bottom:.7rem">
            <span style="font-weight:800"><i class="bi bi-people-fill me-2"></i>Visitors
                <span class="vs-live-pill ms-2" id="vsLivePill" title="Human visitors active in the last 5 minutes"><span class="dot"></span><span id="vsLiveN"><?= (int) ($summary['live'] ?? 0) ?></span> online</span>
            </span>
            <span style="display:inline-flex;align-items:center;gap:.9rem">
                <?php include constant('BASE_PATH') . '/core/views/theme-toggle.php'; ?>
                <a href="<?= $h($u('/database-manager')) ?>"><i class="bi bi-hdd-stack me-1"></i>Virtual Databases</a>
                <a href="<?= $h($u('/projects')) ?>"><i class="bi bi-arrow-left me-1"></i>Projects</a>
            </span>
        </div>
    </div>

    <div class="vs-wrap">
        <div class="vs-pagehead">
            <span class="badge-ico"><i class="bi bi-people-fill"></i></span>
            <div class="head-main">
                <div class="head-row">
                    <h1>Who is visiting</h1>
                    <div class="vs-toolbar">
                        <a class="vs-btn" href="<?= $h($u('/platform/visitors/export' . ($showBots ? '?bots=1' : ''))) ?>" data-tip="Download the visitor log as a CSV file"><i class="bi bi-download"></i> Export CSV</a>
                        <a class="vs-btn" href="<?= $h($u('/platform/visitors' . ($showBots ? '' : '?bots=1'))) ?>" data-tip="<?= $showBots ? 'Hide crawler / bot rows' : 'Include crawler / bot rows' ?>"><i class="bi bi-robot"></i> <?= $showBots ? 'Hide bots' : 'Show bots' ?></a>
                        <a class="vs-btn" href="<?= $h($u('/help/visitor_engine')) ?>" target="_blank" data-tip="Open the Visitor Engine guide"><i class="bi bi-book"></i> Guide</a>
                        <button class="vs-btn primary" id="vsSettings" data-tip="Recording, privacy, retention &amp; the location / network (IP) databases" title="Recording, privacy, retention &amp; the location / network (IP) databases"><i class="bi bi-sliders"></i> Settings</button>
                    </div>
                </div>
                <p class="vs-sub">Every visit to this installation, one row per browser session. Location comes from a real
                   IP lookup; device from the browser's own Client Hints. Headline counts exclude bots.</p>
            </div>
        </div>

        <div class="vs-stats">
            <div class="vs-stat" data-tip="Distinct human visits recorded"><b><?= (int) ($summary['humans'] ?? 0) ?></b><span>visitors</span></div>
            <div class="vs-stat" data-tip="Total pages those visits opened"><b><?= (int) ($summary['page_views'] ?? 0) ?></b><span>page views</span></div>
            <div class="vs-stat accent" data-tip="Human visitors active in the last 5 minutes"><b id="vsStatLive"><?= (int) ($summary['live'] ?? 0) ?></b><span>online now</span></div>
            <div class="vs-stat" data-tip="Visits seen today"><b><?= (int) ($summary['today'] ?? 0) ?></b><span>today</span></div>
            <div class="vs-stat" data-tip="Visits in the last 7 days"><b><?= (int) ($summary['week'] ?? 0) ?></b><span>this week</span></div>
            <div class="vs-stat" data-tip="Share of visitors who had been here before"><b><?= ($summary['humans'] ?? 0) > 0 ? round(($summary['returning'] ?? 0) / $summary['humans'] * 100) : 0 ?>%</b><span>returning</span></div>
            <div class="vs-stat" data-tip="Average pages opened per human visit"><b><?= number_format((float) ($summary['avg_pages'] ?? 0), 1) ?></b><span>pages / visit</span></div>
            <div class="vs-stat" data-tip="Average engaged time per visit (from the beacon)"><b><?= $dur((int) ($summary['avg_seconds'] ?? 0)) ?></b><span>avg time</span></div>
            <div class="vs-stat" data-tip="Distinct countries seen"><b><?= (int) ($summary['countries'] ?? 0) ?></b><span>countries</span></div>
            <div class="vs-stat" data-tip="Crawlers and automated agents"><b><?= (int) ($summary['bots'] ?? 0) ?></b><span>bots</span></div>
        </div>

        <div class="vs-grid2">
            <div class="vs-sec">
                <div class="vs-h vs-trendhead"><h2><i class="bi bi-graph-up"></i> Visitor trend</h2>
                    <span class="vs-metricseg" id="vsTrendRange">
                        <button data-range="24h">24h</button>
                        <button data-range="7d">7d</button>
                        <button data-range="30d" class="on">30d</button>
                        <button data-range="90d">90d</button>
                    </span>
                    <span class="vs-metricseg" id="vsTrendSeg">
                        <button data-metric="visits" class="on">Visitors</button>
                        <button data-metric="views">Page views</button>
                        <button data-metric="returning">Returning</button>
                    </span>
                    <button class="vs-livebtn" id="vsTrendLive" title="Auto-refresh every 20s"><span class="dot"></span> Live</button>
                </div>
                <div class="vs-body">
                    <?php if (!$daily): ?>
                        <div class="vs-empty">No visits recorded yet.</div>
                    <?php else: ?>
                        <div class="vs-chartbox">
                            <canvas id="vsTrend"></canvas>
                            <div class="vs-tt" id="vsTrendTip"></div>
                        </div>
                        <div class="vs-sub2">By hour of day</div>
                        <div class="vs-hbars">
                            <?php for ($i = 0; $i < 24; $i++): $hv = (int) ($hourly[$i] ?? 0);
                                $hh = $maxHour > 0 ? max(2, (int) round($hv / $maxHour * 44)) : 2; ?>
                                <div class="vs-hbar" style="height:<?= $hh ?>px" data-tip="<?= sprintf('%02d:00', $i) ?> · <?= $hv ?> visit(s)"></div>
                            <?php endfor; ?>
                        </div>
                        <div class="d-flex justify-content-between mt-1" style="font-size:.66rem;color:var(--pp-text-muted)"><span>00h</span><span>12h</span><span>23h</span></div>
                    <?php endif; ?>
                </div>
            </div>

            <div class="vs-sec">
                <div class="vs-h"><h2><i class="bi bi-globe2"></i> Where from</h2>
                    <?php $gi = $geoStatus['installed'] ?? false; ?>
                    <span class="chip" id="vsGeoChip" title="<?= $gi ? $h(($geoStatus['type'] ?? '') . ' · build ' . ($geoStatus['build'] ?? '') . ' · ' . ($geoStatus['size_mb'] ?? '') . ' MB') : 'No local IP database installed' ?>">
                        <i class="bi bi-<?= $gi ? 'hdd-fill' : 'exclamation-triangle' ?>"></i> <?= $gi ? 'IP DB ready' : 'No IP DB' ?></span>
                </div>
                <div class="vs-body">
                    <?php if (!$countries): ?>
                        <div class="vs-empty">No country data yet.</div>
                    <?php else: $top = max(1, (int) $countries[0]['visits']); ?>
                        <div class="vs-geo2">
                            <div class="vs-globe-wrap">
                                <canvas id="vsGlobe" width="280" height="280" role="img" aria-label="Visitors around the world"></canvas>
                                <div class="vs-globe-cap">drag to spin · hover a point</div>
                            </div>
                            <div class="vs-colist">
                                <?php foreach ($countries as $c): $cc = (string) ($c['key'] ?? ''); ?>
                                    <?php $cName = (string) ($c['country_name'] ?: ($cc ? $cc : 'Unknown')); ?>
                                    <div class="vs-crow" title="<?= $h($cName) ?>" data-tip="<?= $h($cName) ?>">
                                        <span class="fl"><?= $V::flag($cc) ?></span>
                                        <span class="nm"><span class="t"><?= $h($cc ? strtoupper($cc) : '—') ?></span></span>
                                        <span class="track"><span class="fill" style="width:<?= (int) round(((int) $c['visits'] / $top) * 100) ?>%"></span></span>
                                        <span class="n"><?= (int) $c['visits'] ?></span>
                                    </div>
                                <?php endforeach; ?>
                            </div>
                        </div>
                    <?php endif; ?>
                </div>
            </div>
        </div>

        <!-- Device / browser / OS / source breakdowns -->
        <div class="vs-grid4">
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-phone"></i> Devices</h2></div>
                <div class="vs-body"><?php if (!$devices): ?><div class="vs-empty sm">No data yet.</div><?php else: ?>
                    <div class="vs-donutbox"><svg id="vsDonutDev" width="112" height="112" viewBox="0 0 112 112" aria-label="Devices"></svg><div class="vs-dlegend" id="vsDonutDevLegend"></div></div>
                <?php endif; ?></div></div>
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-window"></i> Browsers</h2></div>
                <div class="vs-body"><?= $bars($browsers, fn($r) => '<span class="t">' . $h((string) ($r['key'] ?: 'Other')) . '</span>') ?></div></div>
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-cpu"></i> Operating systems</h2></div>
                <div class="vs-body"><?= $bars($systems, fn($r) => '<span class="t">' . $h((string) ($r['key'] ?: 'Other')) . '</span>') ?></div></div>
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-signpost-split"></i> Traffic sources</h2></div>
                <div class="vs-body"><?php if (!$refTypes): ?><div class="vs-empty sm">No data yet.</div><?php else: ?>
                    <div class="vs-donutbox"><svg id="vsDonutSrc" width="112" height="112" viewBox="0 0 112 112" aria-label="Traffic sources"></svg><div class="vs-dlegend" id="vsDonutSrcLegend"></div></div>
                <?php endif; ?></div></div>
        </div>

        <!-- Load heatmap: when visitors come, by weekday × hour -->
        <div class="vs-sec">
            <div class="vs-h"><h2><i class="bi bi-calendar3-week"></i> When visitors come</h2>
                <span class="chip">last 30 days · weekday × hour (local server time)</span></div>
            <div class="vs-body">
                <div class="vs-heat" id="vsHeat"></div>
            </div>
        </div>

        <!-- Acquisition -->
        <?php $pathLabel = static fn($r) => '<span class="rk"></span><code class="t" title="' . $h((string) $r['key']) . '">' . $h((string) $r['key']) . '</code>'; ?>
        <div class="vs-grid3">
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-file-earmark-text"></i> Top pages</h2><span class="chip">most viewed</span></div>
                <div class="vs-body vs-pathlist"><?= $bars($topPages, $pathLabel, 'views') ?></div></div>
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-box-arrow-in-right"></i> Entry &amp; exit</h2></div>
                <div class="vs-body">
                    <div class="vs-sub2" style="margin-top:0"><i class="bi bi-box-arrow-in-down-right me-1"></i>Where visitors land</div>
                    <div class="vs-pathlist"><?= $bars(array_slice($entryPages, 0, 5), $pathLabel) ?></div>
                    <div class="vs-sub2"><i class="bi bi-box-arrow-up-right me-1"></i>Where they leave</div>
                    <div class="vs-pathlist"><?= $bars(array_slice($exitPages, 0, 5), $pathLabel) ?></div>
                </div></div>
            <div class="vs-sec"><div class="vs-h"><h2><i class="bi bi-megaphone"></i> Referrers &amp; campaigns</h2></div>
                <div class="vs-body">
                    <div class="vs-sub2" style="margin-top:0">Referring sites</div>
                    <?= $bars(array_slice($referrers, 0, 5), fn($r) => '<span class="t">' . $h((string) $r['key']) . '</span>') ?>
                    <div class="vs-sub2">Campaigns (UTM)</div>
                    <?php if (!$campaigns): ?><div class="vs-empty sm">No campaign traffic.</div>
                    <?php else: $ct = max(1, (int) $campaigns[0]['visits']); foreach ($campaigns as $c): ?>
                        <div class="vs-crow"><span class="nm"><span class="t"><?= $h((string) ($c['source'] ?? '') ?: 'source') ?><?= !empty($c['campaign']) ? ' · ' . $h((string) $c['campaign']) : '' ?></span></span>
                            <span class="track"><span class="fill" style="width:<?= (int) round((int) $c['visits'] / $ct * 100) ?>%"></span></span>
                            <span class="n"><?= (int) $c['visits'] ?></span></div>
                    <?php endforeach; endif; ?>
                </div></div>
        </div>

        <!-- Traffic by channel -->
        <div class="vs-sec">
            <div class="vs-h"><h2><i class="bi bi-diagram-3"></i> Traffic by channel</h2>
                <span class="chip">console · marketing · community · license · update</span></div>
            <div class="vs-body">
                <?php if (!$channels): ?><div class="vs-empty sm">No traffic recorded yet.</div>
                <?php else: $ctop = 1; foreach ($channels as $c) $ctop = max($ctop, (int) $c['visits']); ?>
                    <div class="vs-chan-grid">
                    <?php foreach ($channels as $c): $ck = (string) $c['key']; ?>
                        <div class="vs-crow">
                            <span class="nm"><i class="bi <?= $chanIcon($ck) ?> me-1"></i><span class="t"><?= $h(ucfirst($ck ?: 'console')) ?></span></span>
                            <span class="track"><span class="fill" style="width:<?= (int) round((int) $c['visits'] / $ctop * 100) ?>%"></span></span>
                            <span class="n"><?= (int) $c['visits'] ?></span>
                        </div>
                    <?php endforeach; ?>
                    </div>
                <?php endif; ?>
            </div>
        </div>

        <!-- By server & network -->
        <div class="vs-grid2">
            <div class="vs-sec">
                <div class="vs-h"><h2><i class="bi bi-server"></i> By server</h2><span class="chip">primary + backups</span></div>
                <div class="vs-body">
                    <?php if (!$servers): ?><div class="vs-empty sm">Only this server so far. Backups appear once they report in.</div>
                    <?php else: $stop = 1; foreach ($servers as $s) $stop = max($stop, (int) $s['visits']); foreach ($servers as $s): ?>
                        <div class="vs-crow">
                            <span class="nm"><i class="bi bi-hdd-rack me-1"></i><span class="t"><?= $h((string) ($s['key'] ?: 'unknown')) ?></span></span>
                            <span class="track"><span class="fill" style="width:<?= (int) round((int) $s['visits'] / $stop * 100) ?>%"></span></span>
                            <span class="n" title="<?= (int) ($s['humans'] ?? 0) ?> human · <?= (int) ($s['api'] ?? 0) ?> API"><?= (int) $s['visits'] ?></span>
                        </div>
                    <?php endforeach; endif; ?>
                </div>
            </div>
            <div class="vs-sec">
                <div class="vs-h"><h2><i class="bi bi-diagram-2"></i> Top networks</h2><span class="chip"><i class="bi bi-hdd-fill"></i> = hosting/VPN</span></div>
                <div class="vs-body">
                    <?php if (!$networks): ?><div class="vs-empty sm"><?= !empty($geoStatus['asn']) ? 'No network data yet.' : 'Install the network (ASN) database — Settings → download.' ?></div>
                    <?php else: $ntop = 1; foreach ($networks as $nw) $ntop = max($ntop, (int) $nw['visits']); foreach ($networks as $nw): ?>
                        <div class="vs-crow">
                            <span class="nm"><?= !empty($nw['hosting']) ? '<i class="bi bi-hdd-fill me-1" title="hosting / datacenter / VPN"></i>' : '<i class="bi bi-house-wifi me-1" title="consumer ISP"></i>' ?><span class="t"><?= $h((string) $nw['key']) ?></span></span>
                            <span class="track"><span class="fill" style="width:<?= (int) round((int) $nw['visits'] / $ntop * 100) ?>%"></span></span>
                            <span class="n"><?= (int) $nw['visits'] ?></span>
                        </div>
                    <?php endforeach; endif; ?>
                </div>
            </div>
        </div>

        <!-- Clients & API -->
        <div class="vs-sec">
            <div class="vs-h"><h2><i class="bi bi-hdd-network"></i> Clients &amp; API</h2>
                <span class="chip"><?= (int) ($apiSummary['clients'] ?? 0) ?> clients · <?= (int) ($apiSummary['calls'] ?? 0) ?> calls · <?= (int) ($apiSummary['live'] ?? 0) ?> live</span></div>
            <div class="vs-body">
                <p class="small text-muted mt-0 mb-2">Server-to-server callers — licence activations/renewals/syncs and update checks — grouped by client install: where they call from, what they do, and their success rate. Complements the licensing system's own records.</p>
                <?php if (!$apiClients && !$apiActions): ?>
                    <div class="vs-empty sm">No API clients have called in yet.</div>
                <?php else: ?>
                    <div class="vs-grid2">
                        <div>
                            <div class="vs-sub2" style="margin-top:0">Actions</div>
                            <?php if (!$apiActions): ?><div class="vs-empty sm">—</div>
                            <?php else: $atop = 1; foreach ($apiActions as $a) $atop = max($atop, (int) $a['calls']); foreach ($apiActions as $a): ?>
                                <div class="vs-crow"><span class="nm"><code class="t"><?= $h((string) $a['key']) ?></code>
                                    <?php if ((int) ($a['errors'] ?? 0) > 0): ?><span class="vs-tag bot"><?= (int) $a['errors'] ?> err</span><?php endif; ?></span>
                                    <span class="track"><span class="fill" style="width:<?= (int) round((int) $a['calls'] / $atop * 100) ?>%"></span></span>
                                    <span class="n"><?= (int) $a['calls'] ?></span></div>
                            <?php endforeach; endif; ?>
                        </div>
                        <div>
                            <div class="vs-sub2" style="margin-top:0">Summary</div>
                            <?php
                                $apiCalls = (int) ($apiSummary['calls'] ?? 0);
                                $apiErr   = (int) ($apiSummary['errors'] ?? 0);
                                $apiErrPct = $apiCalls > 0 ? round($apiErr / $apiCalls * 100, 1) : 0.0;
                            ?>
                            <div class="vs-mini-grid">
                                <div class="vs-mini" data-tip="Distinct client installs that have called in"><b><?= (int) ($apiSummary['clients'] ?? 0) ?></b><span>clients</span></div>
                                <div class="vs-mini" data-tip="Total server-to-server calls received"><b><?= $apiCalls ?></b><span>total calls</span></div>
                                <div class="vs-mini <?= $apiErr > 0 ? 'warn' : 'good' ?>" data-tip="Calls that returned an error"><b><?= $apiErr ?></b><span>errors</span><span class="sub"><?= $apiErrPct ?>% of calls</span></div>
                                <div class="vs-mini" data-tip="Licensed clients"><b><?= (int) ($apiSummary['from_clients'] ?? 0) ?></b><span>from clients</span><span class="sub"><?= (int) ($apiSummary['calls_clients'] ?? 0) ?> calls</span></div>
                                <div class="vs-mini" data-tip="Paired backup servers"><b><?= (int) ($apiSummary['from_backups'] ?? 0) ?></b><span>from backups</span><span class="sub"><?= (int) ($apiSummary['calls_backups'] ?? 0) ?> calls</span></div>
                                <div class="vs-mini" data-tip="Callers active in the last 15 minutes"><b><?= (int) ($apiSummary['live'] ?? 0) ?></b><span>active</span><span class="sub">last 15 min</span></div>
                            </div>
                        </div>
                    </div>
                    <?php if ($apiClients): ?>
                    <div class="table-responsive mt-2">
                        <table class="vs-t">
                            <thead><tr><th>Client</th><th>Type</th><th>Where</th><th>Calls</th><th>Success</th><th>Last</th></tr></thead>
                            <tbody>
                            <?php foreach ($apiClients as $c): $calls = (int) $c['calls']; $err = (int) $c['err_count']; $rate = $calls > 0 ? round(($calls - $err) / $calls * 100) : 100; $isBak = ($c['client_type'] ?? '') === 'backup'; ?>
                                <tr>
                                    <td><code><?= $h((string) ($c['client_label'] ?: $c['ip'])) ?></code>
                                        <?php if (!empty($c['org'])): ?><div class="small text-muted"><?php if (!empty($c['is_hosting'])): ?><i class="bi bi-hdd-fill" title="hosting/datacenter"></i> <?php endif; ?>AS<?= (int) $c['asn'] ?> · <?= $h((string) $c['org']) ?></div><?php endif; ?>
                                    </td>
                                    <td><span class="vs-tag <?= $isBak ? 'bot' : 'ret' ?>"><?= $isBak ? 'backup' : 'client' ?></span> <span class="small text-muted"><?= $h((string) $c['channel']) ?></span></td>
                                    <td><?= $V::flag((string) $c['country']) ?> <?= $h((string) ($c['country_name'] ?: ($c['country'] ?: 'Unknown'))) ?></td>
                                    <td><strong><?= $calls ?></strong> <span class="small text-muted"><?= $h((string) $c['last_action']) ?></span></td>
                                    <td><span class="<?= $err > 0 ? 'text-warning' : '' ?>"><?= $rate ?>%</span><?php if ($err > 0): ?> <span class="small text-muted"><?= $err ?> err</span><?php endif; ?></td>
                                    <td class="small text-muted"><?= $h($ago((string) $c['last_seen'])) ?></td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    </div>
                    <?php endif; ?>
                <?php endif; ?>
            </div>
        </div>

        <!-- Properties (report into this dashboard) -->
        <div class="vs-sec">
            <div class="vs-h"><h2><i class="bi bi-broadcast-pin"></i> Properties</h2>
                <button class="btn btn-sm btn-outline-primary" id="vsAddSite"><i class="bi bi-plus-lg"></i> Add property</button></div>
            <div class="vs-body">
                <p class="small text-muted mt-0 mb-2">Let your other sites — the marketing site, community portal, backups, generated apps — report into this one dashboard. Add a property to get its embed snippet (and a secret for server-side reporting).</p>
                <?php if (!$sites): ?>
                    <div class="vs-empty sm">No properties yet. The console itself, licence API and update host are tracked automatically.</div>
                <?php else: ?>
                    <div class="table-responsive">
                        <table class="vs-t" id="vsSitesTable">
                            <thead><tr><th>Property</th><th>Channel</th><th>Site ID</th><th>Browser</th><th>Last report</th><th></th></tr></thead>
                            <tbody>
                            <?php foreach ($sites as $sid => $st): $ls = (int) ($st['last_seen'] ?? 0); ?>
                                <tr>
                                    <td><strong><?= $h((string) $st['label']) ?></strong></td>
                                    <td><span class="vs-tag ret"><?= $h((string) $st['channel']) ?></span></td>
                                    <td><code><?= $h((string) $sid) ?></code></td>
                                    <td><?= !empty($st['browser_enabled']) ? '<i class="bi bi-check-circle text-success"></i>' : '<i class="bi bi-dash-circle text-muted"></i>' ?></td>
                                    <td><?php if ($ls > 0): ?><span class="text-success" title="<?= $h(date('j M Y, H:i', $ls)) ?>"><i class="bi bi-broadcast me-1"></i><?= $h($ago(date('Y-m-d H:i:s', $ls))) ?></span><?php else: ?><span class="text-muted" data-tip="This property has never reported in — check that its beacon/Site ID is wired and it has been visited"><i class="bi bi-exclamation-circle me-1"></i>never</span><?php endif; ?></td>
                                    <td style="text-align:right;white-space:nowrap">
                                        <button class="vs-del" style="color:var(--pp-accent)" data-site-snippet="<?= $h((string) $sid) ?>" data-site-label="<?= $h((string) $st['label']) ?>" title="Show embed snippet"><i class="bi bi-code-slash"></i></button>
                                        <button class="vs-del" data-site-regen="<?= $h((string) $sid) ?>" title="Regenerate secret"><i class="bi bi-arrow-repeat"></i></button>
                                        <button class="vs-del" data-site-del="<?= $h((string) $sid) ?>" title="Delete property"><i class="bi bi-trash"></i></button>
                                    </td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    </div>
                <?php endif; ?>
            </div>
        </div>

        <!-- Online now -->
        <div class="vs-sec">
            <div class="vs-h">
                <h2><i class="bi bi-broadcast"></i> Online now</h2>
                <span class="vs-live-pill on"><span class="dot"></span> <span id="vsLiveHeadN"><?= count($liveNow) ?></span> live · last 5 min</span>
            </div>
            <div class="vs-body"><div class="vs-live" id="vsLiveList"><?php
                if (!$liveNow) {
                    echo '<div class="vs-empty sm">Nobody is browsing right now.</div>';
                } else {
                    foreach ($liveNow as $l) {
                        $cn = (string) ($l['country_name'] ?: ($l['country'] ?: 'Unknown'));
                        $cc = strtoupper((string) $l['country']);
                        echo '<div class="vs-liverow">'
                           . '<span class="av"><i class="bi ' . $devIcon((string) $l['device']) . '"></i><span class="ping"></span></span>'
                           . '<div class="who">'
                           . '<div class="l1"><span>' . $V::flag((string) $l['country']) . '</span> <span>' . $h($cn) . '</span>'
                           . ($cc ? ' <span class="cc">' . $h($cc) . '</span>' : '') . '</div>'
                           . '<div class="l2"><span>' . $h((string) $l['browser']) . '</span><span>·</span>'
                           . '<span>' . $h((string) $l['device']) . '</span>'
                           . '<span class="pth" title="' . $h((string) $l['last_path']) . '">' . $h((string) $l['last_path']) . '</span></div>'
                           . '</div>'
                           . '<span class="when">' . $h($ago((string) $l['last_seen'])) . '</span>'
                           . '</div>';
                    }
                }
            ?></div></div>
        </div>

        <!-- Recent visits -->
        <div class="vs-sec">
            <div class="vs-h">
                <h2><i class="bi bi-list-ul"></i> Recent visits <span class="chip" id="vsTotalChip"><?= number_format((int) $vTotal) ?> total</span></h2>
                <div class="d-flex gap-2 align-items-center flex-wrap">
                    <button class="vs-btn danger" id="vsDelBots" data-tip="Remove every crawler / bot row"><i class="bi bi-robot"></i> Delete bots</button>
                    <button class="vs-btn danger" id="vsClear" data-tip="Delete every recorded visit"><i class="bi bi-trash"></i> Clear all</button>
                </div>
            </div>
            <?php if ($vTotal === 0 && $vQuery === ''): ?>
                <div class="vs-empty">Nothing recorded yet. Visits appear here as soon as someone opens the app.</div>
            <?php else: ?>
                <div class="vs-listbar">
                    <div class="search">
                        <i class="bi bi-search"></i>
                        <input type="search" id="vsSearch" class="form-control form-control-sm" value="<?= $h($vQuery) ?>"
                               placeholder="Filter by country, IP, browser, page, referrer or user…" autocomplete="off">
                    </div>
                    <label class="vs-perpage">Rows per page
                        <select id="vsPerPage" class="form-select form-select-sm">
                            <?php foreach ([10, 25, 50, 100] as $pp): ?>
                                <option value="<?= $pp ?>"<?= $pp === $vPerPage ? ' selected' : '' ?>><?= $pp ?></option>
                            <?php endforeach; ?>
                        </select>
                    </label>
                </div>
                <div class="table-responsive">
                <table class="vs-t" id="vsTable">
                    <thead><tr><th>Where</th><th>Visitor</th><th>Device</th><th>Engagement</th><th>Last seen</th><th></th></tr></thead>
                    <tbody id="vsTbody">
                        <?php include __DIR__ . '/partials/visitor_rows.php'; ?>
                    </tbody>
                </table>
                </div>
                <div class="vs-pager">
                    <span class="showing" id="vsShowing"></span>
                    <div class="vs-pages" id="vsPages"></div>
                </div>
            <?php endif; ?>
        </div>

        <div class="vs-note">
            <i class="bi bi-shield-lock"></i>
            <div><strong>Privacy &amp; honesty.</strong> Location is resolved offline from a local IP database — no visitor
                is sent to a third party unless you switch on the API fallback. Do-Not-Track / Global-Privacy-Control are honoured.
                Retention is <strong><?= (int) ($vSettings['retention_days'] ?? 180) ?> days</strong>
                <?= !empty($vSettings['anonymise_ip']) ? 'with addresses anonymised' : 'with full addresses stored' ?>.
                A visitor's <em>MAC address</em> is deliberately not shown — it never reaches a web server (routers strip it at the first hop),
                and "laptop vs desktop" isn't detectable either, so devices are reported as mobile / tablet / desktop.</div>
        </div>
    </div>

    <!-- Detail drawer -->
    <div class="vs-drawer-back" id="vsBack"></div>
    <aside class="vs-drawer" id="vsDrawer" aria-hidden="true">
        <div class="dh"><div><h2 style="font-size:1rem;font-weight:800;margin:0" id="vsDTitle">Visit</h2><div class="small text-muted" id="vsDSub"></div></div>
            <button class="btn btn-sm btn-outline-secondary" id="vsDClose"><i class="bi bi-x-lg"></i></button></div>
        <div class="db0" id="vsDBody"></div>
    </aside>

    <script>
    (function () {
        var BASE = <?= json_encode($u('')) ?>, CSRF = <?= json_encode($csrf) ?>;
        var CFG  = <?= json_encode($vSettings) ?>, GEO = <?= json_encode($geoSettings) ?>, GEOST = <?= json_encode($geoStatus) ?>;
        var COLLECTOR = <?= json_encode($collectorUrl, JSON_UNESCAPED_SLASHES) ?>, SNIPBASE = <?= json_encode($analyticsSnippetBase, JSON_UNESCAPED_SLASHES) ?>;
        var SLABEL = <?= json_encode($serverLabel) ?>;
        var VP = { page: <?= (int) ($vPage ?? 1) ?>, pages: <?= (int) ($vPages ?? 1) ?>, total: <?= (int) ($vTotal ?? 0) ?>,
                   perPage: <?= (int) ($vPerPage ?? 25) ?>, q: <?= json_encode($vQuery ?? '') ?>, bots: <?= $showBots ? 'true' : 'false' ?> };
        var FLAG = function (cc) { if (!/^[A-Za-z]{2}$/.test(cc || '')) return '🌐';
            cc = cc.toUpperCase(); return String.fromCodePoint(0x1F1E6 + cc.charCodeAt(0) - 65, 0x1F1E6 + cc.charCodeAt(1) - 65); };
        var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
        var DEVICON = function (d) { return { desktop: 'bi-display', mobile: 'bi-phone', tablet: 'bi-tablet' }[String(d || '').toLowerCase()] || 'bi-question-circle'; };
        function post(body) {
            body._token = CSRF;
            return fetch(BASE + '/platform/visitors/action', { method: 'POST', credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
                .then(function (r) { return r.text().then(function (t) {
                    var j = {}; try { j = JSON.parse(t); } catch (e) {}   // tolerate a login-redirect HTML page
                    return { ok: r.ok, j: j };
                }); })
                .catch(function () { return { ok: false, j: {} }; });     // never spam the console on a dropped request
        }
        function reload() { location.reload(); }
        function dur(s) { s = parseInt(s, 10) || 0; if (s <= 0) return '—'; if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'; return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; }

        // ── Row delete ──
        document.addEventListener('click', function (e) {
            var d = e.target.closest('[data-del]');
            if (!d) return;
            e.stopPropagation();
            post({ action: 'delete', id: parseInt(d.getAttribute('data-del'), 10) }).then(function (o) {
                if (o.ok && o.j.success) { var tr = d.closest('tr'); if (tr) tr.remove(); }
                else Swal.fire({ icon: 'error', title: 'Could not delete' });
            });
        });

        // ── Drill-down drawer ──
        var back = document.getElementById('vsBack'), drawer = document.getElementById('vsDrawer');
        function closeDrawer() { drawer.classList.remove('on'); back.classList.remove('on'); drawer.setAttribute('aria-hidden', 'true'); }
        document.getElementById('vsDClose').addEventListener('click', closeDrawer);
        back.addEventListener('click', closeDrawer);
        document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeDrawer(); });

        function kv(label, val) { return val == null || val === '' ? '' : '<dt>' + esc(label) + '</dt><dd>' + esc(val) + '</dd>'; }
        function openDetail(id) {
            document.getElementById('vsDBody').innerHTML = '<div class="vs-empty">Loading…</div>';
            drawer.classList.add('on'); back.classList.add('on'); drawer.setAttribute('aria-hidden', 'false');
            post({ action: 'detail', id: id }).then(function (o) {
                var v = o.j && o.j.visit;
                if (!v || !v.id) { document.getElementById('vsDBody').innerHTML = '<div class="vs-empty">Visit not found.</div>'; return; }
                document.getElementById('vsDTitle').innerHTML = FLAG(v.country) + ' ' + esc(v.country_name || v.country || 'Unknown') + (v.is_return == 1 ? ' <span class="vs-tag ret">returning</span>' : '') + (v.is_bot == 1 ? ' <span class="vs-tag bot">bot</span>' : '');
                document.getElementById('vsDSub').textContent = (v.ip || '—') + (v.username ? ' · ' + v.username : '');
                var loc = [v.city, v.region].filter(Boolean).join(', ');
                var html = '<div class="vs-sub2" style="margin-top:0">Location</div><dl class="vs-kv">'
                    + kv('Country', (v.country_name || '') + (v.country ? ' (' + v.country + ')' : ''))
                    + kv('Region / city', loc) + kv('Timezone', v.timezone)
                    + kv('Coordinates', (v.latitude && v.longitude) ? (v.latitude + ', ' + v.longitude) : '')
                    + kv('Source', ({ db: 'Local IP database', cdn: 'CDN header', api: 'IP lookup API', timezone: 'Browser timezone', none: 'Unknown', private: 'Local network' }[v.geo_source] || v.geo_source))
                    + kv('IP address', v.ip)
                    + kv('Network', v.org ? ((v.asn > 0 ? 'AS' + v.asn + ' · ' : '') + v.org + (v.is_hosting == 1 ? ' (hosting/VPN)' : '')) : '')
                    + kv('Server seen on', v.server)
                    + (v.client_type ? kv('Caller type', v.client_type) : '') + '</dl>'
                    + '<div class="vs-sub2">Device &amp; browser</div><dl class="vs-kv">'
                    + kv('Browser', (v.browser || '') + ' ' + (v.browser_version || '') + (v.engine ? ' · ' + v.engine : ''))
                    + kv('Operating system', (v.platform || '') + ' ' + (v.platform_version || ''))
                    + kv('Device', (v.device || '') + (v.device_model ? ' · ' + v.device_model : ''))
                    + kv('Touch screen', v.is_touch == 1 ? 'Yes' : (v.enriched == 1 ? 'No' : ''))
                    + kv('Screen', v.screen_w > 0 ? (v.screen_w + '×' + v.screen_h + (v.dpr > 0 ? ' @' + v.dpr + 'x' : '')) : '')
                    + kv('Viewport', v.viewport_w > 0 ? (v.viewport_w + '×' + v.viewport_h) : '')
                    + kv('Colour depth', v.color_depth > 0 ? v.color_depth + '-bit' : '')
                    + kv('CPU cores', v.cpu_cores > 0 ? v.cpu_cores : '') + kv('Memory', v.device_memory > 0 ? v.device_memory + ' GB' : '')
                    + kv('Connection', v.connection) + kv('Prefers dark', v.prefers_dark == null ? '' : (v.prefers_dark == 1 ? 'Yes' : 'No'))
                    + kv('Languages', v.languages) + '</dl>'
                    + '<div class="vs-sub2">Engagement</div><dl class="vs-kv">'
                    + kv('Page views', v.page_views) + kv('Active time', dur(v.active_seconds))
                    + kv('Page-load', v.perf_load_ms > 0 ? v.perf_load_ms + ' ms' : '')
                    + kv('First seen', v.first_seen) + kv('Last seen', v.last_seen)
                    + kv('Referrer', v.referrer_domain ? (v.referrer_type + ' · ' + v.referrer_domain) : (v.referrer || 'direct'))
                    + kv('Campaign', [v.utm_source, v.utm_medium, v.utm_campaign].filter(Boolean).join(' / ')) + '</dl>';
                var j = v.journey || [];
                if (j.length) {
                    html += '<div class="vs-sub2">Page journey (' + j.length + ')</div><ul class="vs-journey">';
                    j.forEach(function (hh) {
                        html += '<li><div class="p">' + esc(hh.path) + '</div><div class="m">' + esc(hh.seen) + (hh.dwell_ms ? ' · ' + Math.round(hh.dwell_ms / 1000) + 's on page' : '') + '</div></li>';
                    });
                    html += '</ul>';
                }
                document.getElementById('vsDBody').innerHTML = html;
            });
        }
        var table = document.getElementById('vsTable');
        if (table) table.addEventListener('click', function (e) {
            if (e.target.closest('[data-del]')) return;
            var tr = e.target.closest('tr[data-id]'); if (tr) openDetail(parseInt(tr.getAttribute('data-id'), 10));
        });

        // ── Live auto-refresh ──
        function refreshLive() {
            post({ action: 'live' }).then(function (o) {
                if (!o.ok || !o.j.success || !o.j.summary) return;
                var n = o.j.summary.live || 0;
                document.getElementById('vsLiveN').textContent = n;
                var sl = document.getElementById('vsStatLive'); if (sl) sl.textContent = n;
                var hn = document.getElementById('vsLiveHeadN'); if (hn) hn.textContent = n;
                var list = document.getElementById('vsLiveList'), rows = o.j.live || [];
                if (!rows.length) { list.innerHTML = '<div class="vs-empty sm">Nobody is browsing right now.</div>'; return; }
                list.innerHTML = rows.map(function (l) {
                    var cc = (l.country || '').toUpperCase();
                    return '<div class="vs-liverow">'
                        + '<span class="av"><i class="bi ' + DEVICON(l.device) + '"></i><span class="ping"></span></span>'
                        + '<div class="who"><div class="l1"><span>' + FLAG(l.country) + '</span> <span>'
                        + esc(l.country_name || l.country || 'Unknown') + '</span>'
                        + (cc ? ' <span class="cc">' + esc(cc) + '</span>' : '') + '</div>'
                        + '<div class="l2"><span>' + esc(l.browser) + '</span><span>·</span><span>' + esc(l.device) + '</span>'
                        + '<span class="pth" title="' + esc(l.last_path) + '">' + esc(l.last_path) + '</span></div></div>'
                        + '<span class="when">' + esc(l.ago || '') + '</span></div>';
                }).join('');
            });
        }
        setInterval(refreshLive, 20000);

        // ── Delete bots / clear ──
        document.getElementById('vsDelBots').addEventListener('click', function () {
            Swal.fire({ icon: 'warning', title: 'Delete every bot row?', text: 'Crawler visits are removed. Human visits are untouched.',
                showCancelButton: true, confirmButtonText: 'Delete bots', confirmButtonColor: '#dc2626' })
                .then(function (r) { if (r.isConfirmed) post({ action: 'delete-bots' }).then(function (o) {
                    Swal.fire({ icon: 'success', title: 'Removed ' + (o.j.removed || 0) + ' row(s)', timer: 1300, showConfirmButton: false }).then(reload); }); });
        });
        document.getElementById('vsClear').addEventListener('click', function () {
            Swal.fire({ icon: 'warning', title: 'Clear the whole visitor log?', text: 'Every recorded visit is deleted. This cannot be undone.',
                showCancelButton: true, confirmButtonText: 'Clear everything', confirmButtonColor: '#dc2626' })
                .then(function (r) { if (r.isConfirmed) post({ action: 'clear' }).then(reload); });
        });

        // ── Settings (incl. geo database) ──
        document.getElementById('vsSettings').addEventListener('click', function () {
            var geoLine = GEOST.installed
                ? '<b>' + esc(GEOST.type) + '</b> · build ' + esc(GEOST.build || '?') + ' · ' + esc(GEOST.size_mb) + ' MB'
                : '<span style="color:#f59e0b">No local database installed — country falls back to a CDN header or the browser timezone.</span>';
            Swal.fire({
                title: 'Visitor analytics settings', width: 540,
                html: '<div style="text-align:left;font-size:.9rem">'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sEnabled"' + (CFG.enabled ? ' checked' : '') + '> Record visits</label>'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sBots"' + (CFG.log_bots ? ' checked' : '') + '> Also record bots &amp; crawlers</label>'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sJourney"' + (CFG.track_journey ? ' checked' : '') + '> Track page journeys (per-visit path history)</label>'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sAnon"' + (CFG.anonymise_ip ? ' checked' : '') + '> Anonymise IP addresses (drop the last octet)</label>'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sDnt"' + (CFG.honor_dnt ? ' checked' : '') + '> Honour Do-Not-Track / Global-Privacy-Control</label>'
                    + '<label class="d-block mt-2 mb-2">Keep visits for <input type="number" id="sDays" min="0" max="3650" value="' + (CFG.retention_days || 0) + '" style="width:88px;margin-left:.4rem"> days <span style="color:#94a3b8">(0 = forever)</span></label>'
                    + '<label class="d-block mb-3">This server\'s label <input type="text" id="sServer" value="' + esc(SLABEL) + '" placeholder="primary · ppphp.ppradhan.com" style="width:100%;margin-top:.2rem"> <span style="color:#94a3b8;font-size:.78rem">shown in the "By server" breakdown</span></label>'
                    + '<hr><div style="font-weight:800;font-size:.82rem;margin:.2rem 0 .5rem">Location &amp; network databases</div>'
                    + '<div style="font-size:.8rem;color:#94a3b8;margin-bottom:.5rem">Network (ASN) DB: ' + (GEOST.asn ? '<span style="color:#16a34a">installed</span> — organisation + hosting/VPN detection on' : 'not installed — the download button below fetches it too') + '</div>'
                    + '<div id="geoStat" style="font-size:.82rem;margin-bottom:.5rem">' + geoLine + '</div>'
                    + '<button type="button" id="geoUpdate" class="btn btn-sm btn-outline-primary mb-3"><i class="bi bi-cloud-download"></i> Download / update free DB-IP database</button>'
                    + '<label class="d-flex gap-2 align-items-center mb-2"><input type="checkbox" id="sApi"' + (GEO.api_enabled ? ' checked' : '') + '> Fall back to an online lookup when the database misses an IP <span style="color:#94a3b8">(sends that IP to a third party; cached)</span></label>'
                    + '<label class="d-block">Provider <select id="sApiProv" class="form-select form-select-sm d-inline-block" style="width:auto;margin-left:.4rem">'
                    + '<option value="ipwho.is"' + (GEO.api_provider === 'ipwho.is' ? ' selected' : '') + '>ipwho.is (https)</option>'
                    + '<option value="ip-api.com"' + (GEO.api_provider === 'ip-api.com' ? ' selected' : '') + '>ip-api.com</option></select></label>'
                    + '</div>',
                showCancelButton: true, confirmButtonText: 'Save', focusConfirm: false,
                didOpen: function () {
                    document.getElementById('geoUpdate').addEventListener('click', function () {
                        var btn = this; btn.disabled = true; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Downloading…';
                        post({ action: 'geodb-update' }).then(function (o) {
                            btn.disabled = false; btn.innerHTML = '<i class="bi bi-cloud-download"></i> Download / update free DB-IP database';
                            var st = o.j.status || {};
                            document.getElementById('geoStat').innerHTML = st.installed
                                ? '<b>' + esc(st.type) + '</b> · build ' + esc(st.build || '?') + ' · ' + esc(st.size_mb) + ' MB'
                                : '<span style="color:#dc2626">' + esc(o.j.message || 'Update failed') + '</span>';
                            if (o.j.success) { GEOST = st; }
                        });
                    });
                },
                preConfirm: function () {
                    return {
                        settings: {
                            enabled: document.getElementById('sEnabled').checked,
                            log_bots: document.getElementById('sBots').checked,
                            track_journey: document.getElementById('sJourney').checked,
                            anonymise_ip: document.getElementById('sAnon').checked,
                            honor_dnt: document.getElementById('sDnt').checked,
                            retention_days: parseInt(document.getElementById('sDays').value, 10) || 0
                        },
                        server_label: document.getElementById('sServer').value,
                        geo: {
                            api_enabled: document.getElementById('sApi').checked,
                            api_provider: document.getElementById('sApiProv').value,
                            db_path: GEO.db_path || ''
                        }
                    };
                }
            }).then(function (r) {
                if (r.isConfirmed) post({ action: 'settings', settings: r.value.settings, geo: r.value.geo, server_label: r.value.server_label }).then(function (o) {
                    if (o.ok && o.j.success) Swal.fire({ icon: 'success', title: 'Saved', timer: 1200, showConfirmButton: false }).then(reload);
                    else Swal.fire({ icon: 'error', title: 'Could not save' });
                });
            });
        });

        // ── Properties (report-in sites) ──
        function snippetHtml(siteId) {
            var browser = '<script>window.__PP_ANALYTICS={url:"' + COLLECTOR + '",site:"' + siteId + '"};<\/script>\n'
                        + '<script src="' + SNIPBASE + '/assets/js/pp-analytics.js" defer><\/script>';
            return browser;
        }
        function showSnippet(siteId, label, secret) {
            var html = '<div style="text-align:left">'
                + '<p style="font-size:.86rem;margin:.2rem 0 .4rem"><b>Browser snippet</b> — paste before &lt;/body&gt; on <b>' + esc(label || siteId) + '</b>:</p>'
                + '<div class="vs-snippet">' + esc(snippetHtml(siteId)) + '</div>'
                + '<p style="font-size:.86rem;margin:.8rem 0 .4rem"><b>Site ID</b> <code>' + esc(siteId) + '</code></p>';
            if (secret) html += '<p style="font-size:.86rem;margin:.4rem 0"><b>Server secret</b> (for PHP-side reporting — shown once, store it now):<br><code>' + esc(secret) + '</code></p>';
            else html += '<p style="font-size:.8rem;color:#94a3b8;margin:.4rem 0">The server secret is only shown when created or regenerated.</p>';
            html += '</div>';
            Swal.fire({ title: 'Embed on your property', html: html, width: 640, confirmButtonText: 'Done' });
        }
        var addBtn = document.getElementById('vsAddSite');
        if (addBtn) addBtn.addEventListener('click', function () {
            Swal.fire({
                title: 'Add a property', width: 480,
                html: '<div style="text-align:left;font-size:.9rem">'
                    + '<label class="d-block mb-2">Name<input id="pName" class="form-control form-control-sm" placeholder="Marketing site"></label>'
                    + '<label class="d-block mb-2">Channel <select id="pChan" class="form-select form-select-sm">'
                    + '<option value="marketing">marketing</option><option value="community">community</option>'
                    + '<option value="app">app</option><option value="update">update</option></select></label>'
                    + '<label class="d-flex gap-2 align-items-center"><input type="checkbox" id="pBrowser" checked> Allow browser tracking (embed snippet)</label>'
                    + '</div>',
                showCancelButton: true, confirmButtonText: 'Create',
                preConfirm: function () { return { label: document.getElementById('pName').value, channel: document.getElementById('pChan').value, browser_enabled: document.getElementById('pBrowser').checked }; }
            }).then(function (r) {
                if (!r.isConfirmed) return;
                post({ action: 'site-create', label: r.value.label, channel: r.value.channel, browser_enabled: r.value.browser_enabled }).then(function (o) {
                    if (o.ok && o.j.success && o.j.site) showSnippet(o.j.site.site_id, o.j.site.label, o.j.site.secret);
                    else Swal.fire({ icon: 'error', title: 'Could not create' });
                    setTimeout(reload, 400);
                });
            });
        });
        document.addEventListener('click', function (e) {
            var sn = e.target.closest('[data-site-snippet]');
            if (sn) { showSnippet(sn.getAttribute('data-site-snippet'), sn.getAttribute('data-site-label'), ''); return; }
            var rg = e.target.closest('[data-site-regen]');
            if (rg) {
                Swal.fire({ icon: 'warning', title: 'Regenerate secret?', text: 'The old secret stops working immediately.', showCancelButton: true, confirmButtonText: 'Regenerate' })
                    .then(function (r) { if (r.isConfirmed) post({ action: 'site-regenerate', id: rg.getAttribute('data-site-regen') }).then(function (o) {
                        if (o.ok && o.j.success) showSnippet(rg.getAttribute('data-site-regen'), '', o.j.secret);
                        else Swal.fire({ icon: 'error', title: 'Could not regenerate' }); }); });
                return;
            }
            var dl = e.target.closest('[data-site-del]');
            if (dl) {
                Swal.fire({ icon: 'warning', title: 'Delete this property?', text: 'It stops reporting. Its past data stays.', showCancelButton: true, confirmButtonText: 'Delete', confirmButtonColor: '#dc2626' })
                    .then(function (r) { if (r.isConfirmed) post({ action: 'site-delete', id: dl.getAttribute('data-site-del') }).then(reload); });
            }
        });

        // ── Recent visits: server-side pagination + search ──
        var tbody = document.getElementById('vsTbody'), pagesEl = document.getElementById('vsPages'),
            showingEl = document.getElementById('vsShowing'), tableEl = document.getElementById('vsTable'),
            totalChip = document.getElementById('vsTotalChip');

        function pageBtn(n, label, opts) {
            opts = opts || {};
            var b = document.createElement('button');
            b.className = 'vs-pg' + (opts.active ? ' active' : '') + (opts.gap ? ' gap' : '');
            b.innerHTML = label != null ? label : n;
            if (opts.gap) { b.disabled = true; return b; }
            if (opts.disabled) b.disabled = true;
            if (!opts.active && !opts.disabled) b.addEventListener('click', function () { loadRecent(n); });
            if (opts.tip) b.title = opts.tip;
            return b;
        }
        function renderPager() {
            if (!pagesEl) return;
            var p = VP.page, tp = VP.pages, total = VP.total, per = VP.perPage;
            var from = total === 0 ? 0 : (p - 1) * per + 1, to = Math.min(total, p * per);
            if (showingEl) showingEl.textContent = total === 0 ? 'No matching visits'
                : ('Showing ' + from.toLocaleString() + '–' + to.toLocaleString() + ' of ' + total.toLocaleString());
            pagesEl.innerHTML = '';
            if (tp <= 1) return;
            pagesEl.appendChild(pageBtn(p - 1, '<i class="bi bi-chevron-left"></i>', { disabled: p <= 1, tip: 'Previous' }));
            // windowed page numbers: 1 … p-1 p p+1 … last
            var set = {}; [1, 2, tp - 1, tp, p - 1, p, p + 1].forEach(function (n) { if (n >= 1 && n <= tp) set[n] = 1; });
            var nums = Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
            var prev = 0;
            nums.forEach(function (n) {
                if (n - prev > 1) pagesEl.appendChild(pageBtn(0, '…', { gap: true }));
                pagesEl.appendChild(pageBtn(n, String(n), { active: n === p }));
                prev = n;
            });
            pagesEl.appendChild(pageBtn(p + 1, '<i class="bi bi-chevron-right"></i>', { disabled: p >= tp, tip: 'Next' }));
        }
        var recentReq = 0;
        function loadRecent(page) {
            if (!tbody) return;
            var mine = ++recentReq;
            if (tableEl) tableEl.classList.add('loading');
            post({ action: 'recent', page: page, perPage: VP.perPage, q: VP.q, bots: VP.bots }).then(function (o) {
                if (mine !== recentReq) return;                 // a newer request superseded this one
                if (tableEl) tableEl.classList.remove('loading');
                if (!o.ok || !o.j.success) return;
                tbody.innerHTML = o.j.html || '';
                VP.page = o.j.page; VP.pages = o.j.pages; VP.total = o.j.total; VP.perPage = o.j.perPage;
                if (totalChip) totalChip.textContent = VP.total.toLocaleString() + ' total';
                renderPager();
            });
        }
        var searchBox = document.getElementById('vsSearch'), perSel = document.getElementById('vsPerPage'), searchT;
        if (searchBox) searchBox.addEventListener('input', function () {
            clearTimeout(searchT);
            searchT = setTimeout(function () { VP.q = searchBox.value.trim(); loadRecent(1); }, 350);
        });
        if (perSel) perSel.addEventListener('change', function () { VP.perPage = parseInt(perSel.value, 10) || 25; loadRecent(1); });
        renderPager();   // initial footer for the server-rendered first page
    })();
    </script>

    <!-- World map data (Natural Earth 110m, public domain) — bundled LOCALLY, no CDN. -->
    <script src="<?= $assetsUrl ?>/js/pp-world.js?v=<?= (int) @filemtime(constant('BASE_PATH') . '/public/assets/js/pp-world.js') ?>"></script>
    <!-- Interactive charts: trend + real 3D Earth + donuts. Hand-rolled Canvas/SVG, no library, no CDN. -->
    <script>
    (function () {
        var VIS = {
            daily: <?= json_encode(array_map(static fn ($d) => ['label' => date('j M', strtotime((string) $d['day']) ?: time()), 'visits' => (int) $d['visits'], 'views' => (int) ($d['views'] ?? 0), 'returning' => (int) ($d['returning'] ?? 0)], $daily), JSON_UNESCAPED_SLASHES) ?>,
            countries: <?= json_encode(array_map(static fn ($c) => ['code' => strtoupper((string) ($c['key'] ?? '')), 'name' => (string) ($c['country_name'] ?: ($c['key'] ?: 'Unknown')), 'visits' => (int) $c['visits']], $countries), JSON_UNESCAPED_SLASHES) ?>,
            devices: <?= json_encode(array_map(static fn ($r) => ['label' => ucfirst((string) ($r['key'] ?: 'unknown')), 'value' => (int) ($r['visits'] ?? 0)], $devices), JSON_UNESCAPED_SLASHES) ?>,
            sources: <?= json_encode(array_map(static fn ($r) => ['label' => ucfirst((string) ($r['key'] ?: 'direct')), 'value' => (int) ($r['visits'] ?? 0)], $refTypes), JSON_UNESCAPED_SLASHES) ?>,
            actionUrl: <?= json_encode($u('/platform/visitors/action'), JSON_UNESCAPED_SLASHES) ?>,
            csrf: <?= json_encode($csrf, JSON_UNESCAPED_SLASHES) ?>,
            heatmap: <?= json_encode(is_array($heatmap ?? null) ? $heatmap : ['grid' => [], 'max' => 0], JSON_UNESCAPED_SLASHES) ?>
        };
        // ISO-2 country centroids [lat, lng] for globe placement (local data, no lib).
        var CENTROIDS = {
            US:[39.8,-98.6],CA:[56.1,-106.3],MX:[23.6,-102.6],BR:[-14.2,-51.9],AR:[-38.4,-63.6],CL:[-35.7,-71.5],CO:[4.6,-74.3],PE:[-9.2,-75],VE:[6.4,-66.6],
            GB:[54,-2],IE:[53.4,-8],FR:[46.6,2.2],DE:[51.2,10.4],ES:[40.2,-3.7],PT:[39.4,-8.2],IT:[41.9,12.6],NL:[52.1,5.3],BE:[50.5,4.5],CH:[46.8,8.2],AT:[47.5,14.6],
            SE:[60.1,18.6],NO:[60.5,8.5],DK:[56,9.5],FI:[61.9,25.7],PL:[51.9,19.1],CZ:[49.8,15.5],RO:[45.9,24.9],GR:[39.1,21.8],UA:[48.4,31.2],RU:[61.5,105.3],TR:[38.9,35.2],
            IN:[22,79],NP:[28.4,84.1],PK:[30.4,69.3],BD:[23.7,90.4],LK:[7.9,80.8],CN:[35.9,104.2],JP:[36.2,138.3],KR:[36.5,127.9],TW:[23.7,121],HK:[22.3,114.2],
            SG:[1.35,103.8],MY:[4.2,101.9],TH:[15.9,100.9],VN:[14.1,108.3],PH:[12.9,121.8],ID:[-0.8,113.9],AU:[-25.3,133.8],NZ:[-40.9,174.9],
            AE:[23.4,53.8],SA:[23.9,45.1],QA:[25.3,51.2],IL:[31,34.9],EG:[26.8,30.8],ZA:[-30.6,22.9],NG:[9.1,8.7],KE:[-0.02,37.9],GH:[7.9,-1.0],MA:[31.8,-7.1],DZ:[28,1.7],ET:[9.1,40.5],
            BG:[42.7,25.5],HU:[47.2,19.5],SK:[48.7,19.7],HR:[45.1,15.2],RS:[44,21],SI:[46.2,15],LT:[55.2,23.9],LV:[56.9,24.6],EE:[58.6,25],IS:[64.9,-19],LU:[49.8,6.1],
            BY:[53.7,27.9],KZ:[48,66.9],UZ:[41.4,64.6],GE:[42.3,43.4],AM:[40.1,45],AZ:[40.1,47.6],IR:[32.4,53.7],IQ:[33.2,43.7],JO:[30.6,36.2],LB:[33.9,35.9],KW:[29.3,47.5],
            BH:[26,50.6],OM:[21,57],YE:[15.6,48.5],AF:[33.9,67.7],MM:[21.9,95.9],KH:[12.6,104.9],LA:[19.9,102.5],MN:[46.9,103.8],
            TN:[33.9,9.6],LY:[26.3,17.2],SD:[12.9,30.2],TZ:[-6.4,34.9],UG:[1.4,32.3],CM:[7.4,12.4],CI:[7.5,-5.5],SN:[14.5,-14.5],ZM:[-13.1,27.8],ZW:[-19,29.2],AO:[-11.2,17.9],MZ:[-18.7,35.5],
            CR:[9.7,-83.8],PA:[8.5,-80.8],GT:[15.8,-90.2],DO:[18.7,-70.2],EC:[-1.8,-78.2],UY:[-32.5,-55.8],PY:[-23.4,-58.4],BO:[-16.3,-63.6],
            CY:[35.1,33.4],MT:[35.9,14.4],AL:[41.2,20.2],MK:[41.6,21.7],BA:[43.9,17.7],MD:[47.4,28.4],
            FJ:[-17.7,178],PG:[-6.3,143.9],NC:[-20.9,165.6]
        };

        function cssVar(n, fb) { var v = getComputedStyle(document.body).getPropertyValue(n); return (v && v.trim()) || fb; }
        function toRGB(c) { c = String(c || '').trim();
            var m = c.match(/^#([0-9a-f]{3})$/i); if (m) { var x = m[1]; return [parseInt(x[0]+x[0],16),parseInt(x[1]+x[1],16),parseInt(x[2]+x[2],16)]; }
            m = c.match(/^#([0-9a-f]{6})$/i); if (m) return [parseInt(m[1].slice(0,2),16),parseInt(m[1].slice(2,4),16),parseInt(m[1].slice(4,6),16)];
            m = c.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i); if (m) return [+m[1],+m[2],+m[3]];
            return [79,70,229]; }
        function rgba(rgb, a) { return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + a + ')'; }
        function theme() { var a = cssVar('--pp-accent', '#4f46e5');
            return { accent: a, accentRGB: toRGB(a), text: cssVar('--pp-text', '#172033'), muted: cssVar('--pp-text-muted', '#94a3b8'),
                     grid: cssVar('--pp-border', '#e6e9f0'), surface: cssVar('--pp-surface', '#ffffff') }; }

        // ── Trend (area/line, range + metric toggle, hover, live) ────────
        var metric = 'visits';
        var trendData = VIS.daily;           // swapped out on range change / live refresh
        function drawTrend() {
            var cv = document.getElementById('vsTrend'); if (!cv || !trendData.length) return;
            var t = theme(), dpr = window.devicePixelRatio || 1, W = cv.clientWidth || 400, H = 200;
            cv.width = W * dpr; cv.height = H * dpr; var c = cv.getContext('2d'); c.setTransform(dpr, 0, 0, dpr, 0, 0); c.clearRect(0, 0, W, H);
            var padL = 30, padR = 8, padT = 10, padB = 20, ih = H - padT - padB, iw = W - padL - padR, n = trendData.length;
            var vals = trendData.map(function (d) { return d[metric] || 0; });
            var max = Math.max.apply(null, vals.concat([1])), step = Math.pow(10, Math.floor(Math.log10(max))), yMax = Math.ceil(max / step) * step || 1;
            var xAt = function (i) { return padL + (n === 1 ? iw / 2 : i / (n - 1) * iw); }, yAt = function (v) { return padT + ih - (v / yMax) * ih; };
            c.strokeStyle = t.grid; c.fillStyle = t.muted; c.font = '10px system-ui,sans-serif';
            for (var g = 0; g <= 3; g++) { var yv = yMax / 3 * g, y = yAt(yv); c.globalAlpha = .5; c.beginPath(); c.moveTo(padL, y); c.lineTo(W - padR, y); c.stroke(); c.globalAlpha = 1; c.fillText(String(Math.round(yv)), 2, y + 3); }
            var ex = Math.ceil(n / 6); trendData.forEach(function (d, i) { if (i % ex === 0 || i === n - 1) c.fillText(d.label, xAt(i) - 10, H - 5); });
            var grad = c.createLinearGradient(0, padT, 0, padT + ih); grad.addColorStop(0, rgba(t.accentRGB, .34)); grad.addColorStop(1, rgba(t.accentRGB, .02));
            c.beginPath(); c.moveTo(xAt(0), yAt(0)); vals.forEach(function (v, i) { c.lineTo(xAt(i), yAt(v)); }); c.lineTo(xAt(n - 1), yAt(0)); c.closePath(); c.fillStyle = grad; c.fill();
            c.beginPath(); vals.forEach(function (v, i) { i ? c.lineTo(xAt(i), yAt(v)) : c.moveTo(xAt(i), yAt(v)); }); c.strokeStyle = t.accent; c.lineWidth = 2; c.lineJoin = 'round'; c.stroke();
            cv._geo = { xAt: xAt, yAt: yAt, padL: padL, padR: padR, W: W, ih: ih, padT: padT, vals: vals };
        }
        (function () {
            var cv = document.getElementById('vsTrend'), tip = document.getElementById('vsTrendTip'); if (!cv) return;
            cv.addEventListener('mousemove', function (ev) {
                var g = cv._geo, n = trendData.length; if (!g || !n) return;
                var r = cv.getBoundingClientRect(), i = Math.round((ev.clientX - r.left - g.padL) / ((g.W - g.padL - g.padR) || 1) * (n - 1));
                i = Math.max(0, Math.min(n - 1, i)); var d = trendData[i], t = theme(); drawTrend();
                var c = cv.getContext('2d'), x = g.xAt(i), y = g.yAt(g.vals[i]);
                c.strokeStyle = t.muted; c.globalAlpha = .5; c.beginPath(); c.moveTo(x, g.padT); c.lineTo(x, g.padT + g.ih); c.stroke(); c.globalAlpha = 1;
                c.beginPath(); c.arc(x, y, 4, 0, 7); c.fillStyle = t.accent; c.fill(); c.strokeStyle = t.surface; c.lineWidth = 2; c.stroke();
                tip.innerHTML = '<b>' + (d[metric] || 0) + '</b> ' + metric + '<br><span style="color:var(--pp-text-muted)">' + d.label + '</span>';
                tip.style.opacity = 1; tip.style.left = Math.min(x + 12, g.W - 110) + 'px'; tip.style.top = Math.max(4, y - 38) + 'px';
            });
            cv.addEventListener('mouseleave', function () { tip.style.opacity = 0; drawTrend(); });
        })();
        var seg = document.getElementById('vsTrendSeg');
        if (seg) seg.addEventListener('click', function (e) { var b = e.target.closest('button[data-metric]'); if (!b) return;
            seg.querySelectorAll('button').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); metric = b.dataset.metric; drawTrend(); });

        // Range selector (24h/7d/30d/90d) + Live auto-refresh — fetch the trend action.
        var trendRange = '30d', trendTimer = null;
        function loadTrend() {
            fetch(VIS.actionUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ action: 'trend', range: trendRange, _token: VIS.csrf }) })
                .then(function (r) { return r.json(); })
                .then(function (j) {
                    if (!j || !j.success) return;
                    if (Array.isArray(j.series)) { trendData = j.series; drawTrend(); }
                    var live = document.getElementById('vsStatLive'); if (live && typeof j.live !== 'undefined') live.textContent = j.live;
                }).catch(function () {});
        }
        var rseg = document.getElementById('vsTrendRange');
        if (rseg) rseg.addEventListener('click', function (e) { var b = e.target.closest('button[data-range]'); if (!b) return;
            rseg.querySelectorAll('button').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); trendRange = b.dataset.range; loadTrend(); });
        var liveBtn = document.getElementById('vsTrendLive');
        if (liveBtn) liveBtn.addEventListener('click', function () {
            liveBtn.classList.toggle('on');
            if (liveBtn.classList.contains('on')) { trendTimer = setInterval(loadTrend, 20000); }
            else { clearInterval(trendTimer); trendTimer = null; }
        });

        // ── Real 3D Earth (bundled Natural Earth land, auto-rotate + drag + hover) ──
        (function () {
            var cv = document.getElementById('vsGlobe'); if (!cv) return;
            // Precompute unit-sphere [x,y,z] once (rotation per frame is then just a
            // cheap matrix multiply — no trig per point per frame).
            function unit(lat, lng) { var la = lat * Math.PI / 180, lo = lng * Math.PI / 180, cl = Math.cos(la);
                return [cl * Math.sin(lo), Math.sin(la), cl * Math.cos(lo)]; }
            var LAND = (window.PP_WORLD || []).map(function (ring) { return ring.map(function (p) { return unit(p[1], p[0]); }); });
            var pts = (VIS.countries || []).filter(function (c) { return CENTROIDS[c.code]; })
                .map(function (c) { return { u: unit(CENTROIDS[c.code][0], CENTROIDS[c.code][1]), v: c.visits, name: c.name, code: c.code }; });
            var maxV = pts.reduce(function (m, p) { return Math.max(m, p.v); }, 1);
            var yaw = -1.6, pitch = 0.33, spin = 0.0026, dragging = false, lx = 0, ly = 0, mx = null, my = null, tip = null;
            function mkTip() { if (tip) return tip; tip = document.createElement('div'); tip.className = 'vs-tt'; cv.parentNode.appendChild(tip); return tip; }
            function rot(u) { // yaw about Y, then pitch about X
                var cyw = Math.cos(yaw), syw = Math.sin(yaw), x1 = u[0] * cyw - u[2] * syw, z1 = u[0] * syw + u[2] * cyw;
                var cp = Math.cos(pitch), sp = Math.sin(pitch); return [x1, u[1] * cp - z1 * sp, u[1] * sp + z1 * cp]; }
            function draw() {
                var dpr = window.devicePixelRatio || 1, S = cv.clientWidth || 280, t = theme();
                cv.width = S * dpr; cv.height = S * dpr; var c = cv.getContext('2d'); c.setTransform(dpr, 0, 0, dpr, 0, 0); c.clearRect(0, 0, S, S);
                var cx = S / 2, cyc = S / 2, R = S / 2 - 16;
                // Soft outer atmosphere: brightest right at the limb, fading smoothly
                // OUTWARD (no hard band). The inner disc is painted over by the sphere,
                // so only the gentle outward falloff shows.
                var glowR = R * 0.15;
                var atm = c.createRadialGradient(cx, cyc, R, cx, cyc, R + glowR);
                atm.addColorStop(0, 'rgba(125,178,255,0.34)');
                atm.addColorStop(0.45, 'rgba(112,166,246,0.11)');
                atm.addColorStop(1, 'rgba(110,164,244,0)');
                c.beginPath(); c.arc(cx, cyc, R + glowR, 0, 7); c.fillStyle = atm; c.fill();
                // Ocean sphere (lit from top-left).
                c.save(); c.beginPath(); c.arc(cx, cyc, R, 0, 7); c.clip();
                var oc = c.createRadialGradient(cx - R * .35, cyc - R * .4, R * .1, cx, cyc, R * 1.05);
                oc.addColorStop(0, '#2b6ca3'); oc.addColorStop(0.55, '#1b4a77'); oc.addColorStop(1, '#0a2138');
                c.fillStyle = oc; c.fillRect(0, 0, S, S);
                // Faint graticule under the land.
                c.strokeStyle = 'rgba(255,255,255,0.06)'; c.lineWidth = .6;
                for (var la = -60; la <= 60; la += 30) { c.beginPath(); var st = false;
                    for (var lo = -180; lo <= 180; lo += 8) { var g = rot(unit(la, lo)); if (g[2] < 0) { st = false; continue; }
                        var gx = cx + g[0] * R, gy = cyc - g[1] * R; st ? c.lineTo(gx, gy) : c.moveTo(gx, gy); st = true; } c.stroke(); }
                for (var lo2 = -150; lo2 < 180; lo2 += 30) { c.beginPath(); var s2 = false;
                    for (var la2 = -90; la2 <= 90; la2 += 8) { var g2 = rot(unit(la2, lo2)); if (g2[2] < 0) { s2 = false; continue; }
                        var gx2 = cx + g2[0] * R, gy2 = cyc - g2[1] * R; s2 ? c.lineTo(gx2, gy2) : c.moveTo(gx2, gy2); s2 = true; } c.stroke(); }
                // Land: fill each visible ring + a subtle border (country outlines).
                c.lineWidth = .5; c.strokeStyle = 'rgba(15,42,25,0.5)';
                for (var i = 0; i < LAND.length; i++) {
                    var ring = LAND[i], started = false, vis = 0; c.beginPath();
                    for (var j = 0; j < ring.length; j++) { var r = rot(ring[j]); if (r[2] < 0) continue; vis++;
                        var px = cx + r[0] * R, py = cyc - r[1] * R; started ? c.lineTo(px, py) : c.moveTo(px, py); started = true; }
                    if (vis < 3) continue; c.closePath(); c.fillStyle = '#3f7d54'; c.fill(); c.stroke();
                }
                // Sphere shading: bright top-left highlight + dark bottom-right terminator.
                var hi = c.createRadialGradient(cx - R * .38, cyc - R * .42, 1, cx - R * .38, cyc - R * .42, R * 1.5);
                hi.addColorStop(0, 'rgba(255,255,255,0.28)'); hi.addColorStop(0.5, 'rgba(255,255,255,0)');
                c.fillStyle = hi; c.fillRect(0, 0, S, S);
                var sh = c.createRadialGradient(cx + R * .45, cyc + R * .5, R * .2, cx + R * .3, cyc + R * .35, R * 1.3);
                sh.addColorStop(0, 'rgba(2,8,22,0)'); sh.addColorStop(0.7, 'rgba(2,8,22,0.12)'); sh.addColorStop(1, 'rgba(1,6,18,0.62)');
                c.fillStyle = sh; c.fillRect(0, 0, S, S);
                // Inner atmospheric rim — the thin blue glow that hugs Earth's edge from
                // space (replaces the old hard outline that looked like noise).
                var rim = c.createRadialGradient(cx, cyc, R * 0.80, cx, cyc, R);
                rim.addColorStop(0, 'rgba(130,185,255,0)');
                rim.addColorStop(0.88, 'rgba(130,185,255,0)');
                rim.addColorStop(0.97, 'rgba(148,198,255,0.22)');
                rim.addColorStop(1, 'rgba(190,220,255,0.30)');
                c.fillStyle = rim; c.fillRect(0, 0, S, S);
                c.restore();
                // Visitor markers on top (depth-sorted, glowing).
                var proj = pts.map(function (p) { return { r: rot(p.u), p: p }; }).filter(function (o) { return o.r[2] > -0.03; }).sort(function (a, b) { return a.r[2] - b.r[2]; });
                var hover = null, hx = 0, hy = 0;
                proj.forEach(function (o) {
                    var sx = cx + o.r[0] * R, sy = cyc - o.r[1] * R, rad = 2.4 + Math.sqrt(o.p.v / maxV) * 7, dep = 0.5 + 0.5 * o.r[2];
                    c.beginPath(); c.arc(sx, sy, rad + 4, 0, 7); c.fillStyle = rgba(t.accentRGB, 0.16 * dep); c.fill();
                    c.beginPath(); c.arc(sx, sy, rad, 0, 7); c.fillStyle = rgba(t.accentRGB, dep); c.fill();
                    c.beginPath(); c.arc(sx, sy, rad, 0, 7); c.strokeStyle = 'rgba(255,255,255,' + (0.7 * dep) + ')'; c.lineWidth = 1; c.stroke();
                    if (mx !== null) { var d = Math.hypot(mx - sx, my - sy); if (d < rad + 5) { hover = o.p; hx = sx; hy = sy; } }
                });
                if (hover) { var tp = mkTip(); tp.innerHTML = '<b>' + hover.v + '</b> · ' + hover.name + ' (' + hover.code + ')';
                    tp.style.opacity = 1; tp.style.left = Math.min(hx + 10, S - 100) + 'px'; tp.style.top = Math.max(2, hy - 34) + 'px'; }
                else if (tip) tip.style.opacity = 0;
            }
            cv.addEventListener('pointermove', function (e) { var r = cv.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top;
                if (dragging) { yaw += (e.clientX - lx) * 0.01; pitch = Math.max(-1.35, Math.min(1.35, pitch - (e.clientY - ly) * 0.01)); lx = e.clientX; ly = e.clientY; } });
            cv.addEventListener('pointerdown', function (e) { dragging = true; lx = e.clientX; ly = e.clientY; try { cv.setPointerCapture(e.pointerId); } catch (x) {} });
            cv.addEventListener('pointerup', function () { dragging = false; });
            cv.addEventListener('pointerleave', function () { mx = my = null; });
            (function loop() { if (!dragging) yaw += spin; draw(); requestAnimationFrame(loop); })();
        })();

        // ── 3D interactive donut (SVG arcs + drop-shadow depth + gloss; hover
        //    explodes a slice, dims the rest, cross-highlights the legend and
        //    swaps the centre readout). Hand-rolled, no library, no CDN. ────────
        function donut(svgId, legendId, rows) {
            var svg = document.getElementById(svgId), leg = document.getElementById(legendId); if (!svg || !rows.length) return;
            var t = theme(), tot = rows.reduce(function (s, r) { return s + r.value; }, 0);
            var alphas = [1, .74, .52, .36, .24, .16, .1], cx = 56, cy = 56, ri = 31, ro = 48, TAU = Math.PI * 2;
            function pt(r, a) { return [cx + r * Math.sin(a), cy - r * Math.cos(a)]; }
            function arc(a0, a1, r0, r1, off) {
                // A single slice covering the whole circle is a degenerate arc (start == end),
                // so draw it as a full annulus (evenodd fill) instead.
                if (a1 - a0 >= TAU - 1e-4) {
                    return 'M' + cx + ',' + (cy - r1) + 'A' + r1 + ',' + r1 + ' 0 1 1 ' + cx + ',' + (cy + r1) + 'A' + r1 + ',' + r1 + ' 0 1 1 ' + cx + ',' + (cy - r1) + 'Z'
                         + 'M' + cx + ',' + (cy - r0) + 'A' + r0 + ',' + r0 + ' 0 1 0 ' + cx + ',' + (cy + r0) + 'A' + r0 + ',' + r0 + ' 0 1 0 ' + cx + ',' + (cy - r0) + 'Z';
                }
                var mid = (a0 + a1) / 2, dx = Math.sin(mid) * off, dy = -Math.cos(mid) * off, lg = (a1 - a0) > Math.PI ? 1 : 0;
                var A = pt(r1, a0), B = pt(r1, a1), C = pt(r0, a1), D = pt(r0, a0);
                return 'M' + (A[0] + dx) + ',' + (A[1] + dy) + 'A' + r1 + ',' + r1 + ' 0 ' + lg + ' 1 ' + (B[0] + dx) + ',' + (B[1] + dy)
                     + 'L' + (C[0] + dx) + ',' + (C[1] + dy) + 'A' + r0 + ',' + r0 + ' 0 ' + lg + ' 0 ' + (D[0] + dx) + ',' + (D[1] + dy) + 'Z';
            }
            var defs = '<defs><filter id="' + svgId + '-sh" x="-40%" y="-40%" width="180%" height="180%">'
                + '<feDropShadow dx="0" dy="2.5" stdDeviation="2.6" flood-color="rgba(15,23,42,0.30)"/></filter>'
                + '<radialGradient id="' + svgId + '-gl" cx="40%" cy="32%" r="72%">'
                + '<stop offset="0%" stop-color="rgba(255,255,255,0.55)"/><stop offset="42%" stop-color="rgba(255,255,255,0.12)"/>'
                + '<stop offset="100%" stop-color="rgba(255,255,255,0)"/></radialGradient></defs>';
            var seg = [], ang = 0, paths = '';
            if (tot <= 0) {
                paths = '<circle cx="56" cy="56" r="' + ((ri + ro) / 2) + '" fill="none" stroke="' + rgba(t.accentRGB, .12) + '" stroke-width="' + (ro - ri) + '"/>';
            } else {
                rows.forEach(function (r, i) {
                    var a0 = ang, a1 = ang + r.value / tot * TAU; ang = a1; seg.push([a0, a1]);
                    paths += '<path class="dseg" data-i="' + i + '" fill-rule="evenodd" d="' + arc(a0, a1, ri, ro, 0) + '" fill="' + rgba(t.accentRGB, alphas[i] || .1) + '">'
                        + '<title>' + r.label + ': ' + r.value + ' · ' + Math.round(r.value / tot * 100) + '%</title></path>';
                });
            }
            var gloss = '<circle cx="56" cy="56" r="' + ((ri + ro) / 2) + '" fill="none" stroke="url(#' + svgId + '-gl)" stroke-width="' + (ro - ri) + '" pointer-events="none"/>';
            var center = '<text class="dtot" x="56" y="53" text-anchor="middle" font-size="19" font-weight="800" fill="' + t.text + '">' + tot + '</text>'
                + '<text class="dsub" x="56" y="67" text-anchor="middle" font-size="8.5" fill="' + t.muted + '">total</text>';
            svg.innerHTML = defs + '<g filter="url(#' + svgId + '-sh)">' + paths + '</g>' + gloss + center;
            if (leg) leg.innerHTML = rows.map(function (r, i) { var pct = tot > 0 ? Math.round(r.value / tot * 100) : 0;
                return '<div class="it" data-i="' + i + '"><span class="sw" style="background:' + rgba(t.accentRGB, alphas[i] || .1) + '"></span>'
                    + '<span class="t">' + r.label + '</span><span class="v">' + r.value + ' · ' + pct + '%</span></div>'; }).join('');
            // ── Cross-highlight (hover) + click-to-pin details ──
            var segEls = svg.querySelectorAll('.dseg'), itEls = leg ? leg.querySelectorAll('.it') : [];
            var totEl = svg.querySelector('.dtot'), subEl = svg.querySelector('.dsub');
            // Details card lives just under the legend (created once, reused each render).
            var box = leg ? leg.parentNode : svg.parentNode, det = box ? box.querySelector('.vs-ddetail') : null;
            if (box && !det) { det = document.createElement('div'); det.className = 'vs-ddetail'; box.appendChild(det); }
            if (det) det.hidden = true;   // pin state resets on each (re-)render
            var pinned = null;
            function paint(i) {   // i = active slice (hovered or pinned), or null
                segEls.forEach(function (s) { var k = +s.getAttribute('data-i');
                    s.setAttribute('d', arc(seg[k][0], seg[k][1], ri, ro, i === null ? 0 : (k === i ? 5 : 0)));
                    s.style.opacity = (i === null || k === i) ? 1 : .38; });
                itEls.forEach(function (el) { var k = +el.getAttribute('data-i');
                    el.classList.toggle('on', k === i); el.classList.toggle('pinned', k === pinned); });
                if (i === null || tot <= 0) { totEl.textContent = tot; subEl.textContent = 'total'; }
                else { totEl.textContent = rows[i].value; subEl.textContent = Math.round(rows[i].value / tot * 100) + '%'; }
            }
            function detail(i) {
                if (!det) return;
                if (i === null || tot <= 0) { det.hidden = true; return; }
                var pct = Math.round(rows[i].value / tot * 100), col = rgba(t.accentRGB, alphas[i] || .1);
                det.innerHTML = '<div class="dd-top"><span class="sw" style="background:' + col + '"></span>'
                    + '<span class="dd-val">' + rows[i].value + '</span><span class="dd-lab">' + rows[i].label + '</span>'
                    + '<span class="dd-pct">' + pct + '%</span></div>'
                    + '<div class="dd-sub">' + rows[i].value + ' of ' + tot + ' total' + (pinned === i ? ' · pinned (click again to release)' : '') + '</div>'
                    + '<div class="dd-bar"><span style="width:' + pct + '%"></span></div>';
                det.hidden = false;
            }
            function show(i) { paint(i === null ? pinned : i); detail(i === null ? pinned : i); }
            function pick(k) { pinned = (pinned === k) ? null : k; show(null); }
            segEls.forEach(function (s) { var k = +s.getAttribute('data-i');
                s.addEventListener('mouseenter', function () { show(k); });
                s.addEventListener('mouseleave', function () { show(null); });
                s.addEventListener('click', function () { pick(k); }); });
            itEls.forEach(function (el) { var k = +el.getAttribute('data-i');
                el.style.cursor = 'pointer';
                el.addEventListener('mouseenter', function () { show(k); });
                el.addEventListener('mouseleave', function () { show(null); });
                el.addEventListener('click', function () { pick(k); }); });
        }
        // ── Weekday × hour heatmap ───────────────────────────────────────
        function renderHeat() {
            var el = document.getElementById('vsHeat'); if (!el) return;
            var hm = VIS.heatmap || { grid: [], max: 0 }, max = hm.max || 0, t = theme(), WD = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
            var html = '<div class="hd"></div>';
            for (var h = 0; h < 24; h++) html += '<div class="hd">' + (h % 3 === 0 ? h : '') + '</div>';
            for (var d = 0; d < 7; d++) {
                html += '<div class="rl">' + WD[d] + '</div>';
                for (var hr = 0; hr < 24; hr++) {
                    var v = (hm.grid[d] && hm.grid[d][hr]) || 0;
                    var alpha = max > 0 && v > 0 ? (0.14 + 0.86 * (v / max)) : 0;
                    var bg = alpha > 0 ? rgba(t.accentRGB, alpha.toFixed(2)) : '';
                    html += '<div class="cell" style="' + (bg ? 'background:' + bg : '') + '" title="' + WD[d] + ' ' + hr + ':00 — ' + v + ' visit(s)"></div>';
                }
            }
            el.innerHTML = html;
        }
        function renderAll() { drawTrend(); donut('vsDonutDev', 'vsDonutDevLegend', VIS.devices); donut('vsDonutSrc', 'vsDonutSrcLegend', VIS.sources); renderHeat(); }
        renderAll();
        window.addEventListener('resize', drawTrend);
        // Recolour on theme toggle (canvas/SVG read tokens).
        new MutationObserver(renderAll).observe(document.documentElement, { attributes: true, attributeFilter: ['data-pp-theme', 'class'] });
    })();
    </script>
    <?php include constant('BASE_PATH') . '/core/views/owner-ux.php'; ?>
</body>
</html>
