global_dashboard / index.html
cyberai-1
add the cicle barre
b4502d9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>TrafficSense — Global Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<style>
:root {
--bg: #05070a;
--s1: #090d12;
--s2: #0e1520;
--border: #182030;
--accent: #e8ff47;
--blue: #3d9eff;
--green: #1dffa0;
--red: #ff4d6d;
--amber: #ffb830;
--purple: #b57bff;
--cyan: #00dde0;
--text: #d0dcea;
--dim: #3a5068;
--mono: 'DM Mono', monospace;
--head: 'Syne', sans-serif;
--body: 'DM Sans', sans-serif;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body {
background: var(--bg);
color: var(--text);
font-family: var(--body);
min-height: 100vh;
}
body.video-tab-active { overflow:hidden; }
/* Grid noise texture */
body::after {
content:'';
position:fixed;inset:0;
background-image:
linear-gradient(rgba(255,255,255,.01) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.01) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events:none;
z-index:0;
}
/* ── HEADER ─────────────────────────────────────────────────────────────── */
header {
position:sticky;top:0;z-index:100;
background:rgba(5,7,10,.92);
backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
padding:0 32px;
display:flex;align-items:center;justify-content:space-between;
height:60px;
}
.brand {
display:flex;align-items:baseline;gap:10px;
}
.brand-name {
font-family:var(--head);font-weight:800;font-size:18px;
letter-spacing:.5px;color:var(--text);
}
.brand-name em { font-style:normal; color:var(--accent); }
.brand-tag {
font-family:var(--mono);font-size:10px;
color:var(--dim);letter-spacing:2px;
border:1px solid var(--border);padding:2px 8px;border-radius:2px;
}
.header-actions { display:flex;align-items:center;gap:16px; }
.pill {
font-family:var(--mono);font-size:11px;letter-spacing:1px;
padding:5px 14px;border-radius:20px;
border:1px solid var(--border);color:var(--dim);
background:var(--s1);cursor:pointer;transition:all .2s;
}
.pill:hover { border-color:var(--accent);color:var(--accent); }
.pill.active { background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:500; }
/* ── PAGE LAYOUT ──────────────────────────────────────────────────────── */
.page { position:relative;z-index:1; }
/* ── UPLOAD ZONE ────────────────────────────────────────────────────────── */
#uploadPage { padding:40px 32px; }
.upload-hero {
text-align:center;padding:60px 0 40px;
}
.upload-hero h1 {
font-family:var(--head);font-weight:800;font-size:48px;
line-height:1.05;color:var(--text);margin-bottom:12px;
}
.upload-hero h1 span { color:var(--accent); }
.upload-hero p { color:var(--dim);font-size:15px;max-width:520px;margin:0 auto; }
.upload-grid {
display:grid;grid-template-columns:1fr 1fr;gap:20px;
max-width:900px;margin:40px auto 0;
}
.upload-card {
background:var(--s1);border:1px solid var(--border);
border-radius:8px;padding:28px;
}
.upload-card h3 {
font-family:var(--head);font-weight:700;font-size:16px;
margin-bottom:6px;
}
.upload-card p { font-size:13px;color:var(--dim);margin-bottom:20px; }
.drop-zone {
border:2px dashed var(--border);border-radius:6px;
padding:30px 20px;text-align:center;cursor:pointer;
transition:all .25s;position:relative;
}
.drop-zone:hover,.drop-zone.over {
border-color:var(--accent);background:rgba(232,255,71,.04);
}
.drop-zone input { position:absolute;inset:0;opacity:0;cursor:pointer;width:100%; }
.drop-icon { font-size:32px;margin-bottom:10px; }
.drop-hint { font-size:13px;color:var(--dim); }
.drop-hint b { color:var(--text); }
.file-list { margin-top:14px;display:flex;flex-direction:column;gap:6px; }
.file-chip {
display:flex;align-items:center;justify-content:space-between;
background:var(--s2);border:1px solid var(--border);
border-radius:4px;padding:7px 12px;
font-family:var(--mono);font-size:11px;
}
.file-chip .fname { color:var(--text);word-break:break-all; }
.file-chip .fsize { color:var(--dim);white-space:nowrap;margin-left:12px; }
.file-chip .fdel { color:var(--red);cursor:pointer;margin-left:8px;font-size:13px; }
.source-card {
max-width:900px;margin:20px auto 0;
background:var(--s1);border:1px solid var(--border);
border-radius:8px;padding:22px 28px;
}
.source-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px; }
.source-head h3 {
font-family:var(--head);font-weight:700;font-size:16px;
}
.source-head p { font-size:13px;color:var(--dim);margin-top:4px; }
.source-status {
font-family:var(--mono);font-size:10px;letter-spacing:1px;
color:var(--dim);text-transform:uppercase;white-space:nowrap;
}
.source-list { display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;margin-top:14px; }
.source-item {
background:var(--s2);border:1px solid var(--border);border-radius:4px;
padding:10px 12px;font-family:var(--mono);font-size:10px;color:var(--dim);
}
.source-item b { color:var(--text);font-weight:500;display:block;margin-bottom:5px;word-break:break-all; }
.source-item span { color:var(--accent); }
.source-links {
width:100%;min-height:72px;margin-top:14px;padding:10px 12px;
background:var(--s2);border:1px solid var(--border);border-radius:4px;
color:var(--text);font-family:var(--mono);font-size:11px;resize:vertical;
}
.source-actions { display:flex;gap:10px;align-items:center;margin-top:10px; }
.btn-small {
font-family:var(--mono);font-size:10px;letter-spacing:1px;text-transform:uppercase;
padding:8px 12px;border:1px solid var(--border);border-radius:4px;
background:var(--s2);color:var(--text);cursor:pointer;
}
.btn-small:hover { border-color:var(--accent);color:var(--accent); }
.schema-box {
background:var(--s2);border:1px solid var(--border);border-radius:6px;
padding:16px;margin-top:12px;
font-family:var(--mono);font-size:11px;color:var(--dim);
line-height:2;
}
.schema-box .col-name { color:var(--accent); }
.schema-box .col-type { color:var(--blue); }
.btn-run {
display:block;width:100%;max-width:300px;margin:36px auto 0;
font-family:var(--head);font-weight:700;font-size:15px;
letter-spacing:2px;text-transform:uppercase;
padding:16px;border:none;cursor:pointer;
background:var(--accent);color:var(--bg);border-radius:6px;
transition:all .2s;
}
.btn-run:hover { background:#f5ff70;box-shadow:0 0 40px rgba(232,255,71,.35); }
.btn-run:disabled { background:var(--dim);color:var(--bg);cursor:not-allowed;box-shadow:none; }
/* ── LOADING SPINNER ─────────────────────────────────────────────────────── */
.spinner {
display:none;position:fixed;inset:0;z-index:999;
background:rgba(5,7,10,.95);backdrop-filter:blur(4px);
align-items:center;justify-content:center;
}
.spinner.active { display:flex; }
.spinner-circle {
width:60px;height:60px;border:4px solid var(--dim);
border-top-color:var(--accent);border-right-color:var(--accent);
border-radius:50%;animation:spin .8s linear infinite;
}
@keyframes spin {
from { transform:rotate(0deg); }
to { transform:rotate(360deg); }
}
/* ── DASHBOARD ───────────────────────────────────────────────────────────── */
#dashPage { display:none;padding:0; }
.dash-nav {
position:sticky;top:60px;z-index:90;
display:flex;gap:8px;align-items:center;flex-wrap:wrap;
background:var(--s1);border-bottom:1px solid var(--border);
padding:10px 32px;
}
.dash-tab {
font-family:var(--mono);font-size:11px;letter-spacing:1.5px;color:var(--dim);
padding:9px 16px;border:1px solid var(--border);border-radius:4px;
background:var(--s2);cursor:pointer;
text-transform:uppercase;transition:all .2s;
}
.dash-tab:hover { color:var(--text);border-color:var(--dim); }
.dash-tab.active { color:var(--accent);border-color:var(--accent);background:rgba(232,255,71,.06); }
.dash-filters {
margin-left:auto;
display:flex;
gap:12px;
align-items:center;
flex-wrap:wrap;
}
.filter-control {
display:flex;
align-items:center;
gap:8px;
}
.filter-control label {
font-family:var(--mono);
font-size:11px;
color:var(--dim);
letter-spacing:1px;
}
.filter-control select {
background:var(--s2);
border:1px solid var(--border);
color:var(--text);
padding:6px 10px;
border-radius:4px;
font-family:var(--mono);
font-size:11px;
cursor:pointer;
}
/* Filter selects */
#groupFilter, #sceneFilter {
color: var(--text) !important;
}
#groupFilter option, #sceneFilter option {
background: var(--s1);
color: var(--text);
}
#groupFilter:hover, #sceneFilter:hover {
border-color: var(--accent);
}
.dash-content { padding:32px; }
/* ── STAT CARDS ── */
.kpi-row {
display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
gap:14px;margin-bottom:32px;
}
.kpi-card {
background:var(--s1);border:1px solid var(--border);border-radius:6px;
padding:20px 18px;
}
.kpi-label { font-family:var(--mono);font-size:10px;color:var(--dim);letter-spacing:2px;margin-bottom:8px; }
.kpi-value { font-family:var(--head);font-size:38px;font-weight:800;line-height:1; }
.kpi-unit { font-family:var(--mono);font-size:11px;color:var(--dim);margin-top:4px; }
/* ── CHARTS GRID ── */
.charts-grid {
display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;
}
.chart-panel {
background:var(--s1);border:1px solid var(--border);border-radius:6px;padding:24px;
}
.chart-panel.full { grid-column:1/-1; }
.ch-title {
font-family:var(--mono);font-size:10px;letter-spacing:2px;
color:var(--dim);text-transform:uppercase;margin-bottom:18px;
}
/* Bar chart */
.bar-chart-v { display:flex;align-items:flex-end;gap:10px;height:160px; }
.bar-col { display:flex;flex-direction:column;align-items:center;flex:1;gap:6px; }
.bar-col .b-fill {
width:100%;border-radius:3px 3px 0 0;
transition:height .8s cubic-bezier(.16,1,.3,1);
min-height:2px;
}
.bar-col .b-label { font-family:var(--mono);font-size:10px;color:var(--dim); }
.bar-col .b-val { font-family:var(--head);font-size:13px;font-weight:700;color:var(--text); }
/* Horizontal bars */
.hbar-list { display:flex;flex-direction:column;gap:10px; }
.hbar-row { display:flex;align-items:center;gap:10px; }
.hbar-lbl { font-family:var(--mono);font-size:11px;color:var(--dim);width:86px;flex-shrink:0; }
.hbar-track{ flex:1;height:22px;background:var(--s2);border-radius:3px;overflow:hidden;position:relative; }
.hbar-fill { height:100%;border-radius:3px;transition:width .9s cubic-bezier(.16,1,.3,1); }
.hbar-num { font-family:var(--mono);font-size:11px;color:var(--text);width:38px;text-align:right; }
/* Scene table */
.scene-tbl { width:100%;border-collapse:collapse;font-size:12px; }
.scene-tbl th {
font-family:var(--mono);font-size:10px;letter-spacing:1px;color:var(--dim);
padding:8px 14px;text-align:left;border-bottom:1px solid var(--border);
text-transform:uppercase;
}
.scene-tbl td { padding:11px 14px;border-bottom:1px solid rgba(24,32,48,.7);font-family:var(--mono); }
.scene-tbl tr:hover td { background:var(--s2); }
.sbadge {
display:inline-block;padding:2px 10px;border-radius:3px;
font-size:10px;background:rgba(232,255,71,.1);
color:var(--accent);border:1px solid rgba(232,255,71,.25);
}
.ggrp {
display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;
background:rgba(61,158,255,.1);color:var(--blue);border:1px solid rgba(61,158,255,.25);
}
/* Direction split */
.dir-bar {
display:flex;height:16px;border-radius:3px;overflow:hidden;width:120px;
}
.dir-down { background:var(--green); }
.dir-up { background:var(--purple); }
/* ── VIDEO PANEL ── */
#videoPanel {
display:none;
height:calc(100vh - 105px);
padding:20px 32px;
overflow:hidden;
}
.video-layout {
display:flex;
align-items:stretch;
gap:20px;
height:100%;
min-height:0;
}
.video-stage {
min-width:0;
flex:1;
display:flex;
}
#view-video {
min-width:0;
flex:1;
display:flex;
align-items:center;
justify-content:center;
background:#030608;
overflow:hidden;
position:relative;
border:1px solid var(--border);
border-radius:6px;
min-height:0;
}
#videoEl { display:block;width:100%;height:100%;object-fit:contain; }
#overlayCanvas {
position:absolute;top:0;left:0;width:100%;height:100%;
pointer-events:none;
}
#bufferInfo {
position:absolute;
right:12px;
bottom:12px;
z-index:2;
padding:4px 8px;
background:rgba(3,6,8,.72);
border:1px solid rgba(24,32,48,.9);
border-radius:3px;
color:var(--dim);
font-family:var(--mono);
font-size:10px;
letter-spacing:1px;
}
.video-sidebar {
display:flex;
flex:0 0 390px;
min-width:360px;
flex-direction:column;
gap:14px;
overflow:auto;
padding-right:4px;
}
.vid-ctrl {
background:var(--s1);border:1px solid var(--border);border-radius:6px;padding:18px;
}
.vid-ctrl h4 { font-family:var(--mono);font-size:10px;letter-spacing:2px;color:var(--dim);margin-bottom:14px;text-transform:uppercase; }
.scene-selector { display:flex;flex-direction:column;gap:6px; }
.scene-btn {
display:flex;align-items:center;justify-content:space-between;
background:var(--s2);border:1px solid var(--border);border-radius:4px;
padding:8px 12px;cursor:pointer;transition:all .2s;
font-family:var(--mono);font-size:12px;
}
.scene-btn:hover { border-color:var(--accent); }
.scene-btn.active { border-color:var(--accent);background:rgba(232,255,71,.06); }
.scene-btn .s-name { color:var(--text); }
.scene-btn .s-cnt { color:var(--dim);font-size:10px; }
.video-selector { display:flex;flex-direction:column;gap:6px;margin-bottom:10px; }
.video-btn {
display:flex;flex-direction:column;gap:4px;
background:var(--s2);border:1px solid var(--border);border-radius:4px;
padding:8px 10px;cursor:pointer;transition:all .2s;
font-family:var(--mono);font-size:11px;color:var(--text);
}
.video-btn:hover { border-color:var(--blue); }
.video-btn.active { border-color:var(--blue);background:rgba(61,158,255,.08); }
.video-btn small { color:var(--dim);font-size:9px;line-height:1.3;word-break:break-all; }
.vid-upload-wrap { margin-top:8px; }
.btn-upload-vid {
display:block;width:100%;
font-family:var(--mono);font-size:11px;letter-spacing:1px;
padding:9px;border:1px dashed var(--border);border-radius:4px;
background:none;color:var(--dim);cursor:pointer;text-align:center;
transition:all .2s;position:relative;
}
.btn-upload-vid:hover { border-color:var(--blue);color:var(--blue); }
.btn-upload-vid input { position:absolute;inset:0;opacity:0;cursor:pointer;width:100%; }
/* Frame counters */
.live-counts { display:flex;flex-direction:column;gap:5px; }
.lc-row {
display:flex;align-items:center;justify-content:space-between;
background:var(--s2);border:1px solid var(--border);
border-radius:4px;padding:7px 11px;
}
.lc-left { display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px; }
.lc-dot { width:7px;height:7px;border-radius:50%; }
.lc-count { font-family:var(--head);font-size:16px;font-weight:700;color:var(--accent); }
.no-video {
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;min-height:360px;gap:12px;opacity:.35;
}
.no-video-icon { font-size:48px; }
.no-video-txt { font-family:var(--mono);font-size:12px;letter-spacing:2px; }
@media (max-width: 900px) {
header {
padding:0 16px;
}
.brand-tag {
display:none;
}
#uploadPage {
padding:28px 16px;
}
.upload-hero {
padding:36px 0 24px;
}
.upload-hero h1 {
font-size:34px;
}
.upload-grid,
.charts-grid {
grid-template-columns:1fr;
}
.source-head,
.source-actions {
align-items:stretch;
flex-direction:column;
}
.dash-nav {
top:60px;
padding:10px 16px;
}
.dash-tab {
flex:1 1 140px;
text-align:center;
}
.dash-filters {
width:100%;
margin-left:0;
}
.filter-control {
flex:1 1 180px;
}
.filter-control select {
min-width:0;
width:100%;
}
.dash-content {
padding:20px 16px;
overflow-x:auto;
}
.kpi-row {
grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
gap:10px;
}
.kpi-card,
.chart-panel,
.upload-card,
.source-card,
.vid-ctrl {
padding:16px;
}
.kpi-value {
font-size:30px;
}
.scene-tbl {
min-width:780px;
}
.video-layout {
flex-direction:column;
min-height:0;
}
#videoPanel {
height:calc(100vh - 154px);
padding:16px;
}
.video-stage {
flex:1;
min-height:0;
}
#view-video {
min-height:0;
}
.video-sidebar {
flex:0 0 auto;
min-width:0;
max-height:38vh;
}
}
@media (max-width: 560px) {
header {
height:auto;
min-height:60px;
padding:10px 12px;
gap:10px;
flex-wrap:wrap;
}
.header-actions {
width:100%;
}
.pill {
flex:1;
text-align:center;
}
.dash-nav {
top:80px;
padding:8px 12px;
}
#videoPanel {
height:calc(100vh - 188px);
padding:12px;
}
.upload-hero h1 {
font-size:28px;
}
.dash-tab {
flex-basis:100%;
}
.filter-control {
flex-basis:100%;
}
.kpi-row {
grid-template-columns:1fr;
}
.video-sidebar {
max-height:44vh;
}
}
/* ── CANVAS CHARTS (timeline) ── */
#timelineCanvas { width:100%;height:130px;display:block; }
/* ── SCROLLBAR ── */
::-webkit-scrollbar { width:4px; }
::-webkit-scrollbar-track { background:var(--bg); }
::-webkit-scrollbar-thumb { background:var(--border);border-radius:2px; }
/* Toast */
#toast {
position:fixed;bottom:24px;right:24px;z-index:9999;
background:var(--s2);border:1px solid var(--border);
font-family:var(--mono);font-size:11px;color:var(--text);
padding:12px 20px;border-radius:4px;
transform:translateY(60px);opacity:0;transition:all .3s;
}
#toast.show { transform:translateY(0);opacity:1; }
#toast.ok { border-color:var(--green);color:var(--green); }
#toast.warn { border-color:var(--amber);color:var(--amber); }
#toast.err { border-color:var(--red);color:var(--red); }
.empty-state { font-family:var(--mono);font-size:12px;color:var(--dim);padding:24px;text-align:center; }
</style>
</head>
<body>
<!-- ══ HEADER ══ -->
<header>
<div class="brand">
<div class="brand-name">Traffic<em>Sense</em></div>
<div class="brand-tag">GLOBAL DASHBOARD</div>
</div>
<div class="header-actions">
<button class="pill" id="btnGoUpload" onclick="showPage('upload')">+ Upload Data</button>
<button class="pill active" id="btnGoDash" onclick="showPage('dash')" style="display:none">Dashboard</button>
</div>
</header>
<div class="page">
<!-- ══ UPLOAD PAGE ════════════════════════════════════════════════════════ -->
<div id="uploadPage">
<div class="upload-hero">
<h1>Global Traffic<br/><span>Analytics Dashboard</span></h1>
<p>Upload tracking CSV files from each group, optionally add videos to replay detection overlays.</p>
</div>
<div class="upload-grid">
<!-- CSV Upload -->
<div class="upload-card">
<h3>📊 Tracking CSV Files</h3>
<p>One or more CSV log files from any group. All scenes are merged automatically.</p>
<label class="drop-zone" id="csvZone">
<input type="file" id="csvInput" accept=".csv" multiple/>
<div class="drop-icon">📂</div>
<div class="drop-hint"><b>Drop CSV files here</b><br>or click to browse — multiple files OK</div>
</label>
<div class="file-list" id="csvFileList"></div>
</div>
<!-- Schema -->
<div class="upload-card">
<h3>📋 Required CSV Schema</h3>
<p>Each group must export their log CSV with these exact column names:</p>
<div class="schema-box">
<span class="col-name">frame</span> <span class="col-type">int</span> — frame index<br/>
<span class="col-name">timestamp_sec</span> <span class="col-type">float</span> — time in seconds<br/>
<span class="col-name">scene_name</span> <span class="col-type">str</span> — e.g. intersection_A<br/>
<span class="col-name">group_id</span> <span class="col-type">str</span> — e.g. group_01<br/>
<span class="col-name">video_name</span> <span class="col-type">str</span> — filename of video<br/>
<span class="col-name">track_id</span> <span class="col-type">int</span> — unique object ID<br/>
<span class="col-name">class_name</span> <span class="col-type">str</span> — car / person / …<br/>
<span class="col-name">confidence</span> <span class="col-type">float</span> — 0.0 – 1.0<br/>
<span class="col-name">bbox_x1</span> <span class="col-type">int</span> — left px<br/>
<span class="col-name">bbox_y1</span> <span class="col-type">int</span> — top px<br/>
<span class="col-name">bbox_x2</span> <span class="col-type">int</span> — right px<br/>
<span class="col-name">bbox_y2</span> <span class="col-type">int</span> — bottom px<br/>
<span class="col-name">cx</span> / <span class="col-name">cy</span> <span class="col-type">int</span> — center<br/>
<span class="col-name">frame_width</span> / <span class="col-name">frame_height</span> <span class="col-type">int</span><br/>
<span class="col-name">crossed_line</span> <span class="col-type">bool</span> — true/false<br/>
<span class="col-name">direction</span> <span class="col-type">str</span> — up / down / ""<br/>
<span class="col-name">speed_px_s</span> <span class="col-type">float</span> — speed in px/s
</div>
</div>
</div>
<div class="source-card">
<div class="source-head">
<div>
<h3>🔗 Default Hugging Face Group Data</h3>
<p>CSV files and test videos are discovered from each group data folder.</p>
</div>
<div class="source-status" id="sourceStatus">Loading sources...</div>
</div>
<textarea class="source-links" id="sourceLinks" spellcheck="false"
placeholder="Paste one Hugging Face data folder link per line, e.g. https://huggingface.co/spaces/CyberAl/Traffic-Tracker/tree/main/data"></textarea>
<div class="source-actions">
<button class="btn-small" onclick="discoverCustomSources()">🔄 Refresh Sources</button>
<button class="btn-small" onclick="loadAndRender()">⚡ Build From Sources</button>
</div>
<div class="source-list" id="sourceList"></div>
</div>
<button class="btn-run" id="runBtn" onclick="loadAndRender()" disabled>
▶ BUILD DASHBOARD
</button>
</div>
<!-- Loading Spinner -->
<div class="spinner" id="loadingSpinner">
<div class="spinner-circle"></div>
</div>
<!-- ══ DASHBOARD PAGE ══════════════════════════════════════════════════════ -->
<div id="dashPage">
<div class="dash-nav">
<button class="dash-tab active" onclick="switchDashTab('overview',this)">OVERVIEW</button>
<button class="dash-tab" onclick="switchDashTab('scenes',this)">SCENES</button>
<button class="dash-tab" onclick="switchDashTab('video',this)">VIDEO REPLAY</button>
<div class="dash-filters">
<div class="filter-control">
<label>GROUP:</label>
<select id="groupFilter" onchange="updateSceneFilter(); applyFilters()">
<option value="">ALL GROUPS</option>
</select>
</div>
<div class="filter-control">
<label>SCENE:</label>
<select id="sceneFilter" onchange="applyFilters()">
<option value="">ALL SCENES</option>
</select>
</div>
</div>
</div>
<!-- OVERVIEW TAB -->
<div id="tab-overview" class="dash-content">
<div class="kpi-row" id="kpiRow"></div>
<div class="charts-grid">
<div class="chart-panel">
<div class="ch-title">Unique Tracker Objects per Class — Global</div>
<div class="bar-chart-v" id="classBarChart"></div>
</div>
<div class="chart-panel">
<div class="ch-title">Direction Split (crossing line)</div>
<div id="dirChart"></div>
</div>
<div class="chart-panel full">
<div class="ch-title">Traffic Intensity Timeline — All Scenes (10s buckets)</div>
<canvas id="timelineCanvas"></canvas>
</div>
<div class="chart-panel full">
<div class="ch-title">Average Confidence per Class</div>
<div class="hbar-list" id="confChart"></div>
</div>
</div>
</div>
<!-- SCENES TAB -->
<div id="tab-scenes" class="dash-content" style="display:none">
<table class="scene-tbl">
<thead>
<tr>
<th>Scene</th><th>Group</th><th>Frames</th><th>Duration</th>
<th>Unique Objects</th><th>Cars</th><th>Persons</th><th>Buses/Trucks</th>
<th>Avg Conf</th><th>Crossings ↓/↑</th>
</tr>
</thead>
<tbody id="sceneTbody"></tbody>
</table>
</div>
<!-- VIDEO TAB -->
<div id="videoPanel" style="display:none">
<div class="video-layout">
<div class="video-stage">
<div class="video-wrap" id="view-video">
<div class="no-video no-signal" id="noSignal">
<div class="no-video-icon">🎬</div>
<div class="no-video-txt">NO VIDEO LOADED</div>
<div style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-top:6px">
Select a scene and choose its video below
</div>
</div>
<video id="videoEl" style="display:none" controls></video>
<canvas id="overlayCanvas"></canvas>
<div id="bufferInfo">BUF: 0</div>
</div>
</div>
<div class="video-sidebar">
<!-- Scene selector -->
<div class="vid-ctrl">
<h4>🎯 Select Scene</h4>
<div class="scene-selector" id="sceneSelector"></div>
</div>
<!-- Video selection -->
<div class="vid-ctrl">
<h4>🎥 Choose Video</h4>
<p style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-bottom:10px">
Videos from the same repo/folder as the CSV are listed first. You can still override manually.
</p>
<div class="video-selector" id="remoteVideoSelector"></div>
<label class="btn-upload-vid">
<input type="file" id="vidInput" accept="video/*" onchange="loadVideo(this)"/>
🎥 Choose video file
</label>
<div id="vidName" style="font-family:var(--mono);font-size:10px;color:var(--blue);margin-top:8px;word-break:break-all"></div>
</div>
<!-- Live frame counts -->
<div class="vid-ctrl">
<h4>📍 Current Frame</h4>
<div class="live-counts" id="liveCounts">
<div style="font-family:var(--mono);font-size:10px;color:var(--dim);text-align:center;padding:8px">Play a video to see live counts</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /#dashPage -->
</div><!-- /.page -->
<div id="toast"></div>
<script>
// ══ CLASS COLORS ════════════════════════════════════════════════════════════
const COLORS = {
car: '#ffb830',
vehicle: '#ffb830',
person: '#00dde0',
bus: '#3d9eff',
truck: '#b57bff',
motorbike: '#ff4d6d',
motorcycle:'#ff4d6d',
bicycle: '#1dffa0',
'traffic light': '#1dffa0',
};
function classKey(cls) {
return String(cls || 'unknown').trim().toLowerCase();
}
function clsColor(cls) { return COLORS[classKey(cls)] || '#aaa'; }
// ══ STATE ═══════════════════════════════════════════════════════════════════
let allRows = []; // parsed CSV rows (all files merged)
let scenes = {}; // { scene_name -> {rows, groups, stats} }
let activeScene = null;
let frameIndex = {}; // { scene -> { frame_int -> [rows] } }
let videoBlob = null;
let filteredRows = []; // current filtered rows based on group/scene selection
let remoteSources = []; // Hugging Face source metadata
let remoteCsvFiles = []; // CSV files discovered in Hugging Face folders
let remoteVideoFiles = []; // video files discovered in Hugging Face folders
// ══ INIT ════════════════════════════════════════════════════════════════════
// CSV drag-drop
setupDrop('csvZone', 'csvInput', handleCSVFiles);
loadDefaultGroupSources();
// Handle window resize for video overlay
window.addEventListener('resize', () => {
if (activeScene && document.getElementById('videoEl').style.display !== 'none') {
const frameNum = lastDrawnFrame;
if (frameNum >= 0) {
// Redraw with new dimensions
drawOverlay(frameNum);
}
}
});
function setupDrop(zoneId, inputId, handler) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(inputId);
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('over'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('over');
handler([...e.dataTransfer.files]);
});
input.addEventListener('change', e => handler([...e.target.files]));
}
// ══ CSV HANDLING ═════════════════════════════════════════════════════════════
const csvFiles = [];
function handleCSVFiles(files) {
files.forEach(f => {
if (!csvFiles.find(x => x.name === f.name && x.size === f.size))
csvFiles.push(f);
});
renderFileList();
updateRunButton();
}
function renderFileList() {
const el = document.getElementById('csvFileList');
el.innerHTML = csvFiles.map((f, i) => `
<div class="file-chip">
<span class="fname">${f.name}</span>
<span class="fsize">${(f.size/1024).toFixed(1)} KB</span>
<span class="fdel" onclick="removeCSV(${i})">✕</span>
</div>`).join('');
}
function removeCSV(i) {
csvFiles.splice(i, 1);
renderFileList();
updateRunButton();
}
function updateRunButton() {
document.getElementById('runBtn').disabled = csvFiles.length === 0 && remoteCsvFiles.length === 0;
}
async function loadDefaultGroupSources() {
const status = document.getElementById('sourceStatus');
status.textContent = 'Loading sources...';
try {
const res = await fetch('/api/group-sources');
if (!res.ok) throw new Error(await res.text());
const payload = await res.json();
applyRemoteSources(payload.sources || [], payload.errors || []);
} catch (err) {
status.textContent = 'Source load failed';
toast('Could not load default Hugging Face sources.', 'err');
}
}
async function discoverCustomSources() {
const links = document.getElementById('sourceLinks').value
.split(/\n|,/)
.map(link => link.trim())
.filter(Boolean);
if (!links.length) {
loadDefaultGroupSources();
return;
}
const status = document.getElementById('sourceStatus');
status.textContent = `Checking ${links.length} source(s)...`;
const results = await Promise.allSettled(links.map(async link => {
const res = await fetch(`/api/hf-source?url=${encodeURIComponent(link)}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}));
const sources = [];
const errors = [];
results.forEach((result, idx) => {
if (result.status === 'fulfilled') sources.push(result.value);
else errors.push({ link: links[idx], error: result.reason.message });
});
applyRemoteSources(sources, errors);
}
function applyRemoteSources(sources, errors=[]) {
remoteSources = sources;
remoteCsvFiles = sources.flatMap(src =>
(src.csv_files || []).map(file => ({ ...file, repo: src.repo, folder: src.folder || '', sourceLink: src.link }))
);
remoteVideoFiles = sources.flatMap(src =>
(src.video_files || []).map(file => ({ ...file, repo: src.repo, folder: src.folder || '', sourceLink: src.link }))
);
document.getElementById('sourceLinks').value = sources.map(src => src.link).join('\n');
renderSourceList(errors);
updateRunButton();
const status = document.getElementById('sourceStatus');
status.textContent = `${sources.length} source(s), ${remoteCsvFiles.length} CSV, ${remoteVideoFiles.length} video(s)`;
}
function renderSourceList(errors=[]) {
const el = document.getElementById('sourceList');
const sourceCards = remoteSources.map(src => `
<div class="source-item">
<b>${src.repo}</b>
<span>${(src.csv_files || []).length}</span> CSV ·
<span>${(src.video_files || []).length}</span> video(s)
${src.warning ? `<br><span style="color:var(--amber)">${escapeHtml(src.warning)}</span>` : ''}
</div>
`);
const errorCards = errors.map(err => `
<div class="source-item">
<b>${err.link}</b>
<span style="color:var(--red)">error</span> ${escapeHtml(err.error || 'Could not read source')}
</div>
`);
el.innerHTML = sourceCards.concat(errorCards).join('') ||
'<div class="source-item"><b>No remote source configured</b>Add Hugging Face data links above.</div>';
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function jsString(value) {
return String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'");
}
function sourceKeyFromMeta(meta={}) {
return `${meta.repo || 'local'}::${meta.folder || ''}`;
}
function objectKey(r) {
return [
r.__source_key || sourceKeyFromMeta(r),
r.group_id || '?',
r.scene_name || 'unknown',
r.track_id ?? ''
].join('::');
}
function classUniqueCounts(rows) {
const byClass = {};
rows.forEach(r => {
const cls = r.class_name || 'unknown';
if (!byClass[cls]) byClass[cls] = new Set();
byClass[cls].add(objectKey(r));
});
return Object.fromEntries(Object.entries(byClass).map(([cls, ids]) => [cls, ids.size]));
}
function uniqueClassCount(rows, keys) {
const wanted = new Set(keys);
return new Set(rows.filter(r => wanted.has(r.class_name)).map(objectKey)).size;
}
function annotateRows(rows, meta={}) {
const key = sourceKeyFromMeta(meta);
return rows.map(row => ({
...row,
__source_repo: meta.repo || 'local',
__source_folder: meta.folder || '',
__source_link: meta.sourceLink || '',
__source_file: meta.name || '',
__source_key: key,
}));
}
// ══ LOAD & PARSE ════════════════════════════════════════════════════════════
async function loadAndRender() {
if (!csvFiles.length && !remoteCsvFiles.length) {
toast('No CSV file found. Add a Hugging Face source or upload a CSV.', 'warn');
return;
}
allRows = [];
let pending = csvFiles.length + remoteCsvFiles.length;
document.getElementById('loadingSpinner').classList.add('active');
const done = () => {
pending--;
if (pending === 0) {
document.getElementById('loadingSpinner').classList.remove('active');
buildDashboard();
}
};
csvFiles.forEach(f => {
Papa.parse(f, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
complete: result => {
allRows.push(...annotateRows(result.data, { repo: 'local', folder: '', name: f.name }));
done();
},
error: () => {
toast(`Error reading ${f.name}`, 'err');
done();
}
});
});
remoteCsvFiles.forEach(file => {
fetch(`/api/proxy?url=${encodeURIComponent(file.url)}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(text => {
const result = Papa.parse(text, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
});
allRows.push(...annotateRows(result.data, file));
})
.catch(() => toast(`Error reading ${file.name}`, 'err'))
.finally(done);
});
}
// ══ BUILD DASHBOARD ══════════════════════════════════════════════════════════
function buildDashboard() {
if (!allRows.length) { toast('No valid data found.', 'err'); return; }
// Group by scene
scenes = {};
allRows.forEach(r => {
const sc = r.scene_name || 'unknown';
if (!scenes[sc]) scenes[sc] = { rows: [], groups: new Set(), trackIds: new Set(), sourceKeys: new Set() };
scenes[sc].rows.push(r);
scenes[sc].groups.add(r.group_id || '?');
scenes[sc].sourceKeys.add(r.__source_key || sourceKeyFromMeta(r));
scenes[sc].trackIds.add(objectKey(r));
});
// Build per-scene frame index for video overlay
frameIndex = {};
Object.entries(scenes).forEach(([sc, data]) => {
frameIndex[sc] = {};
data.rows.forEach(r => {
const f = parseInt(r.frame);
if (!frameIndex[sc][f]) frameIndex[sc][f] = [];
frameIndex[sc][f].push(r);
});
});
renderKPIs();
renderClassChart();
renderDirectionChart();
renderTimeline();
renderConfChart();
renderSceneTable();
renderSceneSelector();
populateFilterDropdowns();
// Reset filter selections when loading new data
document.getElementById('groupFilter').value = '';
document.getElementById('sceneFilter').value = '';
filteredRows = [];
showPage('dash');
toast(`Loaded ${allRows.length.toLocaleString()} detections across ${Object.keys(scenes).length} scene(s).`, 'ok');
}
// ── KPIs ─────────────────────────────────────────────────────────────────────
function renderKPIs() {
const totalDet = allRows.length;
const sceneCount = Object.keys(scenes).length;
const groups = new Set(allRows.map(r => r.group_id)).size;
const uniqueTrack= new Set(allRows.map(objectKey)).size;
const crossings = allRows.filter(r => String(r.crossed_line).toLowerCase() === 'true').length;
const avgConf = (allRows.reduce((s,r) => s + parseFloat(r.confidence||0), 0) / allRows.length * 100).toFixed(1);
const totalFrames= new Set(allRows.map(r => `${r.scene_name}::${r.frame}`)).size;
document.getElementById('kpiRow').innerHTML = [
kpi(sceneCount, 'SCENES', 'videos'),
kpi(groups, 'GROUPS', 'contributors'),
kpi(uniqueTrack, 'UNIQUE OBJECTS', 'tracked across all scenes', 'var(--accent)'),
kpi(crossings, 'LINE CROSSINGS', 'counting events', 'var(--green)'),
kpi(totalFrames, 'FRAMES', 'total processed'),
kpi(avgConf+'%', 'AVG CONFIDENCE', 'detection quality', 'var(--blue)'),
].join('');
}
function kpi(val, label, unit, color='var(--text)') {
return `<div class="kpi-card">
<div class="kpi-label">${label}</div>
<div class="kpi-value" style="color:${color}">${val}</div>
<div class="kpi-unit">${unit}</div>
</div>`;
}
// ── CLASS BAR CHART ──────────────────────────────────────────────────────────
function renderClassChart() {
const counts = classUniqueCounts(allRows);
const sorted = Object.entries(counts).sort((a,b) => b[1]-a[1]);
const max = sorted[0]?.[1] || 1;
document.getElementById('classBarChart').innerHTML = sorted.map(([cls, cnt]) => `
<div class="bar-col">
<div class="b-val">${cnt}</div>
<div class="b-fill" style="height:${Math.max(4, cnt/max*140)}px;background:${clsColor(cls)}"></div>
<div class="b-label">${cls}<br><span style="font-size:8px;color:var(--dim)">trackers</span></div>
</div>`).join('');
}
// ── DIRECTION CHART ──────────────────────────────────────────────────────────
function renderDirectionChart() {
const crossings = allRows.filter(r => String(r.crossed_line).toLowerCase() === 'true');
const byClass = {};
crossings.forEach(r => {
if (!byClass[r.class_name]) byClass[r.class_name] = {down:0,up:0};
if (r.direction === 'down') byClass[r.class_name].down++;
else if (r.direction === 'up') byClass[r.class_name].up++;
});
const rows = Object.entries(byClass).map(([cls, {down, up}]) => {
const total = down + up || 1;
const dpct = (down/total*100).toFixed(0);
const upct = (up/total*100).toFixed(0);
return `<div style="margin-bottom:14px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px">
<div style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px">
<span style="width:8px;height:8px;border-radius:50%;background:${clsColor(cls)};display:inline-block"></span>
${cls}
</div>
<div style="font-family:var(--mono);font-size:10px;color:var(--dim)">↓${down}${up}</div>
</div>
<div class="dir-bar">
<div class="dir-down" style="width:${dpct}%"></div>
<div class="dir-up" style="width:${upct}%"></div>
</div>
</div>`;
});
document.getElementById('dirChart').innerHTML = rows.length
? rows.join('')
: `<div class="empty-state">No crossing events found<br><span style="font-size:10px">Make sure your CSV has crossed_line=true rows</span></div>`;
}
// ── TIMELINE ─────────────────────────────────────────────────────────────────
function renderTimeline() {
const c = document.getElementById('timelineCanvas');
const ctx = c.getContext('2d');
c.width = c.offsetWidth || 900;
c.height = 130;
// Bucket by scene+10s
const scColors = {};
const scList = Object.keys(scenes);
const palette = [' #e8ff47','#3d9eff','#1dffa0','#ff4d6d','#b57bff','#ffb830','#00dde0'];
scList.forEach((sc, i) => scColors[sc] = palette[i % palette.length]);
const buckets = {};
allRows.forEach(r => {
const b = Math.floor(r.timestamp_sec / 10);
const k = `${r.scene_name}::${b}`;
if (!buckets[k]) buckets[k] = { scene: r.scene_name, b, count: 0 };
buckets[k].count++;
});
const allBuckets = Object.values(buckets);
const maxB = Math.max(...allBuckets.map(b => b.b), 1);
const maxCt = Math.max(...allBuckets.map(b => b.count), 1);
const pad = 14;
const W = c.width - pad * 2;
const H = c.height - pad * 2 - 14;
ctx.fillStyle = '#090d12'; ctx.fillRect(0,0,c.width,c.height);
// Grid
ctx.strokeStyle = '#182030'; ctx.lineWidth = 1;
[.25,.5,.75,1].forEach(f => {
const y = pad + H - f*H;
ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(pad+W,y); ctx.stroke();
});
// Draw one area per scene
scList.forEach(sc => {
const pts = [];
for (let b = 0; b <= maxB; b++) {
const key = `${sc}::${b}`;
const cnt = buckets[key]?.count || 0;
const x = pad + (b/maxB)*W;
const y = pad + H - (cnt/maxCt)*H;
pts.push([x, y]);
}
if (!pts.length) return;
const color = scColors[sc];
ctx.beginPath();
ctx.moveTo(pts[0][0], pad+H);
pts.forEach(([x,y]) => ctx.lineTo(x,y));
ctx.lineTo(pts[pts.length-1][0], pad+H);
ctx.closePath();
ctx.fillStyle = color + '28';
ctx.fill();
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
pts.forEach(([x,y],i) => i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y));
ctx.stroke();
});
// Legend
ctx.font = '10px DM Mono'; ctx.textAlign = 'left';
scList.forEach((sc, i) => {
const x = pad + i * 130;
ctx.fillStyle = scColors[sc];
ctx.fillRect(x, c.height-12, 10, 10);
ctx.fillStyle = '#3a5068';
ctx.fillText(sc, x+14, c.height-3);
});
// X labels
ctx.fillStyle = '#3a5068'; ctx.textAlign = 'center';
for (let b = 0; b <= maxB; b += Math.ceil(maxB/10)) {
const x = pad + (b/maxB)*W;
ctx.fillText(`${b*10}s`, x, pad+H+12);
}
}
// ── CONFIDENCE CHART ──────────────────────────────────────────────────────────
function renderConfChart() {
const byClass = {};
allRows.forEach(r => {
if (!byClass[r.class_name]) byClass[r.class_name] = [];
byClass[r.class_name].push(parseFloat(r.confidence||0));
});
const sorted = Object.entries(byClass)
.map(([cls, vals]) => [cls, vals.reduce((a,b)=>a+b,0)/vals.length])
.sort((a,b) => b[1]-a[1]);
document.getElementById('confChart').innerHTML = sorted.map(([cls, avg]) => `
<div class="hbar-row">
<div class="hbar-lbl">${cls}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:${(avg*100).toFixed(1)}%;background:${clsColor(cls)}"></div>
</div>
<div class="hbar-num">${(avg*100).toFixed(1)}%</div>
</div>`).join('');
}
// ── SCENE TABLE ───────────────────────────────────────────────────────────────
function renderSceneTable() {
const rows = Object.entries(scenes).map(([sc, data]) => {
const rows = data.rows;
const groups = [...data.groups].join(', ');
const frames = new Set(rows.map(r => r.frame)).size;
const uniqueObjs = data.trackIds.size;
const duration = Math.max(...rows.map(r => parseFloat(r.timestamp_sec||0))).toFixed(1);
const cars = uniqueClassCount(rows, ['car']);
const persons = uniqueClassCount(rows, ['person']);
const heavy = uniqueClassCount(rows, ['bus', 'truck']);
const avgConf = (rows.reduce((s,r)=>s+parseFloat(r.confidence||0),0)/rows.length*100).toFixed(1);
const down = rows.filter(r => r.direction==='down').length;
const up = rows.filter(r => r.direction==='up').length;
return `<tr>
<td><span class="sbadge">${sc}</span></td>
<td><span class="ggrp">${groups}</span></td>
<td style="color:var(--dim)">${frames.toLocaleString()}</td>
<td style="color:var(--dim)">${duration}s</td>
<td style="color:var(--accent);font-weight:600">${uniqueObjs}</td>
<td>${cars}</td><td>${persons}</td><td>${heavy}</td>
<td>${avgConf}%</td>
<td>
<div style="display:flex;gap:8px;align-items:center">
<span style="color:var(--green)">↓${down}</span>
<span style="color:var(--purple)">↑${up}</span>
</div>
</td>
</tr>`;
});
document.getElementById('sceneTbody').innerHTML = rows.join('') ||
`<tr><td colspan="10" class="empty-state">No scene data available</td></tr>`;
}
// ══ VIDEO REPLAY ══════════════════════════════════════════════════════════════
function renderSceneSelector() {
const selectedGroup = document.getElementById('groupFilter').value;
const el = document.getElementById('sceneSelector');
// Get scenes for selected group (or all scenes if no group selected)
let scenesToShow = Object.keys(scenes);
if (selectedGroup) {
scenesToShow = scenesToShow.filter(sc => {
return scenes[sc].rows.some(r => r.group_id === selectedGroup);
});
}
el.innerHTML = scenesToShow.map((sc, idx) => `
<div class="scene-btn ${idx===0?'active':''}"
onclick="selectScene('${jsString(sc)}',this)">
<span class="s-name">${sc}</span>
<span class="s-cnt">${scenes[sc].trackIds.size} objs</span>
</div>`).join('');
activeScene = scenesToShow[0] || null;
renderVideoSelector(activeScene);
if (activeScene) loadDefaultVideoForScene(activeScene);
}
function selectScene(sc, el) {
document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
activeScene = sc;
// Reset video if scene changes
const vid = document.getElementById('videoEl');
vid.pause();
vid.style.display = 'none';
document.getElementById('noSignal').style.display = 'flex';
document.getElementById('vidName').textContent = '';
document.getElementById('liveCounts').innerHTML =
'<div style="font-family:var(--mono);font-size:10px;color:var(--dim);text-align:center;padding:8px">Choose a video for this scene</div>';
clearOverlay();
renderVideoSelector(sc);
loadDefaultVideoForScene(sc);
}
function matchingVideosForScene(sc) {
const selectedGroup = document.getElementById('groupFilter').value;
const rows = (scenes[sc]?.rows || []).filter(r => !selectedGroup || r.group_id === selectedGroup);
const videoNames = [...new Set(rows.map(r => String(r.video_name || '').split('/').pop()).filter(Boolean))];
const sourceKeys = new Set(rows.map(r => r.__source_key).filter(Boolean));
const groupIds = new Set(rows.map(r => String(r.group_id || '').toLowerCase()).filter(Boolean));
const sceneName = String(sc).toLowerCase();
return remoteVideoFiles
.map(file => {
const fileName = String(file.name || '').toLowerCase();
const path = String(file.path || '').toLowerCase();
const repo = String(file.repo || '').toLowerCase();
const folder = String(file.folder || '').toLowerCase();
const sameSource = sourceKeys.has(sourceKeyFromMeta(file));
const sameName = videoNames.some(name => fileName === name.toLowerCase());
const scenePathMatch = path.includes(sceneName) || fileName.includes(sceneName);
const groupMatch = [...groupIds].some(group => repo.includes(group.replace('_', '-')) || repo.includes(group) || folder.includes(group));
let score = 0;
if (sameSource) score += 100;
if (sameName) score += 60;
if (scenePathMatch) score += 25;
if (groupMatch) score += 10;
return { file, score };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score || String(a.file.name).localeCompare(String(b.file.name)))
.map(item => item.file);
}
function findVideoForScene(sc) {
return matchingVideosForScene(sc)[0];
}
function renderVideoSelector(sc) {
const el = document.getElementById('remoteVideoSelector');
if (!el) return;
const files = sc ? matchingVideosForScene(sc) : [];
el.innerHTML = files.length
? files.map((file, idx) => `
<button class="video-btn ${idx===0?'active':''}" onclick="selectRemoteVideo('${encodeURIComponent(file.url)}', this)">
<span>${escapeHtml(file.name)}</span>
<small>${escapeHtml(file.repo || 'unknown repo')}${file.folder ? ' / ' + escapeHtml(file.folder) : ''}</small>
</button>`).join('')
: '<div style="font-family:var(--mono);font-size:10px;color:var(--dim);text-align:center;padding:8px">No matching remote video found</div>';
}
function selectRemoteVideo(encodedUrl, btn) {
const url = decodeURIComponent(encodedUrl);
const file = remoteVideoFiles.find(item => item.url === url);
if (!file) return;
document.querySelectorAll('.video-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadRemoteVideo(file);
}
function loadRemoteVideo(file) {
if (!file) return;
const vid = document.getElementById('videoEl');
vid.pause();
vid.src = file.url;
vid.style.display = 'block';
document.getElementById('noSignal').style.display = 'none';
document.getElementById('vidName').textContent = `${file.name} (${file.repo}${file.folder ? ' / ' + file.folder : ''})`;
document.getElementById('liveCounts').innerHTML =
'<div style="font-family:var(--mono);font-size:10px;color:var(--dim);text-align:center;padding:8px">Play the selected video to see live counts</div>';
vid.onloadedmetadata = () => {
const wrap = document.getElementById('view-video');
const canvas = document.getElementById('overlayCanvas');
canvas.style.width = wrap.offsetWidth + 'px';
canvas.style.height = wrap.offsetHeight + 'px';
canvas.width = wrap.offsetWidth * window.devicePixelRatio;
canvas.height = wrap.offsetHeight * window.devicePixelRatio;
updateBufferInfo();
};
vid.ontimeupdate = onVideoTimeUpdate;
}
function loadDefaultVideoForScene(sc) {
const file = findVideoForScene(sc);
if (!file) return;
loadRemoteVideo(file);
}
function loadVideo(input) {
const file = input.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const vid = document.getElementById('videoEl');
vid.src = url;
vid.style.display = 'block';
document.getElementById('noSignal').style.display = 'none';
document.getElementById('vidName').textContent = file.name;
vid.onloadedmetadata = () => {
const wrap = document.getElementById('view-video');
const canvas = document.getElementById('overlayCanvas');
// Match canvas display size to wrapper
canvas.style.width = wrap.offsetWidth + 'px';
canvas.style.height = wrap.offsetHeight + 'px';
// Set internal resolution with device pixel ratio for crisp rendering
canvas.width = wrap.offsetWidth * window.devicePixelRatio;
canvas.height = wrap.offsetHeight * window.devicePixelRatio;
updateBufferInfo();
toast(`Video loaded: ${vid.videoWidth}×${vid.videoHeight}`, 'ok');
};
vid.ontimeupdate = onVideoTimeUpdate;
}
let lastDrawnFrame = -1;
function updateBufferInfo() {
const vid = document.getElementById('videoEl');
const el = document.getElementById('bufferInfo');
if (!vid || !el || !vid.duration || !vid.buffered.length) {
if (el) el.textContent = 'BUF: 0';
return;
}
const end = vid.buffered.end(vid.buffered.length - 1);
const pct = Math.min(100, Math.round((end / vid.duration) * 100));
el.textContent = `BUF: ${pct}`;
}
function onVideoTimeUpdate() {
if (!activeScene) return;
const vid = document.getElementById('videoEl');
updateBufferInfo();
const fps = detectFPS(activeScene);
const frameNum = Math.round(vid.currentTime * fps);
if (frameNum === lastDrawnFrame) return;
lastDrawnFrame = frameNum;
drawOverlay(frameNum);
}
function detectFPS(sc) {
// Estimate FPS from CSV: max_frame / max_timestamp
const selectedGroup = document.getElementById('groupFilter').value;
const rows = (scenes[sc]?.rows || []).filter(r => !selectedGroup || r.group_id === selectedGroup);
if (!rows.length) return 30;
const maxFrame = Math.max(...rows.map(r => parseInt(r.frame)||0));
const maxTime = Math.max(...rows.map(r => parseFloat(r.timestamp_sec)||0));
return maxTime > 0 ? Math.round(maxFrame / maxTime) : 30;
}
function drawOverlay(frameNum) {
const canvas = document.getElementById('overlayCanvas');
const ctx = canvas.getContext('2d');
const vid = document.getElementById('videoEl');
const wrap = document.getElementById('view-video');
// Get actual display dimensions
const displayWidth = wrap.offsetWidth;
const displayHeight = wrap.offsetHeight;
// Set canvas to actual display size (not internal resolution)
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
// Internal resolution for drawing (higher precision)
canvas.width = displayWidth * window.devicePixelRatio;
canvas.height = displayHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.clearRect(0, 0, displayWidth, displayHeight);
if (!activeScene) return;
const selectedGroup = document.getElementById('groupFilter').value;
const rowsForFrame = frame => {
const frameRows = frameIndex[activeScene]?.[frame] || [];
return selectedGroup ? frameRows.filter(r => r.group_id === selectedGroup) : frameRows;
};
// Find exact frame or nearest ±2 frames
let rows = rowsForFrame(frameNum);
if (!rows.length) {
for (let d = 1; d <= 3; d++) {
rows = rowsForFrame(frameNum-d);
if (!rows.length) rows = rowsForFrame(frameNum+d);
if (rows.length) break;
}
}
if (!rows.length) {
ctx.font = '12px DM Mono';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillText(`FRAME ${frameNum} - No detections`, 10, displayHeight - 10);
return;
}
// Get original frame dimensions from CSV
const frameWidth = parseInt(rows[0]?.frame_width);
const frameHeight = parseInt(rows[0]?.frame_height);
if (!frameWidth || !frameHeight) {
ctx.font = '12px DM Mono';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillText(`FRAME ${frameNum} - Invalid frame dimensions`, 10, displayHeight - 10);
return;
}
// Calculate scale factors accounting for video aspect ratio and display size
// Video maintains aspect ratio with object-fit:contain, so we need to calculate the actual video display area
const videoAspect = frameWidth / frameHeight;
const displayAspect = displayWidth / displayHeight;
let scaleX, scaleY, offsetX = 0, offsetY = 0;
if (displayAspect > videoAspect) {
// Display is wider than video - letterbox left/right
const scaledHeight = displayHeight;
const scaledWidth = scaledHeight * videoAspect;
offsetX = (displayWidth - scaledWidth) / 2;
scaleX = scaledWidth / frameWidth;
scaleY = scaledHeight / frameHeight;
} else {
// Display is taller than video - letterbox top/bottom
const scaledWidth = displayWidth;
const scaledHeight = scaledWidth / videoAspect;
offsetY = (displayHeight - scaledHeight) / 2;
scaleX = scaledWidth / frameWidth;
scaleY = scaledHeight / frameHeight;
}
const liveCounts = {};
rows.forEach(r => {
const x1 = parseInt(r.bbox_x1);
const y1 = parseInt(r.bbox_y1);
const x2 = parseInt(r.bbox_x2);
const y2 = parseInt(r.bbox_y2);
// Scale to display coordinates accounting for aspect ratio and centering
const dispX1 = offsetX + x1 * scaleX;
const dispY1 = offsetY + y1 * scaleY;
const dispX2 = offsetX + x2 * scaleX;
const dispY2 = offsetY + y2 * scaleY;
const w = dispX2 - dispX1;
const h = dispY2 - dispY1;
const col = clsColor(r.class_name);
const conf= (parseFloat(r.confidence)*100).toFixed(0);
const lbl = `${r.class_name} #${r.track_id} ${conf}%`;
// Bounding box with precise coordinates
ctx.strokeStyle = col;
ctx.lineWidth = 2.5;
ctx.strokeRect(dispX1, dispY1, w, h);
// Label background
ctx.font = `bold 12px DM Mono`;
const tw = ctx.measureText(lbl).width;
const th = 18;
ctx.fillStyle = col;
ctx.globalAlpha = 0.9;
ctx.fillRect(dispX1, dispY1 - th, tw + 8, th);
ctx.globalAlpha = 1;
// Label text
ctx.fillStyle = '#000';
ctx.fillText(lbl, dispX1 + 4, dispY1 - 4);
// Center dot (scaled)
const cx = parseInt(r.cx) * scaleX + offsetX;
const cy = parseInt(r.cy) * scaleY + offsetY;
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI*2);
ctx.fillStyle = col;
ctx.fill();
liveCounts[r.class_name] = (liveCounts[r.class_name]||0) + 1;
});
// Frame number overlay
ctx.font = '12px DM Mono'; ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(`FRAME ${frameNum}`, 10, displayHeight - 10);
// Update sidebar live counts
updateLiveCounts(liveCounts);
}
function clearOverlay() {
const canvas = document.getElementById('overlayCanvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
lastDrawnFrame = -1;
}
function updateLiveCounts(counts) {
const el = document.getElementById('liveCounts');
const entries = Object.entries(counts);
el.innerHTML = entries.length
? entries.map(([cls, cnt]) => `
<div class="lc-row">
<div class="lc-left">
<span class="lc-dot" style="background:${clsColor(cls)}"></span>
${cls}
</div>
<div class="lc-count">${cnt}</div>
</div>`).join('')
: '<div style="font-family:var(--mono);font-size:10px;color:var(--dim);text-align:center;padding:8px">No objects this frame</div>';
}
// ══ NAVIGATION ════════════════════════════════════════════════════════════════
function showPage(page) {
if (page !== 'dash') document.body.classList.remove('video-tab-active');
document.getElementById('uploadPage').style.display = page==='upload' ? 'block' : 'none';
document.getElementById('dashPage').style.display = page==='dash' ? 'block' : 'none';
document.getElementById('btnGoDash').style.display = page==='dash' ? 'inline-flex' : 'none';
}
function switchDashTab(tab, btn) {
document.querySelectorAll('.dash-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.body.classList.toggle('video-tab-active', tab === 'video');
document.getElementById('tab-overview').style.display = tab==='overview' ? 'block' : 'none';
document.getElementById('tab-scenes').style.display = tab==='scenes' ? 'block' : 'none';
document.getElementById('videoPanel').style.display = tab==='video' ? 'block' : 'none';
if (tab==='overview') { renderTimeline(); } // reflow canvas
}
// ══ TOAST ═════════════════════════════════════════════════════════════════════
function toast(msg, type='') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show ' + type;
clearTimeout(el._t);
el._t = setTimeout(() => el.className='', 4000);
}
// ══ FILTERING ══════════════════════════════════════════════════════════════════
function populateFilterDropdowns() {
// Collect all unique groups
const groups = new Set();
allRows.forEach(r => {
groups.add(r.group_id || '?');
});
// Populate group filter - clear first to avoid duplicates
const groupSelect = document.getElementById('groupFilter');
// Remove all options except the first "ALL GROUPS"
while (groupSelect.options.length > 1) {
groupSelect.remove(1);
}
const groupOptions = Array.from(groups).sort();
groupOptions.forEach(group => {
const option = document.createElement('option');
option.value = group;
option.textContent = group;
groupSelect.appendChild(option);
});
// Initialize scene filter (scenes for all groups)
updateSceneFilter();
}
function updateSceneFilter() {
const selectedGroup = document.getElementById('groupFilter').value;
const sceneSelect = document.getElementById('sceneFilter');
// Clear existing options except "ALL SCENES" - remove all except first
while (sceneSelect.options.length > 1) {
sceneSelect.remove(1);
}
// Get scenes for selected group (or all scenes if no group selected)
const sceneNames = new Set();
allRows.forEach(r => {
if (!selectedGroup || r.group_id === selectedGroup) {
sceneNames.add(r.scene_name || 'unknown');
}
});
// Populate scene filter with only scenes from selected group
const sceneOptions = Array.from(sceneNames).sort();
sceneOptions.forEach(scene => {
const option = document.createElement('option');
option.value = scene;
option.textContent = scene;
sceneSelect.appendChild(option);
});
// Also update the video scene selector
renderSceneSelector();
}
function applyFilters() {
const selectedGroup = document.getElementById('groupFilter').value;
const selectedScene = document.getElementById('sceneFilter').value;
// Filter rows based on selections
filteredRows = allRows.filter(r => {
const groupMatch = !selectedGroup || r.group_id === selectedGroup;
const sceneMatch = !selectedScene || r.scene_name === selectedScene;
return groupMatch && sceneMatch;
});
// Re-render all charts with filtered data
renderFilteredKPIs();
renderFilteredClassChart();
renderFilteredDirectionChart();
renderFilteredTimeline();
renderFilteredConfChart();
renderFilteredSceneTable();
const groupText = selectedGroup || 'all groups';
const sceneText = selectedScene || 'all scenes';
toast(`Filtered: ${filteredRows.length} detections from ${groupText} in ${sceneText}`, 'ok');
}
function renderFilteredKPIs() {
const rows = filteredRows.length ? filteredRows : allRows;
const uniqueScenes = new Set(rows.map(r => r.scene_name)).size;
const uniqueGroups = new Set(rows.map(r => r.group_id)).size;
const uniqueTrack = new Set(rows.map(objectKey)).size;
const crossings = rows.filter(r => String(r.crossed_line).toLowerCase() === 'true').length;
const avgConf = rows.length ? (rows.reduce((s,r) => s + parseFloat(r.confidence||0), 0) / rows.length * 100).toFixed(1) : '0';
const totalFrames = new Set(rows.map(r => `${r.scene_name}::${r.frame}`)).size;
document.getElementById('kpiRow').innerHTML = [
kpi(uniqueScenes, 'SCENES', 'videos'),
kpi(uniqueGroups, 'GROUPS', 'contributors'),
kpi(uniqueTrack, 'UNIQUE OBJECTS', 'tracked', 'var(--accent)'),
kpi(crossings, 'LINE CROSSINGS', 'counting events', 'var(--green)'),
kpi(totalFrames, 'FRAMES', 'total processed'),
kpi(avgConf+'%', 'AVG CONFIDENCE', 'detection quality', 'var(--blue)'),
].join('');
}
function renderFilteredClassChart() {
const rows = filteredRows.length ? filteredRows : allRows;
const counts = classUniqueCounts(rows);
const sorted = Object.entries(counts).sort((a,b) => b[1]-a[1]);
const max = sorted[0]?.[1] || 1;
document.getElementById('classBarChart').innerHTML = sorted.map(([cls, cnt]) => `
<div class="bar-col">
<div class="b-val">${cnt}</div>
<div class="b-fill" style="height:${Math.max(4, cnt/max*140)}px;background:${clsColor(cls)}"></div>
<div class="b-label">${cls}<br><span style="font-size:8px;color:var(--dim)">trackers</span></div>
</div>`).join('');
}
function renderFilteredDirectionChart() {
const rows = filteredRows.length ? filteredRows : allRows;
const crossings = rows.filter(r => String(r.crossed_line).toLowerCase() === 'true');
const byClass = {};
crossings.forEach(r => {
if (!byClass[r.class_name]) byClass[r.class_name] = {down:0,up:0};
if (r.direction === 'down') byClass[r.class_name].down++;
else if (r.direction === 'up') byClass[r.class_name].up++;
});
const dirRows = Object.entries(byClass).map(([cls, {down, up}]) => {
const total = down + up || 1;
const dpct = (down/total*100).toFixed(0);
const upct = (up/total*100).toFixed(0);
return `<div style="margin-bottom:14px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px">
<div style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px">
<span style="width:8px;height:8px;border-radius:50%;background:${clsColor(cls)};display:inline-block"></span>
${cls}
</div>
<div style="font-family:var(--mono);font-size:10px;color:var(--dim)">↓${down}${up}</div>
</div>
<div class="dir-bar">
<div class="dir-down" style="width:${dpct}%"></div>
<div class="dir-up" style="width:${upct}%"></div>
</div>
</div>`;
});
document.getElementById('dirChart').innerHTML = dirRows.length
? dirRows.join('')
: `<div class="empty-state">No crossing events found<br><span style="font-size:10px">Make sure your CSV has crossed_line=true rows</span></div>`;
}
function renderFilteredTimeline() {
const rows = filteredRows.length ? filteredRows : allRows;
const c = document.getElementById('timelineCanvas');
const ctx = c.getContext('2d');
c.width = c.offsetWidth || 900;
c.height = 130;
const sceneList = [...new Set(rows.map(r => r.scene_name))];
const scColors = {};
const palette = ['#e8ff47','#3d9eff','#1dffa0','#ff4d6d','#b57bff','#ffb830','#00dde0'];
sceneList.forEach((sc, i) => scColors[sc] = palette[i % palette.length]);
const buckets = {};
rows.forEach(r => {
const b = Math.floor(r.timestamp_sec / 10);
const k = `${r.scene_name}::${b}`;
if (!buckets[k]) buckets[k] = { scene: r.scene_name, b, count: 0 };
buckets[k].count++;
});
const allBuckets = Object.values(buckets);
const maxB = Math.max(...allBuckets.map(b => b.b), 1);
const maxCt = Math.max(...allBuckets.map(b => b.count), 1);
const pad = 14;
const W = c.width - pad * 2;
const H = c.height - pad * 2 - 14;
ctx.fillStyle = '#090d12'; ctx.fillRect(0,0,c.width,c.height);
ctx.strokeStyle = '#182030'; ctx.lineWidth = 1;
[.25,.5,.75,1].forEach(f => {
const y = pad + H - f*H;
ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(pad+W,y); ctx.stroke();
});
sceneList.forEach(sc => {
const pts = [];
for (let b = 0; b <= maxB; b++) {
const key = `${sc}::${b}`;
const cnt = buckets[key]?.count || 0;
const x = pad + (b/maxB)*W;
const y = pad + H - (cnt/maxCt)*H;
pts.push([x, y]);
}
if (!pts.length) return;
const color = scColors[sc];
ctx.beginPath();
ctx.moveTo(pts[0][0], pad+H);
pts.forEach(([x,y]) => ctx.lineTo(x,y));
ctx.lineTo(pts[pts.length-1][0], pad+H);
ctx.closePath();
ctx.fillStyle = color + '28';
ctx.fill();
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
pts.forEach(([x,y],i) => i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y));
ctx.stroke();
});
ctx.font = '10px DM Mono'; ctx.textAlign = 'left';
sceneList.forEach((sc, i) => {
const x = pad + i * 130;
ctx.fillStyle = scColors[sc];
ctx.fillRect(x, c.height-12, 10, 10);
ctx.fillStyle = '#3a5068';
ctx.fillText(sc, x+14, c.height-3);
});
ctx.fillStyle = '#3a5068'; ctx.textAlign = 'center';
for (let b = 0; b <= maxB; b += Math.ceil(maxB/10)) {
const x = pad + (b/maxB)*W;
ctx.fillText(`${b*10}s`, x, pad+H+12);
}
}
function renderFilteredConfChart() {
const rows = filteredRows.length ? filteredRows : allRows;
const byClass = {};
rows.forEach(r => {
if (!byClass[r.class_name]) byClass[r.class_name] = [];
byClass[r.class_name].push(parseFloat(r.confidence||0));
});
const sorted = Object.entries(byClass)
.map(([cls, vals]) => [cls, vals.reduce((a,b)=>a+b,0)/vals.length])
.sort((a,b) => b[1]-a[1]);
document.getElementById('confChart').innerHTML = sorted.map(([cls, avg]) => `
<div class="hbar-row">
<div class="hbar-lbl">${cls}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:${(avg*100).toFixed(1)}%;background:${clsColor(cls)}"></div>
</div>
<div class="hbar-num">${(avg*100).toFixed(1)}%</div>
</div>`).join('');
}
function renderFilteredSceneTable() {
const rows = filteredRows.length ? filteredRows : allRows;
const sceneList = [...new Set(rows.map(r => r.scene_name))];
const tableRows = sceneList.map(sc => {
const sceneRows = rows.filter(r => r.scene_name === sc);
const groups = [...new Set(sceneRows.map(r => r.group_id))].join(', ');
const frames = new Set(sceneRows.map(r => r.frame)).size;
const uniqueObjs = new Set(sceneRows.map(objectKey)).size;
const duration = Math.max(...sceneRows.map(r => parseFloat(r.timestamp_sec||0))).toFixed(1);
const cars = uniqueClassCount(sceneRows, ['car']);
const persons = uniqueClassCount(sceneRows, ['person']);
const heavy = uniqueClassCount(sceneRows, ['bus', 'truck']);
const avgConf = (sceneRows.reduce((s,r)=>s+parseFloat(r.confidence||0),0)/sceneRows.length*100).toFixed(1);
const down = sceneRows.filter(r => r.direction==='down').length;
const up = sceneRows.filter(r => r.direction==='up').length;
return `<tr>
<td><span class="sbadge">${sc}</span></td>
<td><span class="ggrp">${groups}</span></td>
<td style="color:var(--dim)">${frames.toLocaleString()}</td>
<td style="color:var(--dim)">${duration}s</td>
<td style="color:var(--accent);font-weight:600">${uniqueObjs}</td>
<td>${cars}</td><td>${persons}</td><td>${heavy}</td>
<td>${avgConf}%</td>
<td>
<div style="display:flex;gap:8px;align-items:center">
<span style="color:var(--green)">↓${down}</span>
<span style="color:var(--purple)">↑${up}</span>
</div>
</td>
</tr>`;
});
document.getElementById('sceneTbody').innerHTML = tableRows.join('') ||
`<tr><td colspan="10" class="empty-state">No scene data available</td></tr>`;
}
</script>
</body>
</html>