Traffic-Tracker / frontend /index.html
cyberai-1
Update
e97bbaf
Raw
History Blame Contribute Delete
62.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TrafficSense β€” Road Traffic Monitor</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700;900&family=Barlow:wght@300;400;500&display=swap" rel="stylesheet"/>
<style>
:root {
--bg: #080c10;
--surface: #0d1218;
--panel: #111820;
--border: #1e2d3d;
--accent: #00e5ff;
--amber: #ffb300;
--green: #00ff88;
--red: #ff3860;
--text: #c8d8e8;
--dim: #4a6070;
--mono: 'Share Tech Mono', monospace;
--head: 'Barlow Condensed', sans-serif;
--body: 'Barlow', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--body);
min-height: 100vh;
font-size: 20px; /* Increased base font size */
}
/* Increase font size for buttons, labels, etc. */
.btn, .btn-primary, .btn-secondary {
font-size: 20px;
}
.s-label, .field label, .nav-btn, .view-tab, .up-hint, .up-name, .counter-list, .cnt-card, .cnt-left, .cnt-val, .total-label, .total-val, .frame-info, .sc-label, .sc-value, .sc-unit, .bar-label, .bar-count, .log-name, .log-meta, .log-dl, #toast {
font-size: 18px !important;
}
.upload-zone {
font-size: 20px;
}
/* ── HEADER ── */
header {
display: flex; align-items: center; justify-content: space-between;
padding: 0 28px; height: 56px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky; top: 0; z-index: 100;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo-box {
width: 30px; height: 30px; border: 2px solid var(--accent);
display: grid; place-items: center;
font-family: var(--mono); font-size: 12px; color: var(--accent);
}
.logo-text { font-family: var(--head); font-weight: 700; font-size: 20px; letter-spacing: 2px; }
.logo-text span { color: var(--accent); }
.header-right { display: flex; align-items: center; gap: 24px; }
nav { display: flex; gap: 2px; }
.nav-btn {
background: none; border: none; cursor: pointer;
font-family: var(--head); font-weight: 600; font-size: 12px;
letter-spacing: 1.5px; color: var(--dim); padding: 6px 14px;
border-bottom: 2px solid transparent; transition: all .2s;
text-transform: uppercase;
}
.nav-btn.active, .nav-btn:hover { color: var(--accent); border-color: var(--accent); }
.status-badge {
font-family: var(--mono); font-size: 11px; color: var(--dim);
display: flex; align-items: center; gap: 8px;
}
.dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--dim);
}
.dot.live { background: var(--green); box-shadow: 0 0 8px var(--green); animation: blink 1.4s ease infinite; }
.dot.proc { background: var(--amber); box-shadow: 0 0 8px var(--amber); animation: blink .8s ease infinite; }
.dot.err { background: var(--red); }
@keyframes blink { 0%,100%{opacity:1}50%{opacity:.3} }
/* ── LAYOUT ── */
main { display: flex; width: 100vw; height: calc(100vh - 56px); overflow: hidden; }
/* ── SIDEBAR ── */
aside {
width: clamp(220px, 22vw, 315px); min-width: 220px;
background: var(--surface);
display: flex; flex-direction: column;
overflow-y: auto;
}
.left-sidebar { border-right: 1px solid var(--border); }
.right-sidebar { border-left: 1px solid var(--border); }
.right-sidebar .s-sect { padding: 14px 16px; }
.right-sidebar .s-label { margin-bottom: 8px; }
.right-sidebar .btn { padding: 8px 10px; font-size: 18px; }
.right-sidebar .btn-secondary { margin-top: 5px; }
.right-sidebar .prog-bar { margin-top: 7px; }
.right-sidebar .prog-label { margin-top: 3px; }
.right-sidebar .total-banner { padding: 7px 10px; margin-bottom: 7px; }
.right-sidebar .total-val { font-size: 28px; }
.right-sidebar .counter-list { gap: 5px; }
.right-sidebar .cnt-card { padding: 7px 10px; }
.right-sidebar .cnt-val { font-size: 22px; }
.right-sidebar .frame-info { line-height: 1.65; }
.s-sect { padding: 14px 18px; border-bottom: 1px solid var(--border); }
.s-label {
font-family: var(--mono); font-size: 14px; letter-spacing: 2px;
color: var(--dim); text-transform: uppercase; margin-bottom: 12px; font-weight: 600;
}
/* Upload */
.source-panel {
border: 1px solid rgba(0,229,255,.35);
background: rgba(0,229,255,.045);
box-shadow: inset 0 0 0 1px rgba(0,229,255,.06), 0 0 24px rgba(0,229,255,.06);
}
.source-panel .s-label {
color: var(--accent);
display: flex;
align-items: center;
justify-content: space-between;
}
.source-pill {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--bg);
background: var(--accent);
padding: 3px 7px;
border-radius: 2px;
}
.source-choice {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
margin: 12px 0 7px;
letter-spacing: 1px;
text-transform: uppercase;
}
.upload-zone {
border: 2px dashed rgba(0,229,255,.55); border-radius: 3px;
background: rgba(8,12,16,.72);
padding: 4px 6px; text-align: center; cursor: pointer;
transition: all .2s; position: relative;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(0,229,255,.09); }
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
.up-icon { font-size: 20px; margin-bottom: 2px; }
.up-hint { font-size: 12px; color: var(--dim); line-height: 1.15; }
.up-hint b { color: var(--text); }
.up-name { font-family: var(--mono); font-size: 11px; color: var(--accent); margin-top: 2px; word-break: break-all; }
.webcam-source {
border: 1px solid rgba(0,255,136,.45);
background: rgba(0,255,136,.055);
padding: 10px;
margin-top: 8px;
border-radius: 3px;
}
.webcam-source .btn {
margin-top: 0;
color: var(--green);
border-color: rgba(0,255,136,.55);
background: rgba(0,255,136,.04);
}
.webcam-source .btn:hover {
color: var(--bg);
background: var(--green);
border-color: var(--green);
box-shadow: 0 0 18px rgba(0,255,136,.25);
text-decoration: none;
}
/* Upload progress bar */
#uploadProgBar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 3px;
margin-top: 10px;
overflow: hidden;
display: none;
}
#uploadProgFill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width .3s;
}
#uploadProgLabel {
font-family: var(--mono);
font-size: 14px;
color: var(--dim);
margin-top: 4px;
text-align: center;
display: none;
}
/* Fields */
.field { margin-bottom: 12px; }
.field label { display: block; font-family: var(--mono); font-size: 13px; color: var(--dim); letter-spacing: 1px; margin-bottom: 5px; font-weight: 600; }
.field input, .field select {
width: 100%; background: var(--panel); border: 1px solid var(--border);
color: var(--text); font-family: var(--mono); font-size: 14px;
padding: 7px 10px; outline: none; border-radius: 2px; transition: border-color .2s;
}
.field input:focus, .field select:focus { border-color: var(--accent); }
.last-config-field { margin-bottom: 0; }
.source-divider {
font-family: var(--mono); font-size: 11px; color: var(--dim);
text-align: center; margin: 10px 0; letter-spacing: 1px;
}
/* Class chips */
.class-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.cls-chip {
display: flex; align-items: center; gap: 7px;
background: var(--panel); border: 1px solid var(--border);
padding: 6px 9px; cursor: pointer; transition: all .2s; border-radius: 2px;
}
.cls-chip input { display: none; }
.cls-chip.on { border-color: var(--accent); background: rgba(0,229,255,.07); }
.cls-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.cls-name { font-size: 13px; font-family: var(--mono); }
/* Buttons */
.btn {
display: block; width: 100%;
font-family: var(--head); font-weight: 700; font-size: 20px;
letter-spacing: 2px; text-transform: uppercase;
padding: 11px; border: none; cursor: pointer;
transition: all .2s; border-radius: 2px;
}
.btn-primary { background: var(--accent); color: var(--bg); }
.btn-primary:hover { background: #33eeff; box-shadow: 0 0 20px rgba(0,229,255,.35); }
.btn-primary:disabled { background: var(--dim); cursor: not-allowed; box-shadow: none; }
.btn-secondary {
background: transparent; color: var(--dim);
border: 1px solid var(--border); margin-top: 7px;
}
.btn-secondary:hover { border-color: var(--red); color: var(--red); }
/* Progress */
.prog-bar { height: 3px; background: var(--border); margin-top: 10px; border-radius: 2px; overflow: hidden; }
.prog-fill { height: 100%; background: var(--accent); transition: width .4s; width: 0; }
.prog-label { font-family: var(--mono); font-size: 13px; color: var(--dim); margin-top: 5px; text-align: center; }
/* Counter cards */
.counter-list { display: flex; flex-direction: column; gap: 8px; }
.cnt-card {
display: flex; align-items: center; justify-content: space-between;
background: rgba(13,18,24,0.8); border: 2px solid var(--border);
padding: 14px 16px; border-radius: 3px;
transition: all .2s;
}
.cnt-card:hover { border-color: var(--accent); background: rgba(0,229,255,0.05); }
.cnt-left { display: flex; align-items: center; gap: 10px; font-size: 15px; font-family: var(--mono); font-weight: 500; }
.cnt-val { font-family: var(--mono); font-size: 28px; color: var(--accent); font-weight: bold; min-width: 40px; text-align: right; }
/* Total unique banner */
.total-banner {
display: flex; align-items: center; justify-content: space-between;
background: rgba(0,229,255,.07); border: 1px solid rgba(0,229,255,.25);
padding: 10px 14px; border-radius: 2px; margin-bottom: 10px;
}
.total-label { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 1px; }
.total-val { font-family: var(--head); font-size: 34px; font-weight: 900; color: var(--accent); }
/* Frame info */
.frame-info { font-family: var(--mono); font-size: 15px; color: var(--dim); line-height: 2.1; }
.frame-info .fi-val { color: var(--text); }
/* ── CONTENT ── */
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.view-tabs {
display: flex; background: var(--surface);
border-bottom: 1px solid var(--border); padding: 0 20px;
}
.view-tab {
font-family: var(--head); font-weight: 600; font-size: 13px;
letter-spacing: 1.5px; color: var(--dim); padding: 10px 18px;
border: none; background: none; cursor: pointer;
border-bottom: 2px solid transparent;
text-transform: uppercase; transition: all .2s;
}
.view-tab.active { color: var(--accent); border-color: var(--accent); }
/* ── VIDEO PANEL ── */
#view-video {
min-width: 0;
flex: 1; display: flex; align-items: center; justify-content: center;
background: #030608; overflow: hidden; position: relative;
}
.no-signal {
display: flex; flex-direction: column; align-items: center; gap: 14px; opacity: .35;
}
.no-signal-icon { font-size: 44px; }
.no-signal-txt { font-family: var(--mono); font-size: 12px; letter-spacing: 2px; }
.no-signal-sub { font-family: var(--mono); font-size: 10px; color: var(--dim); }
#liveCanvas {
max-width: 100%; max-height: calc(100vh - 130px);
display: none; border: 1px solid var(--border);
}
/* Buffer indicator */
#bufferInfo {
position: absolute; top: 10px; right: 14px;
font-family: var(--mono); font-size: 10px; color: var(--dim);
background: rgba(0,0,0,.55); padding: 4px 8px; border-radius: 2px;
display: none;
}
/* ── DASHBOARD PANEL ── */
#view-dashboard { flex: 1; overflow-y: auto; padding: 22px; display: none; }
.dash-toolbar {
display: flex; justify-content: flex-end; margin-bottom: 14px;
}
.dash-toolbar .field { width: min(260px, 100%); margin-bottom: 0; }
.dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin: 0 auto 22px; justify-content: center; }
.stat-card { background: var(--surface); border: 1px solid var(--border); padding: 18px; border-radius: 2px; }
.sc-label { font-family: var(--mono); font-size: 10px; color: var(--dim); letter-spacing: 1.5px; margin-bottom: 8px; }
.sc-value { font-family: var(--head); font-size: 34px; font-weight: 900; color: var(--accent); }
.sc-unit { font-family: var(--mono); font-size: 11px; color: var(--dim); }
.dashboard-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 34%); gap: 14px; align-items: start; }
.dashboard-main, .dashboard-side { min-width: 0; }
.chart-card { background: var(--surface); border: 1px solid var(--border); padding: 18px; border-radius: 2px; margin-bottom: 14px; }
.chart-card h3 { font-family: var(--head); font-size: 11px; letter-spacing: 2px; color: var(--dim); margin-bottom: 14px; text-transform: uppercase; }
.bar-chart { display: flex; flex-direction: column; gap: 9px; }
.bar-row { display: flex; align-items: center; gap: 10px; }
.bar-label { font-family: var(--mono); font-size: 11px; color: var(--dim); width: 80px; flex-shrink: 0; }
.bar-track { flex: 1; height: 18px; background: var(--panel); border-radius: 2px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 2px; transition: width .8s ease; }
.bar-count { font-family: var(--mono); font-size: 11px; color: var(--text); width: 34px; text-align: right; }
#timelineCanvas { width: 100%; height: 110px; display: block; }
#heatmapCanvas { width: 100%; aspect-ratio: 3 / 4; min-height: 300px; display: block; background: #0d1218; border: 1px solid rgba(30,45,61,.7); }
.heatmap-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; font-family: var(--mono); font-size: 11px; color: var(--dim); }
.heatmap-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); gap: 7px 10px; margin-top: 12px; }
.heatmap-legend-item { display: flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 11px; color: var(--dim); min-width: 0; }
.scene-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.scene-table th { font-family: var(--mono); font-size: 10px; letter-spacing: 1px; color: var(--dim); padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); }
.scene-table td { padding: 9px 12px; border-bottom: 1px solid rgba(30,45,61,.5); font-family: var(--mono); }
.scene-table tr:hover td { background: var(--panel); }
.badge { display: inline-block; padding: 2px 8px; font-family: var(--mono); font-size: 10px; border-radius: 2px; background: rgba(0,229,255,.1); color: var(--accent); border: 1px solid rgba(0,229,255,.25); }
/* ── LOGS PANEL ── */
#view-logs { flex: 1; overflow-y: auto; padding: 22px; display: none; }
.log-entry {
background: var(--surface); border: 1px solid var(--border);
padding: 12px 16px; margin-bottom: 7px; border-radius: 2px;
display: flex; align-items: center; justify-content: space-between;
}
.log-entry:hover { border-color: var(--accent); }
.log-name { font-family: var(--mono); font-size: 12px; color: var(--text); }
.log-meta { font-family: var(--mono); font-size: 10px; color: var(--dim); margin-top: 3px; }
.log-dl { color: var(--accent); text-decoration: none; font-family: var(--mono); font-size: 11px; }
@media (max-width: 1100px) {
.dashboard-layout { grid-template-columns: 1fr; }
#heatmapCanvas { aspect-ratio: 16 / 9; min-height: 220px; }
}
.log-dl:hover { text-decoration: underline; }
/* Toast */
#toast {
position: fixed; bottom: 22px; right: 22px;
background: var(--panel); border: 1px solid var(--border);
color: var(--text); font-family: var(--mono); font-size: 11px;
padding: 11px 18px; border-radius: 2px;
transform: translateY(70px); opacity: 0;
transition: all .3s; z-index: 9000;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.ok { border-color: var(--green); color: var(--green); }
#toast.err { border-color: var(--red); color: var(--red); }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@media (max-width: 1180px) {
body { font-size: 18px; }
.btn, .btn-primary, .btn-secondary { font-size: 17px; letter-spacing: 1px; }
.s-label, .field label, .nav-btn, .view-tab, .up-hint, .up-name, .counter-list, .cnt-card, .cnt-left, .cnt-val, .total-label, .total-val, .frame-info, .sc-label, .sc-value, .sc-unit, .bar-label, .bar-count, .log-name, .log-meta, .log-dl, #toast {
font-size: 15px !important;
}
aside { width: 230px; min-width: 230px; }
.right-sidebar { width: 215px; min-width: 215px; }
.right-sidebar .s-sect { padding: 9px 10px; }
.right-sidebar .s-label { margin-bottom: 5px; }
.right-sidebar .btn { padding: 6px 7px; font-size: 15px; letter-spacing: .8px; }
.right-sidebar .btn-secondary { margin-top: 4px; }
.right-sidebar .total-banner { padding: 5px 7px; margin-bottom: 5px; }
.right-sidebar .total-val { font-size: 23px; }
.right-sidebar .cnt-card { padding: 5px 7px; }
.right-sidebar .cnt-val { font-size: 18px; }
.s-sect { padding: 12px; }
.class-grid { grid-template-columns: 1fr 1fr; gap: 4px; }
.cls-chip { padding: 5px 6px; gap: 5px; }
.view-tabs { padding: 0 12px; }
.view-tab { padding: 9px 12px; }
}
@media (max-height: 760px) and (min-width: 900px) {
header { height: 48px; }
main { height: calc(100vh - 48px); }
.logo-box { width: 26px; height: 26px; }
.s-sect { padding: 7px 10px; }
.s-label { margin-bottom: 5px; letter-spacing: 1px; }
.upload-zone { padding: 6px 8px; }
.up-icon { display: none; }
.up-hint { line-height: 1.2; }
.up-name { margin-top: 4px; }
.field { margin-bottom: 5px; }
.field label { margin-bottom: 3px; }
.field input, .field select { padding: 5px 7px; }
.cls-chip { padding: 4px 6px; }
.btn { padding: 7px; }
.prog-bar { margin-top: 7px; }
.cnt-card { padding: 6px 8px; }
.total-banner { padding: 6px 8px; margin-bottom: 6px; }
.frame-info { line-height: 1.7; }
.right-sidebar .s-sect { padding: 6px 8px; }
.right-sidebar .s-label { margin-bottom: 4px; }
.right-sidebar .btn { padding: 5px 6px; }
.right-sidebar .prog-bar { height: 2px; margin-top: 5px; }
.right-sidebar .prog-label { margin-top: 2px; }
.right-sidebar .total-banner { padding: 4px 6px; margin-bottom: 4px; }
.right-sidebar .counter-list { gap: 4px; }
.right-sidebar .cnt-card { padding: 4px 6px; }
.right-sidebar .frame-info { line-height: 1.45; }
}
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-box">TS</div>
<div class="logo-text">TRAFFIC<span>SENSE</span></div>
</div>
<nav>
<button class="nav-btn active" id="navVideo" onclick="switchTab('video',this)">LIVE</button>
<button class="nav-btn" id="navDashboard" onclick="switchTab('dashboard',this)">DASHBOARD</button>
<button class="nav-btn" id="navLogs" onclick="switchTab('logs',this)">LOGS</button>
</nav>
<div class="header-right">
<div class="status-badge">
<div class="dot" id="connDot"></div>
<span id="connLabel">IDLE</span>
</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--dim)" id="clock">--:--:--</div>
</div>
</header>
<main>
<!-- ── SIDEBAR ── -->
<aside class="left-sidebar">
<!-- Upload -->
<div class="s-sect source-panel">
<div class="s-label">Video Source <span class="source-pill">INPUT</span></div>
<div class="source-choice">1. Video file</div>
<label class="upload-zone" id="uploadZone">
<input type="file" id="videoFile" accept="video/*"/>
<div class="up-icon">πŸ“Ή</div>
<div class="up-hint"><b>Drop video here</b><br>or click to browse</div>
<div class="up-name" id="fileName"></div>
</label>
<div class="source-divider">OR</div>
<div class="source-choice">2. Video URL</div>
<div class="field">
<input type="url" id="videoUrl" placeholder="https://example.com/video.mp4"/>
</div>
<div class="source-divider">OR</div>
<div class="source-choice">3. Webcam live</div>
<div class="webcam-source">
<button class="btn btn-secondary" id="webcamBtn" onclick="startWebcam()">β–£ START WEBCAM</button>
</div>
<!-- Progress bar for upload -->
<div id="uploadProgBar"><div id="uploadProgFill"></div></div>
<div id="uploadProgLabel"></div>
</div>
<!-- Config -->
<div class="s-sect">
<div class="field">
<label>SCENE NAME</label>
<input type="text" id="sceneName" value="Africa_countries"/>
</div>
<div class="field">
<label>GROUP ID</label>
<input type="text" id="groupId" value="Group_05"/>
</div>
<div class="field">
<label>MODEL</label>
<select id="modelSel">
<option value="best.pt" selected >YOLOv11 - Fine-tuned</option>
<option value="yolov8n.pt">YOLOv8n β€” nano</option>
<option value="yolov8s.pt">YOLOv8s β€” small</option>
<option value="yolov8m.pt">YOLOv8m β€” medium</option>
<option value="yolov8l.pt">YOLOv8l β€” large</option>
<option value="yolo11n.pt">YOLOv11n β€” nano</option>
<option value="yolo11s.pt">YOLOv11s β€” small</option>
<option value="yolo11x.pt">YOLOv11x</option>
</select>
</div>
<div class="field last-config-field">
<label>CONFIDENCE</label>
<input type="number" id="confVal" value="0.5" min="0.1" max="0.95" step="0.05"/>
</div>
</div>
<!-- Classes -->
<!-- <div class="s-sect">
<div class="s-label">Track Classes</div>
<div class="class-grid" id="classGrid"></div>
</div> -->
<!-- Watch Video Modal -->
<div id="videoModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.95);z-index:10000;align-items:center;justify-content:center;flex-direction:column">
<div style="position:relative;width:90%;max-width:900px;height:80vh;display:flex;flex-direction:column">
<button onclick="closeVideoPlayer()" style="position:absolute;top:-40px;right:0;background:none;border:none;color:var(--accent);font-size:28px;cursor:pointer;z-index:10001">βœ•</button>
<video id="annotatedVideo" controls style="width:100%;height:100%;background:#000;border:1px solid var(--border)" playsinline></video>
</div>
</div>
<!-- Total unique -->
<!-- <div class="s-sect">
<div class="s-label">Unique Objects Counted</div>
<div class="total-banner">
<div class="total-label">TOTAL</div>
<div class="total-val" id="totalUnique">0</div>
</div>
<div class="counter-list" id="counterList">
<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No data yet</div>
</div>
</div> -->
</aside>
<!-- ── CONTENT ── -->
<div class="content">
<div class="view-tabs">
<button class="view-tab active" onclick="switchTab('video',this)">VIDEO FEED</button>
<button class="view-tab" onclick="switchTab('dashboard',this)">ANALYTICS</button>
<button class="view-tab" onclick="switchTab('logs',this)">LOG FILES</button>
</div>
<!-- VIDEO -->
<div id="view-video">
<div id="noSignal" class="no-signal">
<div class="no-signal-icon">πŸ“‘</div>
<div class="no-signal-txt">AWAITING VIDEO FEED</div>
<div class="no-signal-sub">Upload a video and click START</div>
</div>
<canvas id="liveCanvas"></canvas>
<video id="webcamVideo" autoplay muted playsinline style="display:none"></video>
<div id="bufferInfo">BUF: 0</div>
</div>
<!-- DASHBOARD -->
<div id="view-dashboard">
<div class="dash-toolbar">
<div class="field">
<label>FILTER BY SCENE</label>
<select id="sceneFilter" onchange="applySceneFilter()">
<option value="">All scenes</option>
</select>
</div>
</div>
<div class="dashboard-layout">
<div class="dashboard-main">
<div class="dash-grid" id="dashStats"></div>
<div class="chart-card">
<h3 id="objectsChartTitle">Objects by Class - All Scenes</h3>
<div class="bar-chart" id="barChart"></div>
</div>
<div class="chart-card">
<h3>Traffic Intensity Timeline (10s buckets)</h3>
<canvas id="timelineCanvas"></canvas>
</div>
<div class="chart-card">
<h3 id="sceneComparisonTitle">Scene Comparison</h3>
<table class="scene-table">
<thead>
<tr>
<th>SCENE</th><th>DURATION</th><th>TOTAL</th>
<th>CARS</th><th>PEDESTRIANS</th><th>TRUCKS/BUS</th>
</tr>
</thead>
<tbody id="sceneTable"></tbody>
</table>
</div>
</div>
<div class="dashboard-side">
<div class="chart-card">
<h3 id="heatmapTitle">Position Heatmap of the Objects</h3>
<canvas id="heatmapCanvas"></canvas>
<div class="heatmap-meta">
<span id="heatmapPoints">0 positions</span>
<span id="heatmapPeak">Peak 0</span>
</div>
<div class="heatmap-legend" id="heatmapLegend"></div>
</div>
</div>
</div>
</div>
<!-- LOGS -->
<div id="view-logs">
<div id="logList"><div style="font-family:var(--mono);font-size:12px;color:var(--dim)">No logs yet.</div></div>
</div>
</div>
<!-- ── RIGHT INFO SIDEBAR ── -->
<aside class="right-sidebar">
<div class="s-sect">
<div class="s-label">Frame Info</div>
<button class="btn btn-primary" id="startBtn" onclick="startProcessing()">β–Ά START ANALYSIS</button>
<button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β–  STOP</button>
<button class="btn btn-secondary" id="watchVideoBtn" style="display:none;text-align:center;margin-top:7px" onclick="openVideoPlayer()">β–Ά WATCH VIDEO</button>
<div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
<div class="prog-label" id="progLabel"></div>
<a class="btn btn-secondary" id="downloadVideoBtn" style="display:none;text-align:center;text-decoration:none;margin-top:7px" download>⬇ DOWNLOAD ANNOTATED VIDEO</a>
</div>
<div class="s-sect" id="frameInfoPanel" style="display:none">
<div class="frame-info" id="frameInfo"></div>
</div>
<!-- Total unique -->
<div class="s-sect">
<div class="s-label">Unique Objects Counted</div>
<div class="total-banner">
<div class="total-label">TOTAL</div>
<div class="total-val" id="totalUnique">0</div>
</div>
<div class="counter-list" id="counterList">
<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No data yet</div>
</div>
</div>
<!-- Classes -->
<div class="s-sect">
<div class="s-label">Track Classes</div>
<div class="class-grid" id="classGrid"></div>
</div>
</aside>
</main>
<div id="toast"></div>
<script>
// ══ CONFIG ════════════════════════════════════════════════════════════════════
const API_BASE = window.location.origin;
const CLASS_COLORS = {
person: '#ff3860',
bicycle: '#00ff88',
car: '#ffb300',
motorbike: '#ff6eb4',
bus: '#2f80ff',
truck: '#ce93d8',
};
const ALL_CLASSES = ['person','bicycle','car','motorbike','bus','truck'];
// ══ STATE ═════════════════════════════════════════════════════════════════════
let currentSid = null;
let sseSource = null;
let selectedFile = null;
let pollTimer = null;
let dashboardCache = null;
let webcamStream = null;
let webcamSid = null;
let webcamTimer = null;
let webcamBusy = false;
// Canvas
const canvas = document.getElementById('liveCanvas');
const ctx = canvas.getContext('2d');
// ── Playback queue ────────────────────────────────────────────────────────────
// Each entry: { src: 'data:image/jpeg;base64,...', stats: {...}, pts: float }
// pts = video time in seconds when this frame should be displayed.
let frameQueue = []; // ordered list of received frames
let playbackStart = null; // performance.now() when playback started
let videoStart = null; // pts of the first frame (usually ~0)
let rafId = null;
let lastRenderedPts = -1;
// ══ INIT ══════════════════════════════════════════════════════════════════════
window.onload = () => {
buildClassGrid();
setInterval(() => {
document.getElementById('clock').textContent =
new Date().toLocaleTimeString('en-GB', {hour12: false});
}, 1000);
checkHealth();
restoreLastSession();
document.getElementById('videoFile').addEventListener('change', e => {
selectedFile = e.target.files[0];
document.getElementById('fileName').textContent = selectedFile?.name || '';
if (selectedFile) document.getElementById('videoUrl').value = '';
});
document.getElementById('videoUrl').addEventListener('input', e => {
if (e.target.value.trim()) {
selectedFile = null;
document.getElementById('videoFile').value = '';
document.getElementById('fileName').textContent = '';
}
});
const zone = document.getElementById('uploadZone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('drag');
if (e.dataTransfer.files[0]) {
selectedFile = e.dataTransfer.files[0];
document.getElementById('fileName').textContent = selectedFile.name;
document.getElementById('videoUrl').value = '';
}
});
};
function buildClassGrid() {
const grid = document.getElementById('classGrid');
ALL_CLASSES.forEach(cls => {
const lbl = document.createElement('label');
lbl.className = 'cls-chip on';
lbl.innerHTML = `
<input type="checkbox" value="${cls}" checked/>
<span class="cls-dot" style="background:${CLASS_COLORS[cls]}"></span>
<span class="cls-name">${cls}</span>`;
lbl.querySelector('input').addEventListener('change', function() {
lbl.classList.toggle('on', this.checked);
});
grid.appendChild(lbl);
});
}
async function checkHealth() {
try {
const r = await fetch(`${API_BASE}/health`);
setConn(r.ok ? 'live' : 'err', r.ok ? 'CONNECTED' : 'SERVER ERROR');
} catch { setConn('err', 'OFFLINE'); }
}
function setConn(cls, label) {
document.getElementById('connDot').className = 'dot ' + cls;
document.getElementById('connLabel').textContent = label;
}
// ══ TAB SWITCHING ════════════════════════════════════════════════════════════
function switchTab(tab, btn) {
document.querySelectorAll('.view-tab, .nav-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
document.getElementById('view-video').style.display = tab === 'video' ? 'flex' : 'none';
document.getElementById('view-dashboard').style.display = tab === 'dashboard' ? 'block' : 'none';
document.getElementById('view-logs').style.display = tab === 'logs' ? 'block' : 'none';
if (tab === 'dashboard') loadDashboard();
if (tab === 'logs') loadLogs();
}
// ══ PROCESSING ════════════════════════════════════════════════════════════════
async function startProcessing() {
if (webcamSid || webcamStream) await stopWebcam(false);
const videoUrl = document.getElementById('videoUrl').value.trim();
if (!selectedFile && !videoUrl) { toast('Upload a video file or enter a video URL.', 'err'); return; }
const selected = getSelectedClasses();
if (!selected.length) { toast('Select at least one class.', 'err'); return; }
const fd = new FormData();
if (selectedFile) fd.append('file', selectedFile);
else fd.append('video_url', videoUrl);
fd.append('scene_name', document.getElementById('sceneName').value || 'scene_01');
fd.append('group_id', document.getElementById('groupId').value || 'group_01');
fd.append('classes', selected.join(','));
fd.append('conf', document.getElementById('confVal').value);
fd.append('model', document.getElementById('modelSel').value);
setUI('processing');
resetCounters();
// Show upload progress bar
const progBar = document.getElementById('uploadProgBar');
const progFill = document.getElementById('uploadProgFill');
const progLabel = document.getElementById('uploadProgLabel');
progBar.style.display = 'block';
progFill.style.width = '0%';
progLabel.style.display = 'block';
progLabel.textContent = selectedFile ? 'Uploading...' : 'Fetching URL...';
try {
// Use XMLHttpRequest for progress
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/upload`);
xhr.onload = async function() {
progFill.style.width = '100%';
progLabel.textContent = 'Processing...';
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
currentSid = data.session_id;
document.getElementById('sceneName').value = data.scene_name;
localStorage.setItem('trafficTracker:lastSessionId', currentSid);
toast(`Session: ${currentSid}`, 'ok');
startSSE(currentSid);
startPoll(currentSid);
resolve();
} catch(e) {
reject(new Error('Invalid server response'));
}
} else {
reject(new Error(xhr.responseText));
}
};
xhr.onerror = function() {
reject(new Error('Network error'));
};
xhr.upload.onprogress = function(e) {
if (selectedFile && e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
progFill.style.width = pct + '%';
progLabel.textContent = `Uploading... ${pct}%`;
}
};
xhr.send(fd);
});
} catch(e) {
toast('Upload failed: ' + e.message, 'err');
setUI('idle');
} finally {
// Hide upload progress bar after a short delay
setTimeout(() => {
progBar.style.display = 'none';
progLabel.style.display = 'none';
}, 1200);
}
}
function getSelectedClasses() {
return [...document.querySelectorAll('#classGrid input:checked')].map(i => i.value);
}
function stopProcessing(showToast = true) {
if (webcamSid || webcamStream) {
stopWebcam(showToast);
return;
}
if (sseSource) { sseSource.close(); sseSource = null; }
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
frameQueue = [];
playbackStart = null;
videoStart = null;
lastRenderedPts = -1;
setUI('idle');
if (showToast) toast('Stopped.', '');
}
async function startWebcam() {
const selected = getSelectedClasses();
if (!selected.length) { toast('Select at least one class.', 'err'); return; }
stopProcessing(false);
resetCounters();
setUI('processing');
setConn('proc', 'WEBCAM');
try {
webcamStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false,
});
const video = document.getElementById('webcamVideo');
video.srcObject = webcamStream;
await video.play();
const fd = new FormData();
fd.append('scene_name', document.getElementById('sceneName').value || 'webcam');
fd.append('group_id', document.getElementById('groupId').value || 'group_01');
fd.append('classes', selected.join(','));
fd.append('conf', document.getElementById('confVal').value);
fd.append('model', document.getElementById('modelSel').value);
const r = await fetch(`${API_BASE}/webcam/start`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
webcamSid = data.session_id;
currentSid = webcamSid;
document.getElementById('sceneName').value = data.scene_name;
document.getElementById('noSignal').style.display = 'none';
canvas.style.display = 'block';
document.getElementById('bufferInfo').style.display = 'block';
document.getElementById('bufferInfo').textContent = 'WEBCAM';
toast(`Webcam session: ${webcamSid}`, 'ok');
webcamTimer = setInterval(captureWebcamFrame, 220);
} catch (e) {
toast('Webcam failed: ' + e.message, 'err');
await stopWebcam(false);
}
}
async function captureWebcamFrame() {
if (!webcamSid || webcamBusy) return;
const video = document.getElementById('webcamVideo');
if (!video.videoWidth || !video.videoHeight) return;
webcamBusy = true;
try {
const capture = document.createElement('canvas');
capture.width = video.videoWidth;
capture.height = video.videoHeight;
capture.getContext('2d').drawImage(video, 0, 0, capture.width, capture.height);
const blob = await new Promise(resolve => capture.toBlob(resolve, 'image/jpeg', 0.75));
if (!blob) return;
const fd = new FormData();
fd.append('file', blob, 'webcam.jpg');
const r = await fetch(`${API_BASE}/webcam/frame/${encodeURIComponent(webcamSid)}`, {
method: 'POST',
body: fd,
});
if (!r.ok) return;
const data = await r.json();
const img = new Image();
img.onload = () => {
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + data.frame;
applyStats(data.stats);
document.getElementById('bufferInfo').textContent = 'WEBCAM LIVE';
} finally {
webcamBusy = false;
}
}
async function stopWebcam(showToast = true) {
if (webcamTimer) {
clearInterval(webcamTimer);
webcamTimer = null;
}
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
const sid = webcamSid;
webcamSid = null;
webcamBusy = false;
if (sid) {
try {
await fetch(`${API_BASE}/webcam/stop/${encodeURIComponent(sid)}`, { method: 'POST' });
loadDashboard();
} catch {}
}
setUI('idle');
if (showToast) toast('Webcam stopped.', '');
}
function setUI(state) {
const start = document.getElementById('startBtn');
const stop = document.getElementById('stopBtn');
const webcam = document.getElementById('webcamBtn');
if (state === 'processing') {
start.disabled = true;
webcam.disabled = true;
stop.style.display = 'block';
setConn('proc', 'PROCESSING');
} else {
start.disabled = false;
webcam.disabled = false;
stop.style.display = 'none';
document.getElementById('progFill').style.width = '0%';
document.getElementById('progLabel').textContent = '';
setConn('live', 'CONNECTED');
document.getElementById('bufferInfo').style.display = 'none';
}
}
function resetCounters() {
document.getElementById('totalUnique').textContent = '0';
document.getElementById('counterList').innerHTML =
'<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">Processing…</div>';
document.getElementById('frameInfoPanel').style.display = 'none';
}
// ══ SSE STREAM ════════════════════════════════════════════════════════════════
function startSSE(sid) {
if (sseSource) sseSource.close();
// Show canvas, hide no-signal
document.getElementById('noSignal').style.display = 'none';
canvas.style.display = 'block';
document.getElementById('bufferInfo').style.display = 'block';
// Reset playback state
frameQueue = [];
playbackStart = null;
videoStart = null;
lastRenderedPts = -1;
// Start render loop
if (rafId) cancelAnimationFrame(rafId);
renderLoop();
sseSource = new EventSource(`${API_BASE}/stream/${sid}`);
sseSource.onmessage = ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
// ── DONE event ───────────────────────────────────────────────────────
if (msg.event === 'done') {
sseSource.close();
sseSource = null;
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
document.getElementById('progFill').style.width = '100%';
const summary = msg.summary;
if (summary) {
const total = summary.total_unique !== undefined
? summary.total_unique
: Object.values(summary.count_per_class || {}).reduce((a, b) => a + b, 0);
updateCounters(summary.count_per_class || {}, total);
toast(`Done! ${total} unique objects counted.`, 'ok');
} else {
toast('Processing complete!', 'ok');
}
setConn('live', 'DONE');
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
// Show annotated video download and watch buttons
showSessionVideoActions(currentSid);
return;
}
// ── FRAME event ──────────────────────────────────────────────────────
if (msg.frame) {
// Preload image so it's ready to paint without decode latency
const img = new Image();
img.src = 'data:image/jpeg;base64,' + msg.frame;
frameQueue.push({
img,
stats: msg.stats,
pts: msg.pts ?? (msg.stats ? msg.stats.timestamp : 0),
});
}
};
sseSource.onerror = () => {
if (currentSid) checkStatus(currentSid);
};
}
// ── Sequential frame renderer ─────────────────────────────────────────────────
// Plays frames one-by-one at native video FPS. Never skips a frame.
// If YOLO is slower than video FPS, we simply wait for the next frame to
// arrive; playback auto-resumes the instant one is ready.
// frameInterval is derived from pts deltas so it matches the source video exactly.
let frameInterval = 1000 / 30; // ms between frames (recalculated on first frames)
let nextFrameAt = 0; // performance.now() target for the next frame
function renderLoop(now) {
const bufEl = document.getElementById('bufferInfo');
rafId = requestAnimationFrame(renderLoop);
if (frameQueue.length === 0) {
bufEl.textContent = 'BUF: 0 β€” waiting';
return;
}
// Derive frame interval from pts of first two frames (once)
if (frameQueue.length >= 2 && frameInterval === 1000 / 30) {
const delta = frameQueue[1].pts - frameQueue[0].pts;
if (delta > 0) frameInterval = delta * 1000;
}
// Initialise schedule on very first frame
if (nextFrameAt === 0) nextFrameAt = now;
// Not yet time β€” keep waiting
if (now < nextFrameAt) {
bufEl.textContent = `BUF: ${frameQueue.length}`;
return;
}
// Pop and render next frame in order
const item = frameQueue.shift();
if (item.img.complete && item.img.naturalWidth > 0) {
if (canvas.width !== item.img.naturalWidth) canvas.width = item.img.naturalWidth;
if (canvas.height !== item.img.naturalHeight) canvas.height = item.img.naturalHeight;
ctx.drawImage(item.img, 0, 0);
}
if (item.stats) applyStats(item.stats);
// Schedule next frame; if we fell behind (e.g. tab was hidden),
// snap to now so we don't burst-dump a backlog.
nextFrameAt = Math.max(nextFrameAt + frameInterval, now - frameInterval);
bufEl.textContent = `BUF: ${frameQueue.length}`;
}
// ══ STATS UPDATE ══════════════════════════════════════════════════════════════
function applyStats(stats) {
if (!stats) return;
const total = typeof stats.total_unique === 'number'
? stats.total_unique
: Object.values(stats.cumulative || {}).reduce((a, b) => a + b, 0);
updateCounters(stats.cumulative || {}, total);
document.getElementById('frameInfoPanel').style.display = 'block';
document.getElementById('frameInfo').innerHTML =
`FRAME &nbsp;&nbsp;<span class="fi-val">${stats.frame ?? '--'}</span><br>` +
`TIME &nbsp;&nbsp;&nbsp;<span class="fi-val">${stats.timestamp ?? '--'}s</span><br>` +
`SCENE &nbsp;&nbsp;<span class="fi-val">${stats.scene ?? '--'}</span><br>` +
`DETECTED <span class="fi-val">${stats.detections ?? 0}</span><br>` +
`VISIBLE &nbsp;<span class="fi-val" style="color:${stats.any_visible ? 'var(--green)' : 'var(--dim)'}">` +
`${stats.any_visible ? 'YES' : 'NO'}</span>`;
}
function updateCounters(cumulative, total) {
document.getElementById('totalUnique').textContent = Number.isFinite(total) ? total : 0;
const list = document.getElementById('counterList');
const entries = Object.entries(cumulative).filter(([, v]) => v > 0);
if (!entries.length) {
list.innerHTML = '<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:8px">No crossings yet</div>';
return;
}
list.innerHTML = entries.map(([cls, cnt]) => `
<div class="cnt-card">
<div class="cnt-left">
<span class="cls-dot" style="background:${CLASS_COLORS[cls] || '#888'}"></span>
${cls}
</div>
<div class="cnt-val">${cnt}</div>
</div>
`).join('');
}
// ══ POLL ══════════════════════════════════════════════════════════════════════
function startPoll(sid) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => checkStatus(sid), 1500);
}
async function checkStatus(sid) {
try {
const r = await fetch(`${API_BASE}/status/${sid}`);
const d = await r.json();
if (d.total_frames > 0) {
const pct = Math.min(100, Math.round(d.progress / d.total_frames * 100));
document.getElementById('progFill').style.width = pct + '%';
document.getElementById('progLabel').textContent = `${d.progress} / ${d.total_frames} (${pct}%)`;
}
if (d.status === 'done' || d.status === 'error') {
clearInterval(pollTimer);
pollTimer = null;
if (d.status === 'error') toast('Error: ' + d.error, 'err');
}
} catch {}
}
// ══ DASHBOARD ════════════════════════════════════════════════════════════════
async function loadDashboard() {
try {
const r = await fetch(`${API_BASE}/dashboard`);
const d = await r.json();
dashboardCache = d;
populateSceneFilter(d.scenes || []);
applySceneFilter();
} catch {
document.getElementById('dashStats').innerHTML =
'<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>';
}
}
function populateSceneFilter(scenes) {
const select = document.getElementById('sceneFilter');
const current = select.value;
const names = [...new Set(scenes.map(sc => sc.scene).filter(Boolean))].sort();
select.innerHTML = '<option value="">All scenes</option>' +
names.map(name => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join('');
if (names.includes(current)) select.value = current;
}
function applySceneFilter() {
if (!dashboardCache) return;
const scene = document.getElementById('sceneFilter').value;
const scenes = (dashboardCache.scenes || []).filter(sc => !scene || sc.scene === scene);
const global_counts = {};
for (const sc of scenes) {
for (const [cls, cnt] of Object.entries(sc.count_per_class || {})) {
global_counts[cls] = (global_counts[cls] || 0) + cnt;
}
}
renderDashboard({
scenes,
global_counts,
total_scenes: scenes.length,
total_objects: Object.values(global_counts).reduce((a, b) => a + b, 0),
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
function renderDashboard(d) {
const { global_counts = {}, total_scenes = 0, total_objects = 0, scenes = [] } = d;
const totalDur = scenes.reduce((s, sc) => s + (sc.duration_sec || 0), 0);
const selectedScene = document.getElementById('sceneFilter')?.value || '';
const scope = selectedScene ? selectedScene : 'All Scenes';
document.getElementById('objectsChartTitle').textContent = `Objects by Class - ${scope}`;
document.getElementById('sceneComparisonTitle').textContent = selectedScene ? 'Selected Scene' : 'Scene Comparison';
document.getElementById('dashStats').innerHTML = [
statCard('SCENES', total_scenes, 'videos'),
statCard('TOTAL OBJECTS', total_objects, 'unique crossings'),
statCard('TOTAL DURATION',Math.round(totalDur), 'seconds'),
statCard('AVG / SCENE', total_scenes ? Math.round(total_objects / total_scenes) : 0, 'objects'),
].join('');
const max = Math.max(...Object.values(global_counts), 1);
document.getElementById('barChart').innerHTML =
Object.entries(global_counts).map(([cls, cnt]) => `
<div class="bar-row">
<div class="bar-label">${cls}</div>
<div class="bar-track">
<div class="bar-fill" style="width:${(cnt/max*100).toFixed(1)}%;background:${CLASS_COLORS[cls]||'#888'}"></div>
</div>
<div class="bar-count">${cnt}</div>
</div>`).join('') ||
'<div style="color:var(--dim);font-family:var(--mono);font-size:12px">No data yet</div>';
renderTimeline(scenes);
renderPositionHeatmap(scenes);
document.getElementById('sceneTable').innerHTML =
scenes.map(sc => {
const cp = sc.count_per_class || {};
return `<tr>
<td><span class="badge">${sc.scene}</span></td>
<td>${sc.duration_sec}s</td>
<td style="color:var(--accent)">${sc.total_unique_objects ?? sc.total_unique ?? 0}</td>
<td>${cp.car || 0}</td>
<td>${cp.person || 0}</td>
<td>${(cp.truck || 0) + (cp.bus || 0)}</td>
</tr>`;
}).join('') ||
'<tr><td colspan="6" style="color:var(--dim);font-family:var(--mono);padding:20px">No scenes yet.</td></tr>';
}
function statCard(label, value, unit) {
return `<div class="stat-card">
<div class="sc-label">${label}</div>
<div class="sc-value">${value}</div>
<div class="sc-unit">${unit}</div>
</div>`;
}
function renderTimeline(scenes) {
const c = document.getElementById('timelineCanvas');
const ctx2 = c.getContext('2d');
c.width = c.offsetWidth || 800;
c.height = 110;
const merged = {};
for (const sc of scenes)
for (const t of (sc.temporal_distribution || []))
merged[t.bucket_10s] = (merged[t.bucket_10s] || 0) + t.detections;
const keys = Object.keys(merged).map(Number).sort((a,b) => a - b);
ctx2.fillStyle = '#0d1218'; ctx2.fillRect(0, 0, c.width, c.height);
if (!keys.length) return;
const vals = keys.map(k => merged[k]);
const maxV = Math.max(...vals, 1);
const pad = 12;
const w = (c.width - pad * 2) / Math.max(keys.length - 1, 1);
const pts = keys.map((k, i) => [pad + i * w, c.height - pad - (merged[k] / maxV) * (c.height - pad * 2)]);
ctx2.strokeStyle = '#1e2d3d'; ctx2.lineWidth = 1;
[.25,.5,.75,1].forEach(f => {
const y = c.height - pad - f * (c.height - pad * 2);
ctx2.beginPath(); ctx2.moveTo(0, y); ctx2.lineTo(c.width, y); ctx2.stroke();
});
ctx2.beginPath();
ctx2.moveTo(pts[0][0], c.height - pad);
pts.forEach(([x, y]) => ctx2.lineTo(x, y));
ctx2.lineTo(pts[pts.length-1][0], c.height - pad);
ctx2.closePath();
const g = ctx2.createLinearGradient(0, 0, 0, c.height);
g.addColorStop(0, 'rgba(0,229,255,.38)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx2.fillStyle = g; ctx2.fill();
ctx2.beginPath(); ctx2.strokeStyle = '#00e5ff'; ctx2.lineWidth = 2;
pts.forEach(([x, y], i) => i === 0 ? ctx2.moveTo(x, y) : ctx2.lineTo(x, y));
ctx2.stroke();
ctx2.fillStyle = '#4a6070'; ctx2.font = '10px Share Tech Mono'; ctx2.textAlign = 'center';
keys.forEach((k, i) => {
if (i % Math.ceil(keys.length / 8) === 0)
ctx2.fillText(`${k * 10}s`, pad + i * w, c.height - 1);
});
}
function renderPositionHeatmap(scenes) {
const c = document.getElementById('heatmapCanvas');
const ctx = c.getContext('2d');
const cssWidth = c.offsetWidth || 360;
const cssHeight = c.offsetHeight || Math.round(cssWidth * 1.25);
const dpr = window.devicePixelRatio || 1;
c.width = Math.round(cssWidth * dpr);
c.height = Math.round(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#0d1218';
ctx.fillRect(0, 0, cssWidth, cssHeight);
const merged = {};
const classTotals = {};
let cols = 24;
let rows = 24;
let totalPoints = 0;
for (const sc of scenes) {
const hm = sc.position_heatmap || {};
cols = hm.cols || cols;
rows = hm.rows || rows;
totalPoints += hm.total_points || 0;
for (const cell of (hm.cells || [])) {
const key = `${cell.x},${cell.y}`;
if (!merged[key]) merged[key] = { count: 0, classes: {} };
merged[key].count += cell.count || 0;
for (const [cls, count] of Object.entries(cell.classes || {})) {
merged[key].classes[cls] = (merged[key].classes[cls] || 0) + count;
classTotals[cls] = (classTotals[cls] || 0) + count;
}
}
}
const values = Object.values(merged).map(cell => cell.count);
const maxDensity = Math.max(...values, 0);
const leftPad = 42;
const rightPad = 14;
const topPad = 18;
const bottomPad = 34;
const plotW = cssWidth - leftPad - rightPad;
const plotH = cssHeight - topPad - bottomPad;
const cellW = plotW / cols;
const cellH = plotH / rows;
ctx.fillStyle = '#4a6070';
ctx.font = '10px Share Tech Mono';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let i = 0; i <= 4; i++) {
const x = leftPad + (plotW / 4) * i;
ctx.fillText(`${i * 25}%`, x, cssHeight - bottomPad + 12);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const y = topPad + (plotH / 4) * i;
ctx.fillText(`${i * 25}%`, leftPad - 9, y);
}
ctx.strokeStyle = '#1e2d3d';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const x = leftPad + (plotW / 4) * i;
const y = topPad + (plotH / 4) * i;
ctx.beginPath(); ctx.moveTo(x, topPad); ctx.lineTo(x, topPad + plotH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(leftPad, y); ctx.lineTo(leftPad + plotW, y); ctx.stroke();
}
if (!maxDensity) {
ctx.fillStyle = '#4a6070';
ctx.font = '12px Share Tech Mono';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('No position data yet', leftPad + plotW / 2, topPad + plotH / 2);
} else {
for (const [key, cell] of Object.entries(merged)) {
const [x, y] = key.split(',').map(Number);
const intensity = Math.sqrt(cell.count / maxDensity);
const dominantClass = Object.entries(cell.classes)
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
ctx.fillStyle = hexToRgba(CLASS_COLORS[dominantClass] || '#c8d8e8', 0.18 + intensity * 0.78);
ctx.fillRect(leftPad + x * cellW, topPad + y * cellH, Math.ceil(cellW), Math.ceil(cellH));
}
}
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5;
ctx.strokeRect(leftPad, topPad, plotW, plotH);
document.getElementById('heatmapPoints').textContent = `${totalPoints.toLocaleString()} positions`;
document.getElementById('heatmapPeak').textContent = `Peak ${maxDensity.toLocaleString()}`;
document.getElementById('heatmapLegend').innerHTML = ALL_CLASSES
.filter(cls => classTotals[cls] || scenes.some(sc => (sc.selected_classes || []).includes(cls)))
.map(cls => `
<div class="heatmap-legend-item">
<span class="cls-dot" style="background:${CLASS_COLORS[cls] || '#888'}"></span>
<span>${cls}</span>
</div>`).join('');
}
function hexToRgba(hex, alpha) {
const clean = String(hex).replace('#', '');
const value = parseInt(clean.length === 3
? clean.split('').map(ch => ch + ch).join('')
: clean, 16);
const r = (value >> 16) & 255;
const g = (value >> 8) & 255;
const b = value & 255;
return `rgba(${r},${g},${b},${alpha})`;
}
// ══ LOGS ══════════════════════════════════════════════════════════════════════
async function loadLogs() {
try {
const r = await fetch(`${API_BASE}/logs`);
const d = await r.json();
const el = document.getElementById('logList');
el.innerHTML = d.logs.length
? d.logs.map(f => `
<div class="log-entry">
<div>
<div class="log-name">${f.name}</div>
<div class="log-meta">${f.size_kb} KB Β· ${new Date(f.modified * 1000).toLocaleString()}</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
${f.name.toLowerCase().endsWith('.mp4')
? `<button class="log-dl" onclick="openVideoFile('${encodeURIComponent(f.name)}')">β–Ά Watch</button>`
: ''}
<a class="log-dl" href="${API_BASE}/log/${encodeURIComponent(f.name)}" download>⬇ Download</a>
</div>
</div>`).join('')
: '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">No log files yet.</div>';
} catch {
document.getElementById('logList').innerHTML =
'<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>';
}
}
// ══ 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 = '', 3800);
}
// ══ VIDEO PLAYER ═══════════════════════════════════════════════════════════════
function showSessionVideoActions(sid) {
if (!sid) return;
currentSid = sid;
const dlBtn = document.getElementById('downloadVideoBtn');
const watchBtn = document.getElementById('watchVideoBtn');
dlBtn.href = `${API_BASE}/download/video/${encodeURIComponent(sid)}`;
dlBtn.style.display = 'block';
watchBtn.style.display = 'block';
}
async function restoreLastSession() {
const sid = localStorage.getItem('trafficTracker:lastSessionId');
if (!sid) return;
try {
const r = await fetch(`${API_BASE}/summary/${encodeURIComponent(sid)}`);
if (!r.ok) return;
const summary = await r.json();
const total = summary.total_unique !== undefined
? summary.total_unique
: Object.values(summary.count_per_class || {}).reduce((a, b) => a + b, 0);
updateCounters(summary.count_per_class || {}, total);
showSessionVideoActions(sid);
setConn('live', 'DONE');
} catch {}
}
function playVideoSource(src) {
const modal = document.getElementById('videoModal');
const video = document.getElementById('annotatedVideo');
video.src = src;
modal.style.display = 'flex';
video.load();
video.play().catch(() => {
toast('Video ready. Press play in the player.', '');
});
}
function openVideoPlayer() {
if (!currentSid) { toast('No session found.', 'err'); return; }
playVideoSource(`${API_BASE}/stream/video/${encodeURIComponent(currentSid)}?t=${Date.now()}`);
}
function openVideoFile(filename) {
playVideoSource(`${API_BASE}/stream/video-file/${filename}?t=${Date.now()}`);
}
function closeVideoPlayer() {
const modal = document.getElementById('videoModal');
const video = document.getElementById('annotatedVideo');
modal.style.display = 'none';
video.pause();
video.src = '';
}
</script>
</body>
</html>