Spaces:
Sleeping
Sleeping
| <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) ; | |
| } | |
| #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('&', '&') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| .replaceAll("'", '''); | |
| } | |
| 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> | |