Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ISR Command Center</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <style> | |
| /* ── CSS Design System ─────────────────────────────────────────── */ | |
| :root { | |
| --base: #020617; | |
| --accent: #3b82f6; | |
| --accent-light: #60a5fa; | |
| --danger: #ef4444; | |
| --warning: #f59e0b; | |
| --success: #22c55e; | |
| --text-primary: rgba(255, 255, 255, 0.92); | |
| --text-secondary: rgba(255, 255, 255, 0.58); | |
| --text-tertiary: rgba(255, 255, 255, 0.35); | |
| --panel-bg: rgba(255, 255, 255, 0.02); | |
| --panel-border: rgba(255, 255, 255, 0.06); | |
| } | |
| /* ── Reset ──────────────────────────────────────────────────────── */ | |
| *, *::before, *::after { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| font-size: 13px; | |
| color: var(--text-primary); | |
| background: var(--base); | |
| overflow: hidden; | |
| width: 100vw; | |
| height: 100vh; | |
| display: grid; | |
| grid-template-rows: 44px 1fr auto auto; | |
| grid-template-columns: 1fr; | |
| position: relative; | |
| } | |
| /* ── Ambient Background ────────────────────────────────────────── */ | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: | |
| radial-gradient(ellipse 80% 50% at 20% 40%, rgba(59, 130, 246, 0.04) 0%, transparent 70%), | |
| radial-gradient(ellipse 60% 60% at 80% 60%, rgba(96, 165, 250, 0.03) 0%, transparent 70%), | |
| radial-gradient(ellipse 90% 40% at 50% 90%, rgba(59, 130, 246, 0.02) 0%, transparent 60%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| body > * { | |
| position: relative; | |
| z-index: 1; | |
| } | |
| /* ── Panel Base ────────────────────────────────────────────────── */ | |
| .panel { | |
| background: var(--panel-bg); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 10px; | |
| transition: border-color 0.2s ease, background 0.2s ease; | |
| } | |
| .panel:hover { | |
| border-color: rgba(255, 255, 255, 0.1); | |
| } | |
| /* ── Typography ────────────────────────────────────────────────── */ | |
| .label { | |
| font-size: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| color: var(--text-tertiary); | |
| font-weight: 500; | |
| } | |
| .value { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 13px; | |
| color: var(--text-primary); | |
| } | |
| .heading { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| letter-spacing: 0.3px; | |
| } | |
| /* ── Indicators ────────────────────────────────────────────────── */ | |
| .dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| display: inline-block; | |
| flex-shrink: 0; | |
| } | |
| .dot--lg { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| .dot--glow { | |
| box-shadow: 0 0 6px currentColor, 0 0 12px currentColor; | |
| } | |
| .pill { | |
| font-size: 8px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| line-height: 1; | |
| } | |
| /* ── Utility ───────────────────────────────────────────────────── */ | |
| .hidden { | |
| display: none ; | |
| } | |
| /* ── Scrollbar ─────────────────────────────────────────────────── */ | |
| ::-webkit-scrollbar { | |
| width: 4px; | |
| height: 4px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: rgba(255, 255, 255, 0.02); | |
| border-radius: 2px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--accent); | |
| border-radius: 2px; | |
| opacity: 0.5; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--accent-light); | |
| } | |
| /* ── Layout: Top Bar ───────────────────────────────────────────── */ | |
| #topBar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0 20px; | |
| border-bottom: 1px solid var(--panel-border); | |
| background: rgba(2, 6, 23, 0.8); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| } | |
| /* ── Layout: Main Area ─────────────────────────────────────────── */ | |
| #mainArea { | |
| display: flex; | |
| gap: 12px; | |
| padding: 12px; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| #videoFeed { | |
| flex: 7; | |
| min-width: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #drawer { | |
| flex: 3; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: stretch; | |
| overflow: hidden; | |
| } | |
| /* ── Layout: Timeline ──────────────────────────────────────────── */ | |
| #timeline { | |
| margin: 0 12px; | |
| padding: 10px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: stretch; | |
| gap: 6px; | |
| min-height: 48px; | |
| position: relative; | |
| } | |
| /* ── Layout: Command Bar ───────────────────────────────────────── */ | |
| #commandBar { | |
| margin: 8px 12px 12px; | |
| padding: 10px 16px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-start; | |
| gap: 10px; | |
| background: transparent; | |
| border: 1px solid var(--panel-border); | |
| border-radius: 10px; | |
| min-height: 44px; | |
| } | |
| /* ── Placeholder Labels ────────────────────────────────────────── */ | |
| .placeholder-label { | |
| color: var(--text-tertiary); | |
| font-size: 11px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| user-select: none; | |
| } | |
| /* ── Transitions ───────────────────────────────────────────────── */ | |
| a, button, input, select, textarea { | |
| transition: all 0.2s ease; | |
| } | |
| /* ── Top Bar Internals ────────────────────────────────────────── */ | |
| .topbar-left, .topbar-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .topbar-sep { | |
| color: var(--text-tertiary); | |
| font-weight: 300; | |
| font-size: 16px; | |
| margin: 0 2px; | |
| } | |
| .topbar-mission { | |
| color: var(--success); | |
| letter-spacing: 2.5px; | |
| } | |
| .topbar-pulse { | |
| animation: pulse-dot 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse-dot { | |
| 0%, 100% { box-shadow: 0 0 4px currentColor, 0 0 8px currentColor; opacity: 1; } | |
| 50% { box-shadow: 0 0 8px currentColor, 0 0 18px currentColor; opacity: 0.7; } | |
| } | |
| .topbar-indicator { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 10px; | |
| color: var(--text-secondary); | |
| letter-spacing: 0.5px; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .topbar-clock { | |
| min-width: 72px; | |
| text-align: right; | |
| } | |
| /* ── Video Feed ───────────────────────────────────────────────── */ | |
| #videoFeed { | |
| position: relative; | |
| background: #0a0f1a; | |
| } | |
| #videoCanvas, #overlayCanvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #overlayCanvas { | |
| pointer-events: auto; | |
| } | |
| #videoBadges { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| display: flex; | |
| gap: 6px; | |
| z-index: 2; | |
| } | |
| #frameCounter { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| font-size: 10px; | |
| color: var(--text-secondary); | |
| z-index: 2; | |
| background: rgba(0,0,0,0.45); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| } | |
| #videoControls { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| z-index: 2; | |
| } | |
| .vc-btn { | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-primary); | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .vc-select { | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-primary); | |
| font-size: 10px; | |
| padding: 4px 6px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| } | |
| .vc-select option { | |
| background: #0a0f1a; | |
| } | |
| /* Progress Overlay */ | |
| #progressOverlay { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(2,6,23,0.75); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 5; | |
| gap: 8px; | |
| } | |
| .progress-ring circle { | |
| fill: none; | |
| stroke-width: 3; | |
| } | |
| .progress-ring__bg { | |
| stroke: rgba(255,255,255,0.06); | |
| } | |
| .progress-ring__fg { | |
| stroke: var(--accent); | |
| stroke-linecap: round; | |
| stroke-dasharray: 213.628; | |
| stroke-dashoffset: 213.628; | |
| transform: rotate(-90deg); | |
| transform-origin: center; | |
| transition: stroke-dashoffset 0.3s ease; | |
| } | |
| .progress-pct { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| } | |
| /* Start Button */ | |
| #startBtn { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| z-index: 3; | |
| padding: 14px 36px; | |
| font-family: inherit; | |
| font-size: 13px; | |
| font-weight: 600; | |
| letter-spacing: 1.5px; | |
| text-transform: uppercase; | |
| color: #fff; | |
| background: linear-gradient(135deg, var(--accent), #2563eb); | |
| border: none; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| box-shadow: 0 0 20px rgba(59,130,246,0.3), 0 4px 12px rgba(0,0,0,0.3); | |
| transition: transform 0.15s ease, box-shadow 0.15s ease; | |
| } | |
| #startBtn:hover { | |
| transform: translate(-50%, -50%) scale(1.04); | |
| box-shadow: 0 0 30px rgba(59,130,246,0.45), 0 6px 16px rgba(0,0,0,0.35); | |
| } | |
| #startBtn:active { | |
| transform: translate(-50%, -50%) scale(0.97); | |
| } | |
| /* ── Drawer ───────────────────────────────────────────────────── */ | |
| .drawer-tabs { | |
| display: flex; | |
| border-bottom: 1px solid var(--panel-border); | |
| flex-shrink: 0; | |
| } | |
| .drawer-tab { | |
| flex: 1; | |
| background: none; | |
| border: none; | |
| color: var(--text-tertiary); | |
| font-family: inherit; | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 1.5px; | |
| text-transform: uppercase; | |
| padding: 10px 0; | |
| cursor: pointer; | |
| } | |
| .drawer-tab:hover { | |
| color: var(--text-secondary); | |
| } | |
| .drawer-tab.active { | |
| color: var(--accent-light); | |
| } | |
| .drawer-section { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 10px 12px; | |
| } | |
| /* Config Panel */ | |
| #configPanel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .config-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| } | |
| .config-toggle { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .config-toggle-btn { | |
| flex: 1; | |
| background: rgba(255,255,255,0.03); | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-secondary); | |
| font-family: inherit; | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| padding: 6px 0; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| } | |
| .config-toggle-btn.active { | |
| background: rgba(59,130,246,0.12); | |
| border-color: var(--accent); | |
| color: var(--accent-light); | |
| } | |
| .config-toggle-btn:hover:not(.active) { | |
| background: rgba(255,255,255,0.06); | |
| } | |
| .config-select, .config-input { | |
| background: rgba(255,255,255,0.03); | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-primary); | |
| font-family: inherit; | |
| font-size: 11px; | |
| padding: 7px 10px; | |
| border-radius: 6px; | |
| outline: none; | |
| } | |
| .config-select option { | |
| background: #0a0f1a; | |
| } | |
| .config-select:focus, .config-input:focus { | |
| border-color: var(--accent); | |
| } | |
| .config-row { | |
| flex-direction: row; | |
| align-items: center; | |
| } | |
| .config-checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 11px; | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| } | |
| .config-checkbox-label input[type="checkbox"] { | |
| accent-color: var(--accent); | |
| } | |
| /* Track Cards */ | |
| .track-card { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| border-radius: 6px; | |
| background: rgba(255,255,255,0.02); | |
| border: 1px solid rgba(255,255,255,0.04); | |
| margin-bottom: 6px; | |
| cursor: pointer; | |
| } | |
| .track-card.selected { | |
| border-color: var(--accent); | |
| background: rgba(59,130,246,0.06); | |
| } | |
| .track-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| .track-info { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .track-label { | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: var(--text-primary); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .track-meta { | |
| display: flex; | |
| gap: 8px; | |
| font-size: 9px; | |
| color: var(--text-tertiary); | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| margin-top: 2px; | |
| } | |
| .track-conf { | |
| font-size: 10px; | |
| font-weight: 600; | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| color: var(--text-secondary); | |
| flex-shrink: 0; | |
| } | |
| /* ── Timeline Internals ───────────────────────────────────────── */ | |
| .timeline-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| width: 100%; | |
| } | |
| .timeline-time { | |
| font-size: 9px; | |
| color: var(--text-tertiary); | |
| flex-shrink: 0; | |
| min-width: 32px; | |
| } | |
| .timeline-waveform-wrap { | |
| flex: 1; | |
| height: 32px; | |
| position: relative; | |
| cursor: pointer; | |
| } | |
| #waveformCanvas { | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 4px; | |
| } | |
| #playhead { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| width: 2px; | |
| background: #fff; | |
| left: 0; | |
| z-index: 2; | |
| pointer-events: auto; | |
| cursor: ew-resize; | |
| } | |
| .playhead-handle { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #fff; | |
| position: absolute; | |
| top: -4px; | |
| left: -3px; | |
| box-shadow: 0 0 4px rgba(255,255,255,0.4); | |
| } | |
| #eventMarkers { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| pointer-events: none; | |
| z-index: 1; | |
| } | |
| .event-marker { | |
| position: absolute; | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| top: 50%; | |
| transform: translate(-50%, -50%); | |
| pointer-events: auto; | |
| cursor: pointer; | |
| opacity: 0.8; | |
| } | |
| .timeline-legend { | |
| display: flex; | |
| gap: 14px; | |
| font-size: 9px; | |
| color: var(--text-tertiary); | |
| padding: 0 42px; | |
| } | |
| .timeline-legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .timeline-log-toggle { | |
| background: none; | |
| border: none; | |
| color: var(--text-tertiary); | |
| font-family: inherit; | |
| font-size: 8px; | |
| font-weight: 600; | |
| letter-spacing: 1.5px; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| padding: 2px 0; | |
| align-self: flex-start; | |
| margin-left: 42px; | |
| } | |
| .timeline-log-toggle:hover { | |
| color: var(--text-secondary); | |
| } | |
| #eventLog { | |
| position: absolute; | |
| bottom: 100%; | |
| left: 0; | |
| right: 0; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| background: rgba(2,6,23,0.92); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 8px 8px 0 0; | |
| padding: 8px; | |
| z-index: 10; | |
| } | |
| .event-log-entry { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 8px; | |
| padding: 5px 6px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| transition: background 0.15s; | |
| cursor: pointer; | |
| } | |
| .event-log-entry:hover { | |
| background: rgba(255,255,255,0.04); | |
| } | |
| .event-log-time { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 9px; | |
| color: var(--text-tertiary); | |
| flex-shrink: 0; | |
| min-width: 52px; | |
| } | |
| .event-log-type { | |
| font-size: 8px; | |
| font-weight: 600; | |
| letter-spacing: 0.8px; | |
| text-transform: uppercase; | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| } | |
| .event-log-label { | |
| color: var(--text-primary); | |
| font-weight: 500; | |
| flex-shrink: 0; | |
| } | |
| .event-log-desc { | |
| color: var(--text-tertiary); | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| /* ── Command Bar Internals ────────────────────────────────────── */ | |
| .cmd-icon { | |
| color: var(--text-tertiary); | |
| font-size: 16px; | |
| flex-shrink: 0; | |
| } | |
| .cmd-input { | |
| flex: 1; | |
| background: none; | |
| border: none; | |
| color: var(--text-primary); | |
| font-family: inherit; | |
| font-size: 12px; | |
| outline: none; | |
| } | |
| .cmd-input::placeholder { | |
| color: var(--text-tertiary); | |
| } | |
| .cmd-badge { | |
| font-size: 9px; | |
| font-weight: 600; | |
| color: var(--text-tertiary); | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid var(--panel-border); | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| flex-shrink: 0; | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| } | |
| /* ── Toast ────────────────────────────────────────────────────── */ | |
| #toastContainer { | |
| position: fixed; | |
| bottom: 80px; | |
| right: 20px; | |
| z-index: 100; | |
| display: flex; | |
| flex-direction: column-reverse; | |
| gap: 8px; | |
| pointer-events: none; | |
| } | |
| .toast { | |
| background: rgba(2,6,23,0.92); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 8px; | |
| padding: 10px 14px; | |
| max-width: 400px; | |
| font-size: 11px; | |
| color: var(--text-primary); | |
| line-height: 1.5; | |
| pointer-events: auto; | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 8px; | |
| animation: toast-in 0.3s ease forwards; | |
| } | |
| .toast-close { | |
| background: none; | |
| border: none; | |
| color: var(--text-tertiary); | |
| font-size: 14px; | |
| cursor: pointer; | |
| padding: 0; | |
| line-height: 1; | |
| flex-shrink: 0; | |
| } | |
| .toast-close:hover { | |
| color: var(--text-primary); | |
| } | |
| .toast.toast-out { | |
| animation: toast-out 0.25s ease forwards; | |
| } | |
| @keyframes toast-out { | |
| from { opacity: 1; transform: translateY(0); } | |
| to { opacity: 0; transform: translateY(12px); } | |
| } | |
| /* ── State-Driven Visibility ─────────────────────────────────── */ | |
| /* Start button: visible only in ready */ | |
| #startBtn { display: none; } | |
| body[data-state="ready"] #startBtn { display: block; } | |
| /* Progress overlay: visible only in processing */ | |
| #progressOverlay { display: none ; } | |
| body[data-state="processing"] #progressOverlay { | |
| display: flex ; | |
| background: rgba(0,0,0,0.6); | |
| } | |
| /* Drawer tabs: hidden in ready (config panel shown instead) */ | |
| body[data-state="ready"] .drawer-tabs { display: none; } | |
| /* Processing: only TRACKS tab available */ | |
| body[data-state="processing"] .drawer-tab[data-tab="inspect"], | |
| body[data-state="processing"] .drawer-tab[data-tab="metrics"] { | |
| opacity: 0.3; | |
| pointer-events: none; | |
| } | |
| /* Analysis: TRACKS + METRICS available, INSPECT disabled */ | |
| body[data-state="analysis"] .drawer-tab[data-tab="inspect"] { | |
| opacity: 0.3; | |
| pointer-events: none; | |
| } | |
| /* Inspect state: all tabs enabled, INSPECT forced active */ | |
| body[data-state="inspect"] .drawer-tab[data-tab="inspect"] { | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| /* Video controls: hidden in ready */ | |
| body[data-state="ready"] #videoControls { display: none; } | |
| body[data-state="ready"] #videoBadges { display: none; } | |
| body[data-state="ready"] #frameCounter { display: none; } | |
| /* Processing pulse for top bar dot */ | |
| .topbar-dot-processing { | |
| animation: processing-pulse 0.8s ease-in-out infinite ; | |
| } | |
| @keyframes processing-pulse { | |
| 0%, 100% { box-shadow: 0 0 4px currentColor, 0 0 8px currentColor; opacity: 1; } | |
| 50% { box-shadow: 0 0 12px currentColor, 0 0 24px currentColor; opacity: 0.5; } | |
| } | |
| /* Inspect panel styling */ | |
| .inspect-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid var(--panel-border); | |
| } | |
| .inspect-back-btn { | |
| background: none; | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-secondary); | |
| font-family: inherit; | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| .inspect-back-btn:hover { | |
| background: rgba(255,255,255,0.06); | |
| color: var(--text-primary); | |
| } | |
| .inspect-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 0; | |
| border-bottom: 1px solid rgba(255,255,255,0.03); | |
| } | |
| .inspect-label { | |
| font-size: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-tertiary); | |
| } | |
| /* ── Inspect Quad Grid ────────────────────────────────────────── */ | |
| #inspectPanel { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| #inspectPanel.hidden { | |
| display: none ; | |
| } | |
| #inspectHeader { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid var(--panel-border); | |
| flex-shrink: 0; | |
| } | |
| #inspectBack { | |
| background: none; | |
| border: 1px solid var(--panel-border); | |
| color: var(--text-secondary); | |
| font-family: inherit; | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| } | |
| #inspectBack:hover { | |
| background: rgba(255,255,255,0.06); | |
| color: var(--text-primary); | |
| } | |
| #inspectLabel { | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 1.5px; | |
| color: var(--text-primary); | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| #inspectConf { | |
| font-size: 11px; | |
| flex-shrink: 0; | |
| } | |
| #quadGrid { | |
| display: grid; | |
| grid-template: 1fr 1fr / 1fr 1fr; | |
| gap: 6px; | |
| flex: 1; | |
| min-height: 0; | |
| } | |
| .quadrant { | |
| position: relative; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| background: var(--panel-bg); | |
| border: 1px solid var(--panel-border); | |
| cursor: pointer; | |
| } | |
| .quad-label { | |
| position: absolute; | |
| top: 6px; | |
| left: 8px; | |
| font-size: 9px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| z-index: 2; | |
| pointer-events: none; | |
| } | |
| .quad-canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .quad-metric { | |
| position: absolute; | |
| bottom: 6px; | |
| left: 8px; | |
| font-size: 9px; | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| color: var(--text-secondary); | |
| z-index: 2; | |
| pointer-events: none; | |
| } | |
| .quadrant.expanded { | |
| grid-column: 1 / -1; | |
| grid-row: 1 / -1; | |
| z-index: 5; | |
| } | |
| #threeContainer { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #threeContainer canvas { | |
| width: 100% ; | |
| height: 100% ; | |
| } | |
| #inspectMetrics { | |
| flex-shrink: 0; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| padding-top: 8px; | |
| border-top: 1px solid var(--panel-border); | |
| margin-top: 8px; | |
| } | |
| .inspect-metric-item { | |
| flex: 1 1 45%; | |
| min-width: 0; | |
| } | |
| .inspect-metric-item .label { | |
| font-size: 7px; | |
| } | |
| .inspect-metric-item .value { | |
| font-size: 11px; | |
| } | |
| /* ── Metrics Mode ─────────────────────────────────────────────── */ | |
| .metric-hero { | |
| text-align: center; | |
| padding: 16px 0 12px; | |
| border-bottom: 1px solid var(--panel-border); | |
| margin-bottom: 12px; | |
| } | |
| .metric-big-number { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 48px; | |
| font-weight: 300; | |
| color: var(--accent-light); | |
| line-height: 1; | |
| } | |
| .metric-hero .label { | |
| margin-top: 6px; | |
| } | |
| .metric-breakdown { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| margin-bottom: 14px; | |
| } | |
| .metric-bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .metric-bar-label { | |
| font-size: 9px; | |
| font-weight: 600; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: var(--text-secondary); | |
| min-width: 72px; | |
| text-align: right; | |
| } | |
| .metric-bar-track { | |
| flex: 1; | |
| height: 4px; | |
| background: rgba(255,255,255,0.04); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| } | |
| .metric-bar-fill { | |
| height: 100%; | |
| border-radius: 2px; | |
| transition: width 0.6s ease; | |
| } | |
| .metric-bar-count { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 10px; | |
| color: var(--text-primary); | |
| min-width: 16px; | |
| } | |
| .metric-stats { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0; | |
| } | |
| .metric-stat-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 0; | |
| border-bottom: 1px solid rgba(255,255,255,0.03); | |
| } | |
| .metric-stat-row:last-child { | |
| border-bottom: none; | |
| } | |
| .metric-stat-label { | |
| font-size: 9px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| color: var(--text-tertiary); | |
| } | |
| .metric-stat-value { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 12px; | |
| color: var(--text-primary); | |
| font-weight: 500; | |
| } | |
| .inspect-value { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 12px; | |
| color: var(--text-primary); | |
| } | |
| /* Mission report styling */ | |
| .mission-report { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .report-header { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 1.5px; | |
| color: var(--accent-light); | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid var(--panel-border); | |
| } | |
| .report-section { | |
| background: rgba(255,255,255,0.02); | |
| border: 1px solid rgba(255,255,255,0.04); | |
| border-radius: 6px; | |
| padding: 10px 12px; | |
| } | |
| .report-section-title { | |
| font-size: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| color: var(--accent-light); | |
| margin-bottom: 8px; | |
| font-weight: 600; | |
| } | |
| .report-stat { | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 4px 0; | |
| font-size: 11px; | |
| } | |
| .report-stat-label { | |
| color: var(--text-tertiary); | |
| } | |
| .report-stat-value { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| color: var(--text-primary); | |
| font-weight: 500; | |
| } | |
| .report-threat { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 8px; | |
| border-radius: 4px; | |
| background: rgba(239,68,68,0.06); | |
| border: 1px solid rgba(239,68,68,0.12); | |
| margin-bottom: 6px; | |
| } | |
| .report-threat-time { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 9px; | |
| color: var(--text-tertiary); | |
| flex-shrink: 0; | |
| } | |
| .report-threat-label { | |
| font-size: 10px; | |
| color: var(--danger); | |
| font-weight: 500; | |
| } | |
| .report-rec { | |
| font-size: 10px; | |
| color: var(--text-secondary); | |
| padding: 4px 0 4px 12px; | |
| border-left: 2px solid var(--accent); | |
| margin-bottom: 6px; | |
| line-height: 1.5; | |
| } | |
| /* Metrics panel styling */ | |
| .metrics-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .metric-card { | |
| background: rgba(255,255,255,0.02); | |
| border: 1px solid rgba(255,255,255,0.04); | |
| border-radius: 6px; | |
| padding: 10px; | |
| text-align: center; | |
| } | |
| .metric-value { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: var(--text-primary); | |
| } | |
| .metric-label { | |
| font-size: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-tertiary); | |
| margin-top: 4px; | |
| } | |
| /* ── Polish: Global Text Rendering ────────────────────────────── */ | |
| * { | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| ::selection { | |
| background: rgba(59,130,246,0.3); | |
| } | |
| /* ── Polish: Drawer Content Transitions ───────────────────────── */ | |
| .drawer-section { | |
| animation: drawer-fade-in 0.3s ease both; | |
| } | |
| @keyframes drawer-fade-in { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ── Polish: Progress Overlay Fade ────────────────────────────── */ | |
| #progressOverlay { | |
| transition: opacity 0.5s ease; | |
| } | |
| body[data-state="processing"] #progressOverlay { | |
| opacity: 1; | |
| } | |
| /* ── Polish: Start Button Fade Out ────────────────────────────── */ | |
| #startBtn.fading-out { | |
| opacity: 0; | |
| transform: translate(-50%, -50%) scale(0.95); | |
| transition: opacity 0.3s ease, transform 0.3s ease; | |
| pointer-events: none; | |
| } | |
| /* ── Polish: Track Card Stagger Entrance ──────────────────────── */ | |
| @keyframes track-card-enter { | |
| from { opacity: 0; transform: translateY(8px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ── Polish: Toast Slide Up ───────────────────────────────────── */ | |
| @keyframes toast-in { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ── Polish: Track Card Hover ─────────────────────────────────── */ | |
| .track-card { | |
| transition: background 0.15s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; | |
| } | |
| .track-card:hover { | |
| background: rgba(255,255,255,0.05); | |
| border-color: rgba(255,255,255,0.08); | |
| transform: scale(1.01); | |
| } | |
| .track-card.highlighted { | |
| border-color: var(--accent-light) ; | |
| background: rgba(59,130,246,0.12) ; | |
| box-shadow: 0 0 8px rgba(59,130,246,0.15); | |
| transform: scale(1.01); | |
| } | |
| /* Type-colored glow on hover */ | |
| .track-card:hover .track-dot { | |
| transition: box-shadow 0.2s ease; | |
| } | |
| /* ── Polish: Quadrant Hover ───────────────────────────────────── */ | |
| .quadrant { | |
| transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .quadrant:hover { | |
| border-color: rgba(255,255,255,0.15); | |
| transform: scale(1.02); | |
| box-shadow: 0 0 12px rgba(59,130,246,0.1); | |
| } | |
| .quadrant.expanded:hover { | |
| transform: none; | |
| } | |
| /* ── Polish: Event Marker Hover Tooltip ────────────────────────── */ | |
| .event-marker { | |
| transition: opacity 0.15s, transform 0.15s; | |
| z-index: 2; | |
| } | |
| .event-marker:hover { | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1.5); | |
| z-index: 3; | |
| } | |
| .event-marker-tooltip { | |
| position: absolute; | |
| bottom: calc(100% + 8px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(2,6,23,0.95); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 4px; | |
| padding: 4px 8px; | |
| font-size: 9px; | |
| color: var(--text-primary); | |
| white-space: nowrap; | |
| pointer-events: none; | |
| z-index: 20; | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| } | |
| /* ── Polish: Command Bar Focus ────────────────────────────────── */ | |
| #commandBar { | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| #commandBar:focus-within { | |
| border-color: rgba(59,130,246,0.4); | |
| box-shadow: inset 0 0 12px rgba(59,130,246,0.06); | |
| } | |
| /* ── Polish: Tab Button Underline Slide ────────────────────────── */ | |
| .drawer-tab { | |
| position: relative; | |
| border-bottom: 2px solid transparent; | |
| transition: color 0.2s ease, border-color 0.3s ease; | |
| } | |
| .drawer-tab::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -2px; | |
| left: 50%; | |
| right: 50%; | |
| height: 2px; | |
| background: var(--accent); | |
| transition: left 0.25s ease, right 0.25s ease; | |
| } | |
| .drawer-tab.active::after { | |
| left: 0; | |
| right: 0; | |
| } | |
| .drawer-tab.active { | |
| border-bottom-color: transparent; | |
| } | |
| /* ── Polish: Button Lift on Hover ─────────────────────────────── */ | |
| .vc-btn:hover { | |
| background: rgba(255,255,255,0.12); | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| .inspect-back-btn:hover { | |
| transform: translateY(-1px); | |
| } | |
| .config-toggle-btn { | |
| transition: all 0.15s ease; | |
| } | |
| .config-toggle-btn:hover { | |
| transform: translateY(-1px); | |
| } | |
| /* ── Polish: Detection Box Animations ─────────────────────────── */ | |
| @keyframes box-dash-march { | |
| to { stroke-dashoffset: -20; } | |
| } | |
| @keyframes box-pulse-glow { | |
| 0%, 100% { opacity: 0.6; } | |
| 50% { opacity: 1; } | |
| } | |
| /* ── Polish: Top Bar Processing Progress Bar ──────────────────── */ | |
| #topBar { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #topBarProgress { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| height: 2px; | |
| width: 0%; | |
| background: linear-gradient(90deg, var(--accent), var(--accent-light)); | |
| transition: width 0.3s ease; | |
| z-index: 10; | |
| opacity: 0; | |
| } | |
| body[data-state="processing"] #topBarProgress { | |
| opacity: 1; | |
| } | |
| /* FPS flicker */ | |
| @keyframes fps-flicker { | |
| 0% { color: var(--text-secondary); } | |
| 50% { color: var(--accent-light); } | |
| 100% { color: var(--text-secondary); } | |
| } | |
| .fps-updating { | |
| animation: fps-flicker 0.3s ease; | |
| } | |
| /* ── Polish: Keyboard Shortcuts Hint ──────────────────────────── */ | |
| #shortcutsHint { | |
| position: absolute; | |
| bottom: calc(100% + 8px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(2,6,23,0.95); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 8px; | |
| padding: 10px 14px; | |
| z-index: 50; | |
| display: flex; | |
| gap: 16px; | |
| font-size: 9px; | |
| color: var(--text-secondary); | |
| white-space: nowrap; | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.4); | |
| opacity: 0; | |
| animation: shortcuts-fade 3s ease forwards; | |
| pointer-events: none; | |
| } | |
| #shortcutsHint .shortcut-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| #shortcutsHint kbd { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| font-size: 8px; | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 3px; | |
| padding: 1px 4px; | |
| color: var(--text-primary); | |
| } | |
| @keyframes shortcuts-fade { | |
| 0% { opacity: 0; transform: translateX(-50%) translateY(4px); } | |
| 10% { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| 80% { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| 100% { opacity: 0; transform: translateX(-50%) translateY(-4px); } | |
| } | |
| /* ── Polish: Glass Effect on Panels ───────────────────────────── */ | |
| #eventLog { | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.3); | |
| } | |
| .toast { | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.3); | |
| } | |
| #drawer { | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.15); | |
| } | |
| #timeline { | |
| box-shadow: 0 2px 16px rgba(0,0,0,0.15); | |
| } | |
| /* ── Polish: z-index Stacking ─────────────────────────────────── */ | |
| #toastContainer { z-index: 100; } | |
| #eventLog { z-index: 50; } | |
| #progressOverlay { z-index: 5; } | |
| #shortcutsHint { z-index: 60; } | |
| /* ── Polish: Smooth Scroll ────────────────────────────────────── */ | |
| .drawer-section { | |
| scroll-behavior: smooth; | |
| } | |
| #eventLog { | |
| scroll-behavior: smooth; | |
| } | |
| /* ── Min Width Guard ───────────────────────────────────────────── */ | |
| @media (max-width: 1280px) { | |
| body { | |
| min-width: 1280px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ── Top Bar ─────────────────────────────────────────────────── --> | |
| <header id="topBar"> | |
| <div class="topbar-left"> | |
| <span class="dot dot--lg topbar-pulse" style="color: var(--accent); background: var(--accent);"></span> | |
| <span class="heading">ISR COMMAND</span> | |
| <span class="topbar-sep">|</span> | |
| <span class="label topbar-mission">MISSION ACTIVE</span> | |
| </div> | |
| <div class="topbar-right"> | |
| <span class="topbar-indicator"><span class="dot" style="background: var(--success); color: var(--success);"></span> GPU×4</span> | |
| <span class="topbar-indicator" id="fpsDisplay">48 FPS</span> | |
| <span class="topbar-indicator">YOLO11</span> | |
| <span class="topbar-indicator topbar-clock" id="liveClock">00:00:00Z</span> | |
| </div> | |
| <div id="topBarProgress"></div> | |
| </header> | |
| <!-- ── Main Area ───────────────────────────────────────────────── --> | |
| <main id="mainArea"> | |
| <div id="videoFeed" class="panel"> | |
| <canvas id="videoCanvas"></canvas> | |
| <canvas id="overlayCanvas"></canvas> | |
| <div id="videoBadges"> | |
| <span class="pill" style="background: rgba(59,130,246,0.15); color: var(--accent-light);"> | |
| <span class="dot" style="background: var(--accent);"></span> YOLO11 | |
| </span> | |
| <span class="pill" style="background: rgba(34,197,94,0.15); color: var(--success);"> | |
| <span class="dot" style="background: var(--success);"></span> TRACKING | |
| </span> | |
| </div> | |
| <div id="frameCounter" class="value">FRM 0 / 1847</div> | |
| <div id="videoControls"> | |
| <button id="playPauseBtn" class="vc-btn" title="Play / Pause">▶</button> | |
| <select id="speedSelect" class="vc-select"> | |
| <option value="0.5">0.5x</option> | |
| <option value="1" selected>1x</option> | |
| <option value="2">2x</option> | |
| <option value="4">4x</option> | |
| </select> | |
| </div> | |
| <div id="progressOverlay" class="hidden"> | |
| <svg class="progress-ring" width="80" height="80"> | |
| <circle class="progress-ring__bg" cx="40" cy="40" r="34" /> | |
| <circle class="progress-ring__fg" id="progressCircle" cx="40" cy="40" r="34" /> | |
| </svg> | |
| <span class="progress-pct" id="progressPct">0%</span> | |
| </div> | |
| <button id="startBtn">START DETECTION</button> | |
| </div> | |
| <aside id="drawer" class="panel"> | |
| <div class="drawer-tabs"> | |
| <button class="drawer-tab active" data-tab="tracks">TRACKS</button> | |
| <button class="drawer-tab" data-tab="inspect">INSPECT</button> | |
| <button class="drawer-tab" data-tab="metrics">METRICS</button> | |
| </div> | |
| <div id="configPanel" class="drawer-section"> | |
| <div class="config-group"> | |
| <span class="label">MODE</span> | |
| <div class="config-toggle"> | |
| <button class="config-toggle-btn active" data-mode="object_detection">DETECT</button> | |
| <button class="config-toggle-btn" data-mode="segmentation">SEGMENT</button> | |
| <button class="config-toggle-btn" data-mode="drone_detection">DRONE</button> | |
| </div> | |
| </div> | |
| <div class="config-group"> | |
| <span class="label">MODEL</span> | |
| <select class="config-select" id="modelSelect"> | |
| <option value="yolo11" selected>YOLO11</option> | |
| <option value="detr_resnet50">DETR ResNet-50</option> | |
| <option value="grounding_dino">Grounding DINO</option> | |
| <option value="yolov8_visdrone">YOLOv8 VisDrone</option> | |
| </select> | |
| </div> | |
| <div class="config-group"> | |
| <span class="label">QUERIES</span> | |
| <input type="text" class="config-input" id="queryInput" placeholder="person, vehicle, drone..." value="person, vehicle, drone"> | |
| </div> | |
| <div class="config-group config-row"> | |
| <label class="config-checkbox-label"> | |
| <input type="checkbox" id="depthCheck"> <span>Enable Depth</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div id="tracksPanel" class="drawer-section"></div> | |
| <div id="inspectPanel" class="drawer-section hidden"></div> | |
| <div id="metricsPanel" class="drawer-section hidden"></div> | |
| </aside> | |
| </main> | |
| <!-- ── Timeline ────────────────────────────────────────────────── --> | |
| <div id="timeline" class="panel"> | |
| <div class="timeline-bar"> | |
| <span class="timeline-time value" id="timeStart">00:00</span> | |
| <div class="timeline-waveform-wrap"> | |
| <canvas id="waveformCanvas"></canvas> | |
| <div id="playhead"><div class="playhead-handle"></div></div> | |
| <div id="eventMarkers"></div> | |
| </div> | |
| <span class="timeline-time value" id="timeEnd">01:02</span> | |
| </div> | |
| <div class="timeline-legend" id="timelineLegend"></div> | |
| <button class="timeline-log-toggle" id="eventLogToggle">EVENT LOG ▼</button> | |
| <div id="eventLog" class="hidden"></div> | |
| </div> | |
| <!-- ── Command Bar ─────────────────────────────────────────────── --> | |
| <div id="commandBar"> | |
| <span class="cmd-icon">⌘</span> | |
| <input type="text" id="commandInput" class="cmd-input" placeholder="Ask a question or issue a command..." autocomplete="off"> | |
| <span class="cmd-badge">⌘K</span> | |
| </div> | |
| <!-- ── Toast Container ───────────────────────────────────────── --> | |
| <div id="toastContainer"></div> | |
| <script> | |
| /* ================================================================ | |
| * ISR COMMAND CENTER — Mock Data Layer | |
| * ================================================================ */ | |
| // ── State ─────────────────────────────────────────────────────── | |
| const STATE = { | |
| current: 'ready', | |
| selectedTrackId: null, | |
| expandedQuadrant: null, | |
| playheadFrame: 0, | |
| totalFrames: 1847, | |
| fps: 30, | |
| isPlaying: false, | |
| drawerTab: 'config', | |
| eventLogOpen: false, | |
| trackFilter: null, | |
| }; | |
| // ── Color Map ─────────────────────────────────────────────────── | |
| const TYPE_COLORS = { | |
| person: '#3b82f6', | |
| vehicle: '#f59e0b', | |
| drone: '#ef4444', | |
| stationary: '#22c55e', | |
| }; | |
| function getColorForType(type) { | |
| return TYPE_COLORS[type] || '#8b5cf6'; | |
| } | |
| // ── Track Schema ──────────────────────────────────────────────── | |
| function createTrack(id, label, type, confidence, speed, depth, area, keyframes) { | |
| return { | |
| id, | |
| label, | |
| type, | |
| color: getColorForType(type), | |
| confidence, | |
| speed, // kph | |
| depth, // meters | |
| area, // percentage of frame | |
| keyframes, // [{ frame, bbox: { x, y, w, h } }] — values as % | |
| active: true, | |
| }; | |
| } | |
| // ── 20 Mock Tracks ────────────────────────────────────────────── | |
| const MOCK_TRACKS = [ | |
| // 8 persons | |
| createTrack('P-001', 'Person Alpha', 'person', 0.94, 5.2, 12.4, 1.8, [ | |
| { frame: 0, bbox: { x: 10, y: 30, w: 4, h: 10 } }, | |
| { frame: 400, bbox: { x: 20, y: 32, w: 4, h: 10 } }, | |
| { frame: 800, bbox: { x: 35, y: 34, w: 4.5, h: 11 } }, | |
| { frame: 1200, bbox: { x: 48, y: 33, w: 4, h: 10 } }, | |
| { frame: 1600, bbox: { x: 60, y: 31, w: 4, h: 10 } }, | |
| ]), | |
| createTrack('P-002', 'Person Bravo', 'person', 0.91, 8.7, 18.1, 1.5, [ | |
| { frame: 100, bbox: { x: 55, y: 45, w: 3.5, h: 9 } }, | |
| { frame: 500, bbox: { x: 50, y: 43, w: 3.5, h: 9 } }, | |
| { frame: 900, bbox: { x: 42, y: 40, w: 4, h: 10 } }, | |
| { frame: 1300, bbox: { x: 33, y: 38, w: 4, h: 10 } }, | |
| { frame: 1700, bbox: { x: 25, y: 36, w: 3.5, h: 9 } }, | |
| ]), | |
| createTrack('P-003', 'Person Charlie', 'person', 0.88, 3.1, 8.5, 2.1, [ | |
| { frame: 200, bbox: { x: 70, y: 55, w: 5, h: 12 } }, | |
| { frame: 600, bbox: { x: 68, y: 54, w: 5, h: 12 } }, | |
| { frame: 1000, bbox: { x: 65, y: 52, w: 5, h: 12 } }, | |
| { frame: 1400, bbox: { x: 62, y: 50, w: 5, h: 12 } }, | |
| ]), | |
| createTrack('P-004', 'Person Delta', 'person', 0.86, 12.4, 22.3, 1.2, [ | |
| { frame: 50, bbox: { x: 15, y: 60, w: 3, h: 8 } }, | |
| { frame: 450, bbox: { x: 25, y: 55, w: 3.5, h: 9 } }, | |
| { frame: 850, bbox: { x: 40, y: 48, w: 4, h: 10 } }, | |
| { frame: 1250, bbox: { x: 55, y: 42, w: 4, h: 10 } }, | |
| { frame: 1650, bbox: { x: 70, y: 38, w: 3.5, h: 9 } }, | |
| { frame: 1847, bbox: { x: 80, y: 35, w: 3, h: 8 } }, | |
| ]), | |
| createTrack('P-005', 'Person Echo', 'person', 0.92, 1.8, 6.2, 2.5, [ | |
| { frame: 300, bbox: { x: 45, y: 70, w: 5.5, h: 13 } }, | |
| { frame: 700, bbox: { x: 44, y: 69, w: 5.5, h: 13 } }, | |
| { frame: 1100, bbox: { x: 43, y: 68, w: 5.5, h: 13 } }, | |
| { frame: 1500, bbox: { x: 42, y: 67, w: 5.5, h: 13 } }, | |
| ]), | |
| createTrack('P-006', 'Person Foxtrot', 'person', 0.79, 22.1, 30.5, 0.9, [ | |
| { frame: 0, bbox: { x: 80, y: 25, w: 3, h: 7 } }, | |
| { frame: 350, bbox: { x: 65, y: 30, w: 3.5, h: 8 } }, | |
| { frame: 700, bbox: { x: 45, y: 38, w: 4, h: 10 } }, | |
| { frame: 1050, bbox: { x: 28, y: 44, w: 4, h: 10 } }, | |
| { frame: 1400, bbox: { x: 12, y: 50, w: 3.5, h: 8 } }, | |
| ]), | |
| createTrack('P-007', 'Person Golf', 'person', 0.83, 6.5, 14.8, 1.6, [ | |
| { frame: 150, bbox: { x: 30, y: 20, w: 4, h: 10 } }, | |
| { frame: 550, bbox: { x: 35, y: 22, w: 4, h: 10 } }, | |
| { frame: 950, bbox: { x: 38, y: 25, w: 4, h: 10 } }, | |
| { frame: 1350, bbox: { x: 40, y: 28, w: 4, h: 10 } }, | |
| { frame: 1750, bbox: { x: 42, y: 30, w: 4, h: 10 } }, | |
| ]), | |
| createTrack('P-008', 'Person Hotel', 'person', 0.77, 15.3, 25.0, 1.1, [ | |
| { frame: 250, bbox: { x: 88, y: 40, w: 3, h: 8 } }, | |
| { frame: 650, bbox: { x: 75, y: 42, w: 3.5, h: 9 } }, | |
| { frame: 1050, bbox: { x: 58, y: 45, w: 4, h: 10 } }, | |
| { frame: 1450, bbox: { x: 40, y: 48, w: 4, h: 10 } }, | |
| ]), | |
| // 5 vehicles | |
| createTrack('V-001', 'Vehicle Alpha', 'vehicle', 0.96, 45.0, 35.2, 4.8, [ | |
| { frame: 0, bbox: { x: 5, y: 50, w: 10, h: 8 } }, | |
| { frame: 400, bbox: { x: 25, y: 48, w: 10, h: 8 } }, | |
| { frame: 800, bbox: { x: 50, y: 46, w: 11, h: 9 } }, | |
| { frame: 1200, bbox: { x: 72, y: 44, w: 10, h: 8 } }, | |
| { frame: 1600, bbox: { x: 90, y: 42, w: 9, h: 7 } }, | |
| ]), | |
| createTrack('V-002', 'Vehicle Bravo', 'vehicle', 0.93, 62.3, 42.1, 5.2, [ | |
| { frame: 200, bbox: { x: 90, y: 55, w: 9, h: 7 } }, | |
| { frame: 550, bbox: { x: 70, y: 53, w: 10, h: 8 } }, | |
| { frame: 900, bbox: { x: 48, y: 50, w: 11, h: 9 } }, | |
| { frame: 1250, bbox: { x: 25, y: 48, w: 10, h: 8 } }, | |
| { frame: 1600, bbox: { x: 5, y: 46, w: 9, h: 7 } }, | |
| ]), | |
| createTrack('V-003', 'Vehicle Charlie', 'vehicle', 0.89, 38.7, 28.9, 3.9, [ | |
| { frame: 100, bbox: { x: 40, y: 62, w: 9, h: 7 } }, | |
| { frame: 500, bbox: { x: 50, y: 60, w: 9.5, h: 7.5 } }, | |
| { frame: 900, bbox: { x: 60, y: 58, w: 10, h: 8 } }, | |
| { frame: 1300, bbox: { x: 68, y: 56, w: 10, h: 8 } }, | |
| { frame: 1700, bbox: { x: 75, y: 54, w: 9, h: 7 } }, | |
| ]), | |
| createTrack('V-004', 'Vehicle Delta', 'vehicle', 0.85, 78.5, 55.0, 3.2, [ | |
| { frame: 0, bbox: { x: 92, y: 35, w: 7, h: 6 } }, | |
| { frame: 300, bbox: { x: 68, y: 38, w: 8, h: 7 } }, | |
| { frame: 600, bbox: { x: 42, y: 42, w: 10, h: 8 } }, | |
| { frame: 900, bbox: { x: 18, y: 45, w: 10, h: 8 } }, | |
| ]), | |
| createTrack('V-005', 'Vehicle Echo', 'vehicle', 0.91, 31.2, 20.7, 4.5, [ | |
| { frame: 500, bbox: { x: 10, y: 68, w: 10, h: 8 } }, | |
| { frame: 900, bbox: { x: 30, y: 65, w: 10.5, h: 8.5 } }, | |
| { frame: 1300, bbox: { x: 52, y: 62, w: 11, h: 9 } }, | |
| { frame: 1700, bbox: { x: 72, y: 60, w: 10, h: 8 } }, | |
| ]), | |
| // 2 drones | |
| createTrack('D-001', 'Drone Alpha', 'drone', 0.97, 42.0, 85.0, 0.6, [ | |
| { frame: 400, bbox: { x: 60, y: 8, w: 2.5, h: 2 } }, | |
| { frame: 700, bbox: { x: 50, y: 12, w: 3, h: 2.5 } }, | |
| { frame: 1000, bbox: { x: 38, y: 10, w: 3, h: 2.5 } }, | |
| { frame: 1300, bbox: { x: 28, y: 6, w: 2.5, h: 2 } }, | |
| { frame: 1600, bbox: { x: 18, y: 8, w: 2, h: 1.8 } }, | |
| { frame: 1847, bbox: { x: 10, y: 5, w: 2, h: 1.5 } }, | |
| ]), | |
| createTrack('D-002', 'Drone Bravo', 'drone', 0.93, 55.8, 120.0, 0.4, [ | |
| { frame: 800, bbox: { x: 20, y: 5, w: 2, h: 1.5 } }, | |
| { frame: 1050, bbox: { x: 35, y: 8, w: 2.5, h: 2 } }, | |
| { frame: 1300, bbox: { x: 52, y: 6, w: 2.5, h: 2 } }, | |
| { frame: 1550, bbox: { x: 68, y: 10, w: 3, h: 2.5 } }, | |
| { frame: 1800, bbox: { x: 82, y: 7, w: 2.5, h: 2 } }, | |
| ]), | |
| // 5 stationary | |
| createTrack('S-001', 'Stationary Alpha', 'stationary', 0.95, 0.0, 10.2, 3.8, [ | |
| { frame: 0, bbox: { x: 75, y: 72, w: 8, h: 6 } }, | |
| { frame: 600, bbox: { x: 75, y: 72, w: 8, h: 6 } }, | |
| { frame: 1200, bbox: { x: 75, y: 72, w: 8, h: 6 } }, | |
| { frame: 1847, bbox: { x: 75, y: 72, w: 8, h: 6 } }, | |
| ]), | |
| createTrack('S-002', 'Stationary Bravo', 'stationary', 0.90, 0.2, 15.8, 5.1, [ | |
| { frame: 0, bbox: { x: 22, y: 78, w: 10, h: 7 } }, | |
| { frame: 600, bbox: { x: 22, y: 78, w: 10, h: 7 } }, | |
| { frame: 1200, bbox: { x: 22, y: 78, w: 10, h: 7 } }, | |
| { frame: 1847, bbox: { x: 22, y: 78, w: 10, h: 7 } }, | |
| ]), | |
| createTrack('S-003', 'Stationary Charlie', 'stationary', 0.87, 0.1, 8.3, 2.4, [ | |
| { frame: 0, bbox: { x: 50, y: 82, w: 6, h: 5 } }, | |
| { frame: 900, bbox: { x: 50, y: 82, w: 6, h: 5 } }, | |
| { frame: 1847, bbox: { x: 50, y: 82, w: 6, h: 5 } }, | |
| ]), | |
| createTrack('S-004', 'Stationary Delta', 'stationary', 0.82, 0.5, 22.1, 6.2, [ | |
| { frame: 0, bbox: { x: 88, y: 65, w: 11, h: 8 } }, | |
| { frame: 500, bbox: { x: 88, y: 65, w: 11, h: 8 } }, | |
| { frame: 1000, bbox: { x: 88, y: 65.5, w: 11, h: 8 } }, | |
| { frame: 1500, bbox: { x: 88, y: 65, w: 11, h: 8 } }, | |
| { frame: 1847, bbox: { x: 88, y: 65, w: 11, h: 8 } }, | |
| ]), | |
| createTrack('S-005', 'Stationary Echo', 'stationary', 0.78, 0.3, 5.6, 1.9, [ | |
| { frame: 200, bbox: { x: 35, y: 58, w: 5, h: 4 } }, | |
| { frame: 700, bbox: { x: 35, y: 58, w: 5, h: 4 } }, | |
| { frame: 1200, bbox: { x: 35, y: 58, w: 5, h: 4 } }, | |
| { frame: 1700, bbox: { x: 35, y: 58, w: 5, h: 4 } }, | |
| ]), | |
| ]; | |
| // ── 18 Mock Timeline Events ───────────────────────────────────── | |
| const MOCK_EVENTS = [ | |
| { frame: 0, time: '00:00:00', type: 'system', label: 'Mission Start', description: 'ISR feed initialized. All sensors nominal.', priority: 'low' }, | |
| { frame: 85, time: '00:00:03', type: 'detection', label: 'First Detection', description: 'Person Alpha acquired at sector 2.', priority: 'medium' }, | |
| { frame: 210, time: '00:00:07', type: 'detection', label: 'Vehicle Spotted', description: 'Vehicle Alpha entering FOV from west.', priority: 'medium' }, | |
| { frame: 400, time: '00:00:13', type: 'alert', label: 'Drone Detected', description: 'Drone Alpha detected at high altitude. Tracking.', priority: 'high' }, | |
| { frame: 520, time: '00:00:17', type: 'tracking', label: 'Track Merge', description: 'Tracks P-003 and P-005 proximity alert.', priority: 'low' }, | |
| { frame: 650, time: '00:00:22', type: 'detection', label: 'New Vehicle', description: 'Vehicle Delta — high speed approach detected.', priority: 'medium' }, | |
| { frame: 780, time: '00:00:26', type: 'alert', label: 'Speed Warning', description: 'Vehicle Delta exceeding 75 kph in restricted zone.', priority: 'high' }, | |
| { frame: 800, time: '00:00:27', type: 'alert', label: 'Second Drone', description: 'Drone Bravo detected. Multiple aerial contacts.', priority: 'high' }, | |
| { frame: 920, time: '00:00:31', type: 'tracking', label: 'Track Lost', description: 'Vehicle Delta exited FOV at sector 1.', priority: 'medium' }, | |
| { frame: 1050, time: '00:00:35', type: 'detection', label: 'Crowd Formation', description: '5 persons converging at grid reference 4-C.', priority: 'medium' }, | |
| { frame: 1180, time: '00:00:39', type: 'system', label: 'Depth Map Update', description: 'Depth estimation recalibrated. Accuracy +12%.', priority: 'low' }, | |
| { frame: 1300, time: '00:00:43', type: 'alert', label: 'Drone Maneuver', description: 'Drone Alpha executing erratic flight pattern.', priority: 'high' }, | |
| { frame: 1380, time: '00:00:46', type: 'tracking', label: 'Re-ID Confirmed', description: 'Person Hotel re-identified after occlusion.', priority: 'low' }, | |
| { frame: 1450, time: '00:00:48', type: 'detection', label: 'Vehicle Convoy', description: 'Vehicles Bravo and Echo moving in formation.', priority: 'medium' }, | |
| { frame: 1550, time: '00:00:52', type: 'alert', label: 'Perimeter Breach', description: 'Drone Bravo approaching restricted airspace.', priority: 'high' }, | |
| { frame: 1650, time: '00:00:55', type: 'tracking', label: 'Person Foxtrot Exit', description: 'Person Foxtrot exited FOV. Track archived.', priority: 'low' }, | |
| { frame: 1750, time: '00:00:58', type: 'system', label: 'Frame Drop Warning', description: 'Processing latency spike: 45ms → 120ms.', priority: 'medium' }, | |
| { frame: 1847, time: '00:01:02', type: 'system', label: 'Mission Complete', description: 'ISR feed terminated. 20 tracks catalogued.', priority: 'low' }, | |
| ]; | |
| // ── Mock Detection Density (100 values, 0-1) ──────────────────── | |
| const MOCK_DENSITY = []; | |
| const DENSITY_TYPES = []; | |
| (function generateDensity() { | |
| const segments = 100; | |
| for (let i = 0; i < segments; i++) { | |
| const t = i / segments; | |
| // Base density with peaks at key event areas | |
| let d = 0.15 + Math.random() * 0.15; | |
| // Peak near frame 400 (drone entry) | |
| d += 0.35 * Math.exp(-Math.pow((t - 0.22) / 0.05, 2)); | |
| // Peak near frame 800 (second drone + speed warning) | |
| d += 0.45 * Math.exp(-Math.pow((t - 0.43) / 0.06, 2)); | |
| // Peak near frame 1050 (crowd) | |
| d += 0.30 * Math.exp(-Math.pow((t - 0.57) / 0.04, 2)); | |
| // Peak near frame 1300-1550 (drone maneuver + perimeter breach) | |
| d += 0.50 * Math.exp(-Math.pow((t - 0.75) / 0.08, 2)); | |
| // Gentle ramp at end | |
| d += 0.10 * Math.exp(-Math.pow((t - 0.95) / 0.06, 2)); | |
| MOCK_DENSITY.push(Math.min(1.0, d)); | |
| // Dominant type per segment | |
| if (t > 0.20 && t < 0.28) DENSITY_TYPES.push('drone'); | |
| else if (t > 0.40 && t < 0.48) DENSITY_TYPES.push('drone'); | |
| else if (t > 0.70 && t < 0.82) DENSITY_TYPES.push('drone'); | |
| else if (t > 0.30 && t < 0.38) DENSITY_TYPES.push('vehicle'); | |
| else if (t > 0.55 && t < 0.62) DENSITY_TYPES.push('person'); | |
| else DENSITY_TYPES.push(Math.random() > 0.6 ? 'vehicle' : 'person'); | |
| } | |
| })(); | |
| // ── Mock Placeholder Data for Depth / Mask / PointCloud ───────── | |
| const MOCK_MASKS = {}; | |
| MOCK_TRACKS.forEach(t => { | |
| // Each mask is a placeholder canvas-like descriptor | |
| MOCK_MASKS[t.id] = { | |
| trackId: t.id, | |
| type: 'binary-mask', | |
| width: 640, | |
| height: 480, | |
| data: null, // would be Uint8Array in real impl | |
| }; | |
| }); | |
| const MOCK_DEPTH = { | |
| type: 'depth-map', | |
| width: 640, | |
| height: 480, | |
| min: 0.5, | |
| max: 150.0, | |
| data: null, // would be Float32Array in real impl | |
| }; | |
| const MOCK_POINTCLOUD = { | |
| type: 'point-cloud', | |
| numPoints: 12000, | |
| bounds: { minX: -10, maxX: 10, minY: -5, maxY: 5, minZ: 0, maxZ: 50 }, | |
| positions: null, // would be Float32Array(numPoints * 3) in real impl | |
| colors: null, // would be Float32Array(numPoints * 3) in real impl | |
| }; | |
| // ── Mock AI Responses ─────────────────────────────────────────── | |
| const MOCK_AI_RESPONSES = { | |
| 'threat assessment': { | |
| text: 'THREAT ASSESSMENT — 2 aerial contacts (Drone Alpha, Drone Bravo) classified HIGH PRIORITY. Drone Alpha exhibiting erratic flight pattern near restricted airspace. Drone Bravo approaching perimeter. 1 ground vehicle (Vehicle Delta) flagged for excessive speed (78.5 kph) in restricted zone. Recommend immediate action on aerial contacts.', | |
| action: null, | |
| }, | |
| 'show all drones': { | |
| text: 'Filtering 2 drone tracks: Drone Alpha (D-001, confidence 0.97) and Drone Bravo (D-002, confidence 0.93). Both classified as HIGH PRIORITY aerial threats.', | |
| action: 'filter-drones', | |
| }, | |
| 'generate report': { | |
| text: 'Generating mission report... Report includes 20 tracked objects across 1847 frames (61.6 seconds). 5 high-priority alerts logged. Report ready for download.', | |
| action: 'show-report', | |
| }, | |
| 'summarize mission': { | |
| text: 'MISSION SUMMARY — Duration: 61.6s (1847 frames at 30fps). Total tracks: 20 (8 persons, 5 vehicles, 2 drones, 5 stationary). High-priority events: 5 (2 drone detections, 1 speed violation, 1 erratic maneuver, 1 perimeter breach). All tracks catalogued. Recommend review of aerial contact timeline.', | |
| action: null, | |
| }, | |
| 'count vehicles': { | |
| text: '5 vehicle tracks detected: Vehicle Alpha (45.0 kph), Vehicle Bravo (62.3 kph), Vehicle Charlie (38.7 kph), Vehicle Delta (78.5 kph), Vehicle Echo (31.2 kph). Average speed: 51.1 kph. Vehicle Delta flagged for excessive speed.', | |
| action: null, | |
| }, | |
| }; | |
| // ── Async Data Functions (mock backend API) ───────────────────── | |
| async function fetchTracks(jobId) { | |
| return MOCK_TRACKS; | |
| } | |
| async function fetchFrame(jobId, frameIdx) { | |
| // In production, returns image blob from MJPEG stream | |
| return null; | |
| } | |
| async function fetchMask(jobId, frameIdx, trackId) { | |
| return MOCK_MASKS[trackId] || null; | |
| } | |
| async function fetchDepth(jobId, frameIdx, trackId = null) { | |
| return MOCK_DEPTH; | |
| } | |
| async function fetchPointCloud(jobId, frameIdx, trackId) { | |
| return MOCK_POINTCLOUD; | |
| } | |
| async function fetchTimelineSummary(jobId) { | |
| return MOCK_DENSITY; | |
| } | |
| async function startDetection(video, config) { | |
| return { jobId: 'demo-001' }; | |
| } | |
| async function pollStatus(jobId) { | |
| return { | |
| status: STATE.current, | |
| progress: STATE.playheadFrame / STATE.totalFrames, | |
| }; | |
| } | |
| async function askAI(question, context) { | |
| const key = question.toLowerCase().trim(); | |
| if (MOCK_AI_RESPONSES[key]) { | |
| return MOCK_AI_RESPONSES[key]; | |
| } | |
| // Partial matching — check if the question contains any known keyword | |
| for (const [keyword, response] of Object.entries(MOCK_AI_RESPONSES)) { | |
| if (key.includes(keyword)) { | |
| return response; | |
| } | |
| } | |
| return { | |
| text: 'Command not recognized. Try: threat assessment, show all drones, generate report, summarize mission, count vehicles', | |
| action: null, | |
| }; | |
| } | |
| // ── getTracksAtFrame — with linear bbox interpolation ─────────── | |
| function getTracksAtFrame(frameIdx) { | |
| const visible = []; | |
| for (const track of MOCK_TRACKS) { | |
| const kf = track.keyframes; | |
| if (kf.length === 0) continue; | |
| const firstFrame = kf[0].frame; | |
| const lastFrame = kf[kf.length - 1].frame; | |
| // Track not visible outside its keyframe range | |
| if (frameIdx < firstFrame || frameIdx > lastFrame) continue; | |
| // Find surrounding keyframes for interpolation | |
| let before = null; | |
| let after = null; | |
| for (let i = 0; i < kf.length; i++) { | |
| if (kf[i].frame <= frameIdx) { | |
| before = kf[i]; | |
| } | |
| if (kf[i].frame >= frameIdx && after === null) { | |
| after = kf[i]; | |
| } | |
| } | |
| let bbox; | |
| if (!before || !after || before.frame === after.frame) { | |
| // Exact keyframe or edge case | |
| bbox = { ...(before || after).bbox }; | |
| } else { | |
| // Linear interpolation | |
| const t = (frameIdx - before.frame) / (after.frame - before.frame); | |
| bbox = { | |
| x: before.bbox.x + (after.bbox.x - before.bbox.x) * t, | |
| y: before.bbox.y + (after.bbox.y - before.bbox.y) * t, | |
| w: before.bbox.w + (after.bbox.w - before.bbox.w) * t, | |
| h: before.bbox.h + (after.bbox.h - before.bbox.h) * t, | |
| }; | |
| } | |
| visible.push({ | |
| ...track, | |
| bbox, | |
| interpolatedFrame: frameIdx, | |
| }); | |
| } | |
| return visible; | |
| } | |
| // ── Boot Log ──────────────────────────────────────────────────── | |
| console.log('[ISR Command Center] Mock data layer initialized.'); | |
| console.log(` Tracks: ${MOCK_TRACKS.length}`); | |
| console.log(` Events: ${MOCK_EVENTS.length}`); | |
| console.log(` Density segments: ${MOCK_DENSITY.length}`); | |
| console.log(` AI commands: ${Object.keys(MOCK_AI_RESPONSES).length}`); | |
| /* ================================================================ | |
| * TASK 3: Top Bar — Clock | |
| * ================================================================ */ | |
| function updateClock() { | |
| const el = document.getElementById('liveClock'); | |
| if (!el) return; | |
| const now = new Date(); | |
| const h = String(now.getUTCHours()).padStart(2, '0'); | |
| const m = String(now.getUTCMinutes()).padStart(2, '0'); | |
| const s = String(now.getUTCSeconds()).padStart(2, '0'); | |
| el.textContent = `${h}:${m}:${s}Z`; | |
| } | |
| /* ================================================================ | |
| * TASK 4: Video Feed — Rendering | |
| * ================================================================ */ | |
| let videoCanvas, videoCtx, overlayCanvas, overlayCtx; | |
| let scanLineY = 0; | |
| let animFrame = 0; | |
| let playbackSpeed = 1; | |
| let lastFrameTime = 0; | |
| function initCanvases() { | |
| videoCanvas = document.getElementById('videoCanvas'); | |
| overlayCanvas = document.getElementById('overlayCanvas'); | |
| videoCtx = videoCanvas.getContext('2d'); | |
| overlayCtx = overlayCanvas.getContext('2d'); | |
| resizeCanvases(); | |
| } | |
| function resizeCanvases() { | |
| const container = document.getElementById('videoFeed'); | |
| const rect = container.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| [videoCanvas, overlayCanvas].forEach(c => { | |
| c.width = rect.width * dpr; | |
| c.height = rect.height * dpr; | |
| c.style.width = rect.width + 'px'; | |
| c.style.height = rect.height + 'px'; | |
| c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); | |
| }); | |
| } | |
| function renderVideoBackground(ctx, w, h, frame) { | |
| ctx.fillStyle = '#0a0f1a'; | |
| ctx.fillRect(0, 0, w, h); | |
| // Grid lines | |
| ctx.strokeStyle = 'rgba(255,255,255,0.02)'; | |
| ctx.lineWidth = 0.5; | |
| for (let x = 0; x < w; x += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, h); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < h; y += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(w, y); | |
| ctx.stroke(); | |
| } | |
| // Scan line — slow horizontal sweep | |
| scanLineY = (scanLineY + 0.5) % h; | |
| const scanGrad = ctx.createLinearGradient(0, scanLineY - 30, 0, scanLineY + 30); | |
| scanGrad.addColorStop(0, 'rgba(255,255,255,0)'); | |
| scanGrad.addColorStop(0.5, 'rgba(255,255,255,0.015)'); | |
| scanGrad.addColorStop(1, 'rgba(255,255,255,0)'); | |
| ctx.fillStyle = scanGrad; | |
| ctx.fillRect(0, scanLineY - 30, w, 60); | |
| // Vignette effect — darker at edges | |
| const vignetteGrad = ctx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.25, w * 0.5, h * 0.5, Math.max(w, h) * 0.75); | |
| vignetteGrad.addColorStop(0, 'rgba(0,0,0,0)'); | |
| vignetteGrad.addColorStop(1, 'rgba(0,0,0,0.35)'); | |
| ctx.fillStyle = vignetteGrad; | |
| ctx.fillRect(0, 0, w, h); | |
| // Corner crosshair markers — military tactical HUD | |
| const crossLen = 18; | |
| const crossOff = 12; | |
| ctx.strokeStyle = 'rgba(255,255,255,0.08)'; | |
| ctx.lineWidth = 1; | |
| // Top-left | |
| ctx.beginPath(); | |
| ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff + crossLen, crossOff); | |
| ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff, crossOff + crossLen); | |
| ctx.stroke(); | |
| // Top-right | |
| ctx.beginPath(); | |
| ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff - crossLen, crossOff); | |
| ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff, crossOff + crossLen); | |
| ctx.stroke(); | |
| // Bottom-left | |
| ctx.beginPath(); | |
| ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff + crossLen, h - crossOff); | |
| ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff, h - crossOff - crossLen); | |
| ctx.stroke(); | |
| // Bottom-right | |
| ctx.beginPath(); | |
| ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff - crossLen, h - crossOff); | |
| ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff, h - crossOff - crossLen); | |
| ctx.stroke(); | |
| } | |
| function roundRect(ctx, x, y, w, h, r) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + r, y); | |
| ctx.lineTo(x + w - r, y); | |
| ctx.arcTo(x + w, y, x + w, y + r, r); | |
| ctx.lineTo(x + w, y + h - r); | |
| ctx.arcTo(x + w, y + h, x + w - r, y + h, r); | |
| ctx.lineTo(x + r, y + h); | |
| ctx.arcTo(x, y + h, x, y + h - r, r); | |
| ctx.lineTo(x, y + r); | |
| ctx.arcTo(x, y, x + r, y, r); | |
| ctx.closePath(); | |
| } | |
| // Track which boxes have been seen, for fade-in animation | |
| const boxFirstSeen = {}; | |
| let boxAnimTime = 0; | |
| function renderDetections(ctx, w, h, frame) { | |
| ctx.clearRect(0, 0, w, h); | |
| const tracks = getTracksAtFrame(frame); | |
| boxAnimTime = performance.now(); | |
| for (const t of tracks) { | |
| const bx = (t.bbox.x / 100) * w; | |
| const by = (t.bbox.y / 100) * h; | |
| const bw = (t.bbox.w / 100) * w; | |
| const bh = (t.bbox.h / 100) * h; | |
| // Fade-in: track when boxes first appear | |
| if (!boxFirstSeen[t.id]) { | |
| boxFirstSeen[t.id] = boxAnimTime; | |
| } | |
| const age = boxAnimTime - boxFirstSeen[t.id]; | |
| const fadeAlpha = Math.min(1, age / 300); // 300ms fade-in | |
| const isSelected = (STATE.selectedTrackId === t.id); | |
| const isHighlighted = (highlightedTrackId === t.id) || isSelected; | |
| const lineWidth = isHighlighted ? 2.5 : 1.5; | |
| ctx.globalAlpha = fadeAlpha; | |
| // Pulsing glow for highlighted boxes | |
| if (isHighlighted) { | |
| const pulse = 0.6 + 0.4 * Math.sin(boxAnimTime / 300); | |
| ctx.shadowColor = t.color; | |
| ctx.shadowBlur = 8 + 8 * pulse; | |
| } | |
| // Selected box: animated dashed border | |
| if (isSelected) { | |
| ctx.setLineDash([6, 4]); | |
| ctx.lineDashOffset = -(boxAnimTime / 50); // marching ants | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 2; | |
| roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.lineDashOffset = 0; | |
| } | |
| // Rounded rect border | |
| ctx.strokeStyle = isHighlighted ? '#fff' : t.color; | |
| ctx.lineWidth = lineWidth; | |
| roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.stroke(); | |
| ctx.shadowColor = 'transparent'; | |
| ctx.shadowBlur = 0; | |
| // Faint fill | |
| ctx.fillStyle = t.color + (isHighlighted ? '1A' : '0D'); | |
| roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.fill(); | |
| // Label badge above | |
| const labelText = `${t.label.split(' ').pop()} ${t.confidence.toFixed(2)}`; | |
| ctx.font = '500 9px Inter, sans-serif'; | |
| const tm = ctx.measureText(labelText); | |
| const lw = tm.width + 8; | |
| const lh = 14; | |
| const lx = bx; | |
| const ly = by - lh - 3; | |
| ctx.fillStyle = t.color + 'CC'; | |
| roundRect(ctx, lx, ly, lw, lh, 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(labelText, lx + 4, ly + lh / 2); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function updateFrameCounter() { | |
| const el = document.getElementById('frameCounter'); | |
| if (el) el.textContent = `FRM ${STATE.playheadFrame} / ${STATE.totalFrames}`; | |
| } | |
| function mainRenderLoop(timestamp) { | |
| if (!videoCanvas) { requestAnimationFrame(mainRenderLoop); return; } | |
| const container = document.getElementById('videoFeed'); | |
| const rect = container.getBoundingClientRect(); | |
| const w = rect.width; | |
| const h = rect.height; | |
| renderVideoBackground(videoCtx, w, h, STATE.playheadFrame); | |
| if (STATE.current !== 'ready') { | |
| renderDetections(overlayCtx, w, h, STATE.playheadFrame); | |
| } else { | |
| overlayCtx.clearRect(0, 0, w, h); | |
| } | |
| // Advance playhead if playing | |
| if (STATE.isPlaying && (STATE.current === 'playing' || STATE.current === 'analysis')) { | |
| if (!lastFrameTime) lastFrameTime = timestamp; | |
| const elapsed = timestamp - lastFrameTime; | |
| const framesPerMs = (STATE.fps * playbackSpeed) / 1000; | |
| const framesToAdvance = Math.floor(elapsed * framesPerMs); | |
| if (framesToAdvance > 0) { | |
| STATE.playheadFrame = Math.min(STATE.playheadFrame + framesToAdvance, STATE.totalFrames); | |
| lastFrameTime = timestamp; | |
| updateFrameCounter(); | |
| updatePlayheadPosition(); | |
| updateTimeDisplay(); | |
| if (STATE.playheadFrame >= STATE.totalFrames) { | |
| STATE.isPlaying = false; | |
| STATE.playheadFrame = STATE.totalFrames; | |
| document.getElementById('playPauseBtn').innerHTML = '▶'; | |
| } | |
| } | |
| } | |
| requestAnimationFrame(mainRenderLoop); | |
| } | |
| /* ================================================================ | |
| * TASK 8: State Machine | |
| * ================================================================ */ | |
| let processingInterval = null; | |
| let playbackAnimFrame = null; | |
| let highlightedTrackId = null; | |
| function setState(newState) { | |
| const prev = STATE.current; | |
| STATE.current = newState; | |
| document.body.setAttribute('data-state', newState); | |
| onStateChange(prev, newState); | |
| } | |
| function onStateChange(prev, newState) { | |
| const topDot = document.querySelector('.topbar-left .dot'); | |
| const missionLabel = document.querySelector('.topbar-mission'); | |
| // Update top bar status | |
| if (newState === 'ready') { | |
| if (topDot) { | |
| topDot.style.color = 'var(--accent)'; | |
| topDot.style.background = 'var(--accent)'; | |
| topDot.classList.remove('topbar-dot-processing'); | |
| } | |
| if (missionLabel) missionLabel.textContent = 'MISSION ACTIVE'; | |
| } else if (newState === 'processing') { | |
| if (topDot) { | |
| topDot.style.color = 'var(--warning)'; | |
| topDot.style.background = 'var(--warning)'; | |
| topDot.classList.add('topbar-dot-processing'); | |
| } | |
| if (missionLabel) { | |
| missionLabel.style.color = 'var(--warning)'; | |
| missionLabel.textContent = 'PROCESSING'; | |
| } | |
| } else if (newState === 'analysis' || newState === 'playing') { | |
| if (topDot) { | |
| topDot.style.color = 'var(--success)'; | |
| topDot.style.background = 'var(--success)'; | |
| topDot.classList.remove('topbar-dot-processing'); | |
| } | |
| if (missionLabel) { | |
| missionLabel.style.color = 'var(--success)'; | |
| missionLabel.textContent = 'ANALYSIS'; | |
| } | |
| } else if (newState === 'inspect') { | |
| if (topDot) { | |
| topDot.style.color = 'var(--accent-light)'; | |
| topDot.style.background = 'var(--accent-light)'; | |
| topDot.classList.remove('topbar-dot-processing'); | |
| } | |
| if (missionLabel) { | |
| missionLabel.style.color = 'var(--accent-light)'; | |
| missionLabel.textContent = 'INSPECT'; | |
| } | |
| } | |
| // Drawer tab availability managed by CSS, but switch to appropriate tab | |
| if (newState === 'ready') { | |
| switchDrawerTab('tracks'); | |
| } else if (newState === 'processing') { | |
| switchDrawerTab('tracks'); | |
| } else if (newState === 'analysis' || newState === 'playing') { | |
| switchDrawerTab('tracks'); | |
| } else if (newState === 'inspect') { | |
| switchDrawerTab('inspect'); | |
| } | |
| } | |
| /* ================================================================ | |
| * TASK 8: Processing Simulation | |
| * ================================================================ */ | |
| function startProcessingSimulation() { | |
| // Fade out start button before transitioning | |
| const startBtn = document.getElementById('startBtn'); | |
| if (startBtn) startBtn.classList.add('fading-out'); | |
| // Clear box animation tracking for fresh detection appearances | |
| Object.keys(boxFirstSeen).forEach(k => delete boxFirstSeen[k]); | |
| setTimeout(() => { | |
| setState('processing'); | |
| const circumference = 2 * Math.PI * 34; | |
| const circle = document.getElementById('progressCircle'); | |
| const pctEl = document.getElementById('progressPct'); | |
| const waveformCanvas = document.getElementById('waveformCanvas'); | |
| const tracksPanel = document.getElementById('tracksPanel'); | |
| const topBarProgress = document.getElementById('topBarProgress'); | |
| const fpsDisplay = document.getElementById('fpsDisplay'); | |
| STATE.playheadFrame = 0; | |
| let startTime = performance.now(); | |
| const duration = 10000; // 10 seconds | |
| let lastTrackIndex = 0; | |
| const trackStaggerInterval = 500; // one every 0.5s | |
| let lastFpsFlicker = 0; | |
| // Clear track list | |
| tracksPanel.innerHTML = ''; | |
| processingInterval = setInterval(() => { | |
| const elapsed = performance.now() - startTime; | |
| const progress = Math.min(elapsed / duration, 1.0); | |
| // Update playhead frame linearly | |
| STATE.playheadFrame = Math.round(progress * STATE.totalFrames); | |
| updateFrameCounter(); | |
| updatePlayheadPosition(); | |
| // Update time display | |
| updateTimeDisplay(); | |
| // Update progress ring | |
| const offset = circumference - progress * circumference; | |
| circle.style.strokeDashoffset = offset; | |
| pctEl.textContent = Math.round(progress * 100) + '%'; | |
| // Update top bar progress bar | |
| if (topBarProgress) { | |
| topBarProgress.style.width = (progress * 100) + '%'; | |
| } | |
| // FPS flicker effect — every ~800ms | |
| if (fpsDisplay && elapsed - lastFpsFlicker > 800) { | |
| lastFpsFlicker = elapsed; | |
| const fpsVal = 45 + Math.floor(Math.random() * 8); | |
| fpsDisplay.textContent = fpsVal + ' FPS'; | |
| fpsDisplay.classList.add('fps-updating'); | |
| setTimeout(() => fpsDisplay.classList.remove('fps-updating'), 300); | |
| } | |
| // Progressively fill waveform — render partial density | |
| if (waveformCanvas) { | |
| const visibleBars = Math.round(progress * MOCK_DENSITY.length); | |
| const partialDensity = MOCK_DENSITY.map((v, i) => i < visibleBars ? v : 0); | |
| renderWaveform(waveformCanvas, partialDensity); | |
| } | |
| // Stagger track cards | |
| const tracksToShow = Math.min(Math.floor(elapsed / trackStaggerInterval), MOCK_TRACKS.length); | |
| while (lastTrackIndex < tracksToShow) { | |
| appendTrackCard(MOCK_TRACKS[lastTrackIndex], tracksPanel); | |
| lastTrackIndex++; | |
| } | |
| // Completion | |
| if (progress >= 1.0) { | |
| clearInterval(processingInterval); | |
| processingInterval = null; | |
| // Reset top bar progress | |
| if (topBarProgress) { | |
| topBarProgress.style.width = '100%'; | |
| setTimeout(() => { | |
| topBarProgress.style.opacity = '0'; | |
| setTimeout(() => { topBarProgress.style.width = '0%'; }, 500); | |
| }, 300); | |
| } | |
| // Reset start button state and FPS display | |
| if (startBtn) startBtn.classList.remove('fading-out'); | |
| if (fpsDisplay) fpsDisplay.textContent = '48 FPS'; | |
| // Brief pause then transition to analysis | |
| setTimeout(() => { | |
| enterAnalysisState(); | |
| }, 400); | |
| } | |
| }, 1000 / 60); // ~60fps | |
| }, 300); // Wait for start button fade | |
| } | |
| function appendTrackCard(track, container) { | |
| const card = document.createElement('div'); | |
| card.className = 'track-card'; | |
| card.dataset.trackId = track.id; | |
| card.style.opacity = '0'; | |
| card.style.transform = 'translateY(8px)'; | |
| card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; | |
| card.innerHTML = ` | |
| <div class="track-dot" style="background: ${track.color}; box-shadow: 0 0 4px ${track.color};"></div> | |
| <div class="track-info"> | |
| <div class="track-label">${track.label}</div> | |
| <div class="track-meta"> | |
| <span>${track.speed.toFixed(1)} kph</span> | |
| <span>${track.depth.toFixed(1)}m</span> | |
| <span>${track.id}</span> | |
| </div> | |
| </div> | |
| <span class="track-conf">${track.confidence.toFixed(2)}</span> | |
| `; | |
| wireTrackCard(card, track); | |
| container.appendChild(card); | |
| // Trigger animation | |
| requestAnimationFrame(() => { | |
| card.style.opacity = '1'; | |
| card.style.transform = 'translateY(0)'; | |
| }); | |
| } | |
| function wireTrackCard(card, track) { | |
| card.addEventListener('click', () => { | |
| if (STATE.current === 'analysis' || STATE.current === 'playing') { | |
| enterInspectState(track.id); | |
| } else if (STATE.current === 'inspect') { | |
| enterInspectState(track.id); | |
| } else { | |
| STATE.selectedTrackId = STATE.selectedTrackId === track.id ? null : track.id; | |
| document.querySelectorAll('.track-card').forEach(c => { | |
| c.classList.toggle('selected', c.dataset.trackId === STATE.selectedTrackId); | |
| }); | |
| } | |
| }); | |
| // Hover sync: card -> canvas | |
| card.addEventListener('mouseenter', () => { | |
| highlightedTrackId = track.id; | |
| card.classList.add('highlighted'); | |
| }); | |
| card.addEventListener('mouseleave', () => { | |
| highlightedTrackId = null; | |
| card.classList.remove('highlighted'); | |
| }); | |
| } | |
| /* ================================================================ | |
| * TASK 8: Analysis State Entry | |
| * ================================================================ */ | |
| function enterAnalysisState() { | |
| setState('analysis'); | |
| // Reset playhead to 0 | |
| STATE.playheadFrame = 0; | |
| STATE.isPlaying = false; | |
| lastFrameTime = 0; | |
| updateFrameCounter(); | |
| updatePlayheadPosition(); | |
| updateTimeDisplay(); | |
| document.getElementById('playPauseBtn').innerHTML = '▶'; | |
| // Render full waveform | |
| const waveformCanvas = document.getElementById('waveformCanvas'); | |
| if (waveformCanvas) renderWaveform(waveformCanvas, MOCK_DENSITY); | |
| // Show full track list with interaction wiring | |
| renderTrackListFull(MOCK_TRACKS, STATE.trackFilter); | |
| // Render metrics panel | |
| renderMetricsPanel(); | |
| showToast('Processing complete. 20 tracks detected across 1847 frames. Use timeline to review.', 6000); | |
| } | |
| function renderTrackListFull(tracks, filter) { | |
| const panel = document.getElementById('tracksPanel'); | |
| panel.innerHTML = ''; | |
| const filtered = filter ? tracks.filter(t => t.type === filter) : tracks; | |
| if (filtered.length === 0) { | |
| panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No tracks match the current filter.</div>'; | |
| return; | |
| } | |
| // If there's a filter, add a clear filter button | |
| if (filter) { | |
| const clearBtn = document.createElement('button'); | |
| clearBtn.className = 'inspect-back-btn'; | |
| clearBtn.style.marginBottom = '8px'; | |
| clearBtn.style.width = '100%'; | |
| clearBtn.textContent = 'CLEAR FILTER — SHOW ALL'; | |
| clearBtn.addEventListener('click', () => { | |
| STATE.trackFilter = null; | |
| renderTrackListFull(MOCK_TRACKS, null); | |
| }); | |
| panel.appendChild(clearBtn); | |
| } | |
| for (const t of filtered) { | |
| const card = document.createElement('div'); | |
| card.className = 'track-card' + (STATE.selectedTrackId === t.id ? ' selected' : ''); | |
| card.dataset.trackId = t.id; | |
| card.innerHTML = ` | |
| <div class="track-dot" style="background: ${t.color}; box-shadow: 0 0 4px ${t.color};"></div> | |
| <div class="track-info"> | |
| <div class="track-label">${t.label}</div> | |
| <div class="track-meta"> | |
| <span>${t.speed.toFixed(1)} kph</span> | |
| <span>${t.depth.toFixed(1)}m</span> | |
| <span>${t.id}</span> | |
| </div> | |
| </div> | |
| <span class="track-conf">${t.confidence.toFixed(2)}</span> | |
| `; | |
| wireTrackCard(card, t); | |
| panel.appendChild(card); | |
| } | |
| } | |
| function renderMetricsPanel() { | |
| const panel = document.getElementById('metricsPanel'); | |
| if (!panel) return; | |
| const personCount = MOCK_TRACKS.filter(t => t.type === 'person').length; | |
| const vehicleCount = MOCK_TRACKS.filter(t => t.type === 'vehicle').length; | |
| const droneCount = MOCK_TRACKS.filter(t => t.type === 'drone').length; | |
| const stationaryCount = MOCK_TRACKS.filter(t => t.type === 'stationary').length; | |
| const totalCount = MOCK_TRACKS.length; | |
| const avgConf = (MOCK_TRACKS.reduce((s, t) => s + t.confidence, 0) / totalCount).toFixed(2); | |
| const breakdown = [ | |
| { label: 'Person', count: personCount, color: TYPE_COLORS.person }, | |
| { label: 'Vehicle', count: vehicleCount, color: TYPE_COLORS.vehicle }, | |
| { label: 'Drone', count: droneCount, color: TYPE_COLORS.drone }, | |
| { label: 'Stationary', count: stationaryCount, color: TYPE_COLORS.stationary }, | |
| ]; | |
| const maxCount = Math.max(...breakdown.map(b => b.count)); | |
| panel.innerHTML = ` | |
| <div class="metric-hero"> | |
| <div class="metric-big-number">${totalCount}</div> | |
| <div class="label">TOTAL DETECTIONS</div> | |
| </div> | |
| <div class="metric-breakdown"> | |
| ${breakdown.map(b => ` | |
| <div class="metric-bar-row"> | |
| <span class="metric-bar-label">${b.label}</span> | |
| <div class="metric-bar-track"> | |
| <div class="metric-bar-fill" style="width: ${maxCount > 0 ? (b.count / maxCount) * 100 : 0}%; background: ${b.color};"></div> | |
| </div> | |
| <span class="metric-bar-count" style="color: ${b.color};">${b.count}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <div class="metric-stats"> | |
| <div class="metric-stat-row"><span class="metric-stat-label">AVG CONFIDENCE</span><span class="metric-stat-value">${avgConf}</span></div> | |
| <div class="metric-stat-row"><span class="metric-stat-label">PROCESSING FPS</span><span class="metric-stat-value">48</span></div> | |
| <div class="metric-stat-row"><span class="metric-stat-label">GPU UTILIZATION</span><span class="metric-stat-value">94%</span></div> | |
| <div class="metric-stat-row"><span class="metric-stat-label">TOTAL FRAMES</span><span class="metric-stat-value">${STATE.totalFrames}</span></div> | |
| <div class="metric-stat-row"><span class="metric-stat-label">PROCESS TIME</span><span class="metric-stat-value">38.5s</span></div> | |
| </div> | |
| `; | |
| } | |
| /* ================================================================ | |
| * TASK 10: Inspect State — 2x2 Quad View | |
| * ================================================================ */ | |
| let threeScene = null; | |
| let threeCamera = null; | |
| let threeRenderer = null; | |
| let threeMesh = null; | |
| let threeAnimId = null; | |
| function enterInspectState(trackId) { | |
| // Clean up previous 3D viewer | |
| destroy3DViewer(); | |
| STATE.selectedTrackId = trackId; | |
| STATE.expandedQuadrant = null; | |
| setState('inspect'); | |
| const track = MOCK_TRACKS.find(t => t.id === trackId); | |
| if (!track) return; | |
| const panel = document.getElementById('inspectPanel'); | |
| panel.innerHTML = ` | |
| <div id="inspectHeader"> | |
| <button id="inspectBack">← Back</button> | |
| <span id="inspectLabel" style="color: ${track.color};">${track.label.toUpperCase()}</span> | |
| <span id="inspectConf" class="value">${track.confidence.toFixed(2)}</span> | |
| </div> | |
| <div id="quadGrid"> | |
| <div class="quadrant" data-quad="seg"> | |
| <div class="quad-label" style="color: var(--accent)">SEGMENTATION</div> | |
| <canvas class="quad-canvas"></canvas> | |
| <div class="quad-metric"></div> | |
| </div> | |
| <div class="quadrant" data-quad="edge"> | |
| <div class="quad-label" style="color: #94a3b8">EDGE</div> | |
| <canvas class="quad-canvas"></canvas> | |
| </div> | |
| <div class="quadrant" data-quad="depth"> | |
| <div class="quad-label" style="color: var(--warning)">DEPTH</div> | |
| <canvas class="quad-canvas"></canvas> | |
| <div class="quad-metric"></div> | |
| </div> | |
| <div class="quadrant" data-quad="3d"> | |
| <div class="quad-label" style="color: var(--success)">3D MESH</div> | |
| <div id="threeContainer"></div> | |
| <div class="quad-metric"></div> | |
| </div> | |
| </div> | |
| <div id="inspectMetrics"></div> | |
| `; | |
| // Render all 4 quadrants | |
| requestAnimationFrame(() => { | |
| const segCanvas = panel.querySelector('[data-quad="seg"] .quad-canvas'); | |
| const edgeCanvas = panel.querySelector('[data-quad="edge"] .quad-canvas'); | |
| const depthCanvas = panel.querySelector('[data-quad="depth"] .quad-canvas'); | |
| const threeContainer = document.getElementById('threeContainer'); | |
| if (segCanvas) renderSegmentation(segCanvas, track); | |
| if (edgeCanvas) renderEdgeDetection(edgeCanvas, track); | |
| if (depthCanvas) renderDepthHeatmap(depthCanvas, track); | |
| if (threeContainer) init3DViewer(threeContainer, track); | |
| // Update quad metrics | |
| const segMetric = panel.querySelector('[data-quad="seg"] .quad-metric'); | |
| const depthMetric = panel.querySelector('[data-quad="depth"] .quad-metric'); | |
| const meshMetric = panel.querySelector('[data-quad="3d"] .quad-metric'); | |
| if (segMetric) segMetric.textContent = `AREA ${track.area.toFixed(1)}%`; | |
| if (depthMetric) depthMetric.textContent = `${track.depth.toFixed(1)}m`; | |
| if (meshMetric) meshMetric.textContent = '12,847 pts'; | |
| }); | |
| // Inspect metrics strip | |
| const metricsStrip = document.getElementById('inspectMetrics'); | |
| if (metricsStrip) { | |
| metricsStrip.innerHTML = ` | |
| <div class="inspect-metric-item"><div class="label">VELOCITY</div><div class="value">${track.speed.toFixed(1)} kph</div></div> | |
| <div class="inspect-metric-item"><div class="label">DEPTH</div><div class="value">${track.depth.toFixed(1)}m</div></div> | |
| <div class="inspect-metric-item"><div class="label">AREA</div><div class="value">${track.area.toFixed(1)}%</div></div> | |
| <div class="inspect-metric-item"><div class="label">CONFIDENCE</div><div class="value">${track.confidence.toFixed(2)}</div></div> | |
| `; | |
| } | |
| // Wire back button | |
| document.getElementById('inspectBack').addEventListener('click', exitInspectState); | |
| // Wire quadrant expand/collapse | |
| panel.querySelectorAll('.quadrant').forEach(q => { | |
| q.addEventListener('click', () => { | |
| const quadName = q.dataset.quad; | |
| if (STATE.expandedQuadrant === quadName) { | |
| // Collapse | |
| q.classList.remove('expanded'); | |
| STATE.expandedQuadrant = null; | |
| // Show other quadrants | |
| panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = ''); | |
| } else { | |
| // Collapse any previous | |
| panel.querySelectorAll('.quadrant.expanded').forEach(eq => eq.classList.remove('expanded')); | |
| panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = ''); | |
| // Hide others, expand this one | |
| panel.querySelectorAll('.quadrant').forEach(oq => { | |
| if (oq.dataset.quad !== quadName) oq.style.display = 'none'; | |
| }); | |
| q.classList.add('expanded'); | |
| STATE.expandedQuadrant = quadName; | |
| } | |
| // Re-render canvases after layout change | |
| requestAnimationFrame(() => { | |
| const segCanvas = panel.querySelector('[data-quad="seg"] .quad-canvas'); | |
| const edgeCanvas = panel.querySelector('[data-quad="edge"] .quad-canvas'); | |
| const depthCanvas = panel.querySelector('[data-quad="depth"] .quad-canvas'); | |
| if (segCanvas && segCanvas.offsetParent !== null) renderSegmentation(segCanvas, track); | |
| if (edgeCanvas && edgeCanvas.offsetParent !== null) renderEdgeDetection(edgeCanvas, track); | |
| if (depthCanvas && depthCanvas.offsetParent !== null) renderDepthHeatmap(depthCanvas, track); | |
| // Resize 3D if visible | |
| if (threeRenderer && document.getElementById('threeContainer') && document.getElementById('threeContainer').offsetParent !== null) { | |
| const tc = document.getElementById('threeContainer'); | |
| threeRenderer.setSize(tc.clientWidth, tc.clientHeight); | |
| if (threeCamera) { | |
| threeCamera.aspect = tc.clientWidth / tc.clientHeight; | |
| threeCamera.updateProjectionMatrix(); | |
| } | |
| } | |
| }); | |
| }); | |
| }); | |
| // Pause playback, jump to track midpoint | |
| STATE.isPlaying = false; | |
| document.getElementById('playPauseBtn').innerHTML = '▶'; | |
| const midFrame = Math.round((track.keyframes[0].frame + track.keyframes[track.keyframes.length - 1].frame) / 2); | |
| STATE.playheadFrame = midFrame; | |
| updatePlayheadPosition(); | |
| updateFrameCounter(); | |
| updateTimeDisplay(); | |
| } | |
| function exitInspectState() { | |
| destroy3DViewer(); | |
| STATE.selectedTrackId = null; | |
| STATE.expandedQuadrant = null; | |
| setState('analysis'); | |
| switchDrawerTab('tracks'); | |
| renderTrackListFull(MOCK_TRACKS, STATE.trackFilter); | |
| } | |
| /* ── Segmentation Renderer ────────────────────────────────────── */ | |
| function renderSegmentation(canvas, track) { | |
| const parent = canvas.parentElement; | |
| const rect = parent.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| const w = rect.width; | |
| const h = rect.height; | |
| // Dark background | |
| ctx.fillStyle = '#0a0f1a'; | |
| ctx.fillRect(0, 0, w, h); | |
| // Grid | |
| ctx.strokeStyle = 'rgba(255,255,255,0.03)'; | |
| ctx.lineWidth = 0.5; | |
| for (let x = 0; x < w; x += 20) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } | |
| for (let y = 0; y < h; y += 20) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } | |
| // Draw mask silhouette based on type | |
| const cx = w * 0.5; | |
| const cy = h * 0.5; | |
| ctx.beginPath(); | |
| if (track.type === 'person') { | |
| // Organic blob for person | |
| const s = Math.min(w, h) * 0.3; | |
| ctx.moveTo(cx, cy - s); | |
| ctx.bezierCurveTo(cx + s * 0.6, cy - s, cx + s * 0.7, cy - s * 0.3, cx + s * 0.5, cy); | |
| ctx.bezierCurveTo(cx + s * 0.7, cy + s * 0.4, cx + s * 0.4, cy + s, cx, cy + s); | |
| ctx.bezierCurveTo(cx - s * 0.4, cy + s, cx - s * 0.7, cy + s * 0.4, cx - s * 0.5, cy); | |
| ctx.bezierCurveTo(cx - s * 0.7, cy - s * 0.3, cx - s * 0.6, cy - s, cx, cy - s); | |
| } else if (track.type === 'vehicle') { | |
| // Angular shape for vehicle | |
| const sw = Math.min(w, h) * 0.4; | |
| const sh = Math.min(w, h) * 0.25; | |
| ctx.moveTo(cx - sw, cy - sh * 0.3); | |
| ctx.lineTo(cx - sw * 0.6, cy - sh); | |
| ctx.lineTo(cx + sw * 0.6, cy - sh); | |
| ctx.lineTo(cx + sw, cy - sh * 0.3); | |
| ctx.lineTo(cx + sw, cy + sh); | |
| ctx.lineTo(cx - sw, cy + sh); | |
| } else if (track.type === 'drone') { | |
| // Triangular / cross shape for drone | |
| const s = Math.min(w, h) * 0.25; | |
| ctx.moveTo(cx, cy - s); | |
| ctx.lineTo(cx + s * 1.2, cy + s * 0.3); | |
| ctx.lineTo(cx + s * 0.3, cy + s * 0.1); | |
| ctx.lineTo(cx, cy + s); | |
| ctx.lineTo(cx - s * 0.3, cy + s * 0.1); | |
| ctx.lineTo(cx - s * 1.2, cy + s * 0.3); | |
| } else { | |
| // Generic rectangle for stationary | |
| const sw = Math.min(w, h) * 0.35; | |
| const sh = Math.min(w, h) * 0.25; | |
| ctx.rect(cx - sw, cy - sh, sw * 2, sh * 2); | |
| } | |
| ctx.closePath(); | |
| // Semi-transparent fill | |
| ctx.fillStyle = 'rgba(59,130,246,0.3)'; | |
| ctx.fill(); | |
| // Glowing contour | |
| ctx.shadowColor = '#3b82f6'; | |
| ctx.shadowBlur = 8; | |
| ctx.strokeStyle = '#3b82f6'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| /* ── Edge Detection Renderer ──────────────────────────────────── */ | |
| function renderEdgeDetection(canvas, track) { | |
| const parent = canvas.parentElement; | |
| const rect = parent.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| const w = rect.width; | |
| const h = rect.height; | |
| // Black background | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0, 0, w, h); | |
| const cx = w * 0.5; | |
| const cy = h * 0.5; | |
| function drawSilhouette(lineWidth, opacity) { | |
| ctx.beginPath(); | |
| if (track.type === 'person') { | |
| const s = Math.min(w, h) * 0.3; | |
| ctx.moveTo(cx, cy - s); | |
| ctx.bezierCurveTo(cx + s * 0.6, cy - s, cx + s * 0.7, cy - s * 0.3, cx + s * 0.5, cy); | |
| ctx.bezierCurveTo(cx + s * 0.7, cy + s * 0.4, cx + s * 0.4, cy + s, cx, cy + s); | |
| ctx.bezierCurveTo(cx - s * 0.4, cy + s, cx - s * 0.7, cy + s * 0.4, cx - s * 0.5, cy); | |
| ctx.bezierCurveTo(cx - s * 0.7, cy - s * 0.3, cx - s * 0.6, cy - s, cx, cy - s); | |
| } else if (track.type === 'vehicle') { | |
| const sw = Math.min(w, h) * 0.4; | |
| const sh = Math.min(w, h) * 0.25; | |
| ctx.moveTo(cx - sw, cy - sh * 0.3); | |
| ctx.lineTo(cx - sw * 0.6, cy - sh); | |
| ctx.lineTo(cx + sw * 0.6, cy - sh); | |
| ctx.lineTo(cx + sw, cy - sh * 0.3); | |
| ctx.lineTo(cx + sw, cy + sh); | |
| ctx.lineTo(cx - sw, cy + sh); | |
| } else if (track.type === 'drone') { | |
| const s = Math.min(w, h) * 0.25; | |
| ctx.moveTo(cx, cy - s); | |
| ctx.lineTo(cx + s * 1.2, cy + s * 0.3); | |
| ctx.lineTo(cx + s * 0.3, cy + s * 0.1); | |
| ctx.lineTo(cx, cy + s); | |
| ctx.lineTo(cx - s * 0.3, cy + s * 0.1); | |
| ctx.lineTo(cx - s * 1.2, cy + s * 0.3); | |
| } else { | |
| const sw = Math.min(w, h) * 0.35; | |
| const sh = Math.min(w, h) * 0.25; | |
| ctx.rect(cx - sw, cy - sh, sw * 2, sh * 2); | |
| } | |
| ctx.closePath(); | |
| ctx.strokeStyle = `rgba(255,255,255,${opacity})`; | |
| ctx.lineWidth = lineWidth; | |
| ctx.stroke(); | |
| } | |
| // Outer edge — strong | |
| drawSilhouette(2.5, 0.9); | |
| // Inner detail lines — weaker | |
| drawSilhouette(1, 0.35); | |
| // Secondary inner detail: offset slightly inward | |
| ctx.save(); | |
| ctx.translate(0, 0); | |
| ctx.scale(0.88, 0.88); | |
| ctx.translate(w * 0.5 * (1 - 1/0.88), h * 0.5 * (1 - 1/0.88)); | |
| drawSilhouette(0.8, 0.2); | |
| ctx.restore(); | |
| } | |
| /* ── Depth Heatmap Renderer ───────────────────────────────────── */ | |
| function renderDepthHeatmap(canvas, track) { | |
| const parent = canvas.parentElement; | |
| const rect = parent.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| const w = rect.width; | |
| const h = rect.height; | |
| // Viridis-style gradient background | |
| const bgGrad = ctx.createLinearGradient(0, 0, w, h); | |
| bgGrad.addColorStop(0, '#440154'); // deep purple | |
| bgGrad.addColorStop(0.25, '#31688e'); // blue | |
| bgGrad.addColorStop(0.5, '#35b779'); // green | |
| bgGrad.addColorStop(0.75, '#90d743'); // lime | |
| bgGrad.addColorStop(1, '#fde725'); // yellow | |
| ctx.fillStyle = bgGrad; | |
| ctx.fillRect(0, 0, w, h); | |
| // Warm "closer" region at object center | |
| const cx = w * 0.5; | |
| const cy = h * 0.5; | |
| const maxR = Math.min(w, h) * 0.45; | |
| const radGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR); | |
| radGrad.addColorStop(0, 'rgba(253,231,37,0.7)'); // warm yellow | |
| radGrad.addColorStop(0.3, 'rgba(253,231,37,0.4)'); | |
| radGrad.addColorStop(0.6, 'rgba(144,215,67,0.2)'); | |
| radGrad.addColorStop(1, 'rgba(68,1,84,0)'); // transparent at edges | |
| ctx.fillStyle = radGrad; | |
| ctx.fillRect(0, 0, w, h); | |
| // Second warm spot offset | |
| const ox = cx + w * 0.15; | |
| const oy = cy - h * 0.1; | |
| const rad2 = ctx.createRadialGradient(ox, oy, 0, ox, oy, maxR * 0.5); | |
| rad2.addColorStop(0, 'rgba(253,231,37,0.35)'); | |
| rad2.addColorStop(1, 'rgba(68,1,84,0)'); | |
| ctx.fillStyle = rad2; | |
| ctx.fillRect(0, 0, w, h); | |
| } | |
| /* ── 3D Mesh Viewer ───────────────────────────────────────────── */ | |
| function init3DViewer(container, track) { | |
| if (!window.THREE) return; | |
| const w = container.clientWidth || 150; | |
| const h = container.clientHeight || 120; | |
| threeScene = new THREE.Scene(); | |
| threeScene.background = new THREE.Color('#0a0f1a'); | |
| threeCamera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100); | |
| threeCamera.position.set(3, 2, 3); | |
| threeCamera.lookAt(0, 0, 0); | |
| threeRenderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); | |
| threeRenderer.setSize(w, h); | |
| threeRenderer.setPixelRatio(window.devicePixelRatio || 1); | |
| container.appendChild(threeRenderer.domElement); | |
| // Geometry by type | |
| let geometry; | |
| if (track.type === 'vehicle') { | |
| geometry = new THREE.BoxGeometry(1.6, 0.8, 1); | |
| } else if (track.type === 'person') { | |
| geometry = new THREE.CylinderGeometry(0.4, 0.4, 1.6, 12); | |
| } else if (track.type === 'drone') { | |
| geometry = new THREE.ConeGeometry(0.6, 1.2, 6); | |
| } else { | |
| geometry = new THREE.BoxGeometry(1.2, 0.6, 1.2); | |
| } | |
| const color = new THREE.Color(track.color); | |
| const material = new THREE.MeshBasicMaterial({ | |
| color: color, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.7, | |
| }); | |
| threeMesh = new THREE.Mesh(geometry, material); | |
| threeScene.add(threeMesh); | |
| // Lighting | |
| const ambient = new THREE.AmbientLight(0x404040); | |
| threeScene.add(ambient); | |
| const point = new THREE.PointLight(0xffffff, 0.8, 50); | |
| point.position.set(5, 5, 5); | |
| threeScene.add(point); | |
| // Auto-rotation animation | |
| let angle = 0; | |
| function animate() { | |
| threeAnimId = requestAnimationFrame(animate); | |
| angle += 0.01; | |
| threeCamera.position.x = 3 * Math.cos(angle); | |
| threeCamera.position.z = 3 * Math.sin(angle); | |
| threeCamera.lookAt(0, 0, 0); | |
| threeMesh.rotation.y += 0.005; | |
| threeRenderer.render(threeScene, threeCamera); | |
| } | |
| animate(); | |
| } | |
| function destroy3DViewer() { | |
| if (threeAnimId) { | |
| cancelAnimationFrame(threeAnimId); | |
| threeAnimId = null; | |
| } | |
| if (threeRenderer) { | |
| threeRenderer.dispose(); | |
| if (threeRenderer.domElement && threeRenderer.domElement.parentElement) { | |
| threeRenderer.domElement.parentElement.removeChild(threeRenderer.domElement); | |
| } | |
| threeRenderer = null; | |
| } | |
| if (threeMesh) { | |
| if (threeMesh.geometry) threeMesh.geometry.dispose(); | |
| if (threeMesh.material) threeMesh.material.dispose(); | |
| threeMesh = null; | |
| } | |
| threeScene = null; | |
| threeCamera = null; | |
| } | |
| /* ================================================================ | |
| * TASK 9: Mission Report | |
| * ================================================================ */ | |
| function renderMissionReport() { | |
| const panel = document.getElementById('tracksPanel'); | |
| panel.innerHTML = ''; | |
| const report = document.createElement('div'); | |
| report.className = 'mission-report'; | |
| const highPriorityEvents = MOCK_EVENTS.filter(e => e.priority === 'high'); | |
| const personCount = MOCK_TRACKS.filter(t => t.type === 'person').length; | |
| const vehicleCount = MOCK_TRACKS.filter(t => t.type === 'vehicle').length; | |
| const droneCount = MOCK_TRACKS.filter(t => t.type === 'drone').length; | |
| const stationaryCount = MOCK_TRACKS.filter(t => t.type === 'stationary').length; | |
| report.innerHTML = ` | |
| <div class="report-header">MISSION REPORT — ISR-2026-03-20</div> | |
| <div class="report-section"> | |
| <div class="report-section-title">SUMMARY STATISTICS</div> | |
| <div class="report-stat"><span class="report-stat-label">Duration</span><span class="report-stat-value">61.6s (1847 frames)</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Frame Rate</span><span class="report-stat-value">30 fps</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Total Tracks</span><span class="report-stat-value">${MOCK_TRACKS.length}</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Persons</span><span class="report-stat-value">${personCount}</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Vehicles</span><span class="report-stat-value">${vehicleCount}</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Drones</span><span class="report-stat-value" style="color: var(--danger);">${droneCount}</span></div> | |
| <div class="report-stat"><span class="report-stat-label">Stationary</span><span class="report-stat-value">${stationaryCount}</span></div> | |
| <div class="report-stat"><span class="report-stat-label">High-Priority Alerts</span><span class="report-stat-value" style="color: var(--danger);">${highPriorityEvents.length}</span></div> | |
| </div> | |
| <div class="report-section"> | |
| <div class="report-section-title">THREAT ASSESSMENT</div> | |
| ${highPriorityEvents.map(evt => ` | |
| <div class="report-threat"> | |
| <span class="report-threat-time">${evt.time}</span> | |
| <span class="report-threat-label">${evt.label} — ${evt.description}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <div class="report-section"> | |
| <div class="report-section-title">TACTICAL RECOMMENDATIONS</div> | |
| <div class="report-rec">Deploy counter-UAS measures for aerial contacts D-001 and D-002. Erratic flight patterns suggest reconnaissance activity.</div> | |
| <div class="report-rec">Establish speed enforcement at western approach. Vehicle Delta exceeded restricted zone speed limit by 3.5 kph.</div> | |
| <div class="report-rec">Monitor crowd formation at grid 4-C. Five persons converging may indicate planned gathering.</div> | |
| <div class="report-rec">Reinforce perimeter surveillance at sector where Drone Bravo approached restricted airspace.</div> | |
| <div class="report-rec">Increase sensor coverage at FOV edges — two tracks lost due to exit from field of view.</div> | |
| </div> | |
| `; | |
| // Back to tracks button | |
| const backBtn = document.createElement('button'); | |
| backBtn.className = 'inspect-back-btn'; | |
| backBtn.style.marginTop = '8px'; | |
| backBtn.style.width = '100%'; | |
| backBtn.textContent = '\u2190 Back to Tracks'; | |
| backBtn.addEventListener('click', () => { | |
| renderTrackListFull(MOCK_TRACKS, STATE.trackFilter); | |
| }); | |
| panel.appendChild(report); | |
| panel.appendChild(backBtn); | |
| } | |
| /* ================================================================ | |
| * TASK 9: Command Bar Actions | |
| * ================================================================ */ | |
| function executeAction(action) { | |
| if (action === 'filter-drones') { | |
| STATE.trackFilter = 'drone'; | |
| renderTrackListFull(MOCK_TRACKS, 'drone'); | |
| // Highlight drone events on timeline | |
| document.querySelectorAll('.event-marker').forEach(m => { | |
| m.style.opacity = '0.3'; | |
| }); | |
| MOCK_EVENTS.forEach((evt, i) => { | |
| if (evt.type === 'alert' || evt.label.toLowerCase().includes('drone')) { | |
| const markers = document.querySelectorAll('.event-marker'); | |
| if (markers[i]) { | |
| markers[i].style.opacity = '1'; | |
| markers[i].style.transform = 'translate(-50%, -50%) scale(1.4)'; | |
| } | |
| } | |
| }); | |
| } else if (action === 'show-report') { | |
| renderMissionReport(); | |
| switchDrawerTab('tracks'); | |
| } | |
| } | |
| /* ================================================================ | |
| * TASK 8/9: Play/Pause + Keyboard Controls | |
| * ================================================================ */ | |
| function togglePlayPause() { | |
| if (STATE.current === 'ready') return; | |
| if (STATE.current === 'processing') return; | |
| STATE.isPlaying = !STATE.isPlaying; | |
| lastFrameTime = 0; | |
| const btn = document.getElementById('playPauseBtn'); | |
| btn.innerHTML = STATE.isPlaying ? '▮▮' : '▶'; | |
| if (STATE.isPlaying && STATE.playheadFrame >= STATE.totalFrames) { | |
| STATE.playheadFrame = 0; | |
| } | |
| // Ensure we're in a playable state | |
| if (STATE.isPlaying && STATE.current === 'analysis') { | |
| STATE.current = 'playing'; | |
| document.body.setAttribute('data-state', 'analysis'); // keep analysis CSS | |
| } | |
| if (!STATE.isPlaying && STATE.current === 'playing') { | |
| STATE.current = 'analysis'; | |
| document.body.setAttribute('data-state', 'analysis'); | |
| } | |
| } | |
| function updateTimeDisplay() { | |
| const totalSeconds = STATE.playheadFrame / STATE.fps; | |
| const min = Math.floor(totalSeconds / 60); | |
| const sec = Math.floor(totalSeconds % 60); | |
| const el = document.getElementById('timeStart'); | |
| if (el) el.textContent = String(min).padStart(2, '0') + ':' + String(sec).padStart(2, '0'); | |
| } | |
| function seekFrames(delta) { | |
| if (STATE.current === 'ready' || STATE.current === 'processing') return; | |
| STATE.playheadFrame = Math.max(0, Math.min(STATE.totalFrames, STATE.playheadFrame + delta)); | |
| updatePlayheadPosition(); | |
| updateFrameCounter(); | |
| updateTimeDisplay(); | |
| } | |
| function handleKeyboardControls(e) { | |
| // Don't capture when typing in command bar or other inputs | |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; | |
| // Escape priority chain | |
| if (e.code === 'Escape') { | |
| e.preventDefault(); | |
| // 1. Expanded quadrant -> collapse | |
| if (STATE.expandedQuadrant) { | |
| const panel = document.getElementById('inspectPanel'); | |
| if (panel) { | |
| panel.querySelectorAll('.quadrant.expanded').forEach(eq => eq.classList.remove('expanded')); | |
| panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = ''); | |
| } | |
| STATE.expandedQuadrant = null; | |
| return; | |
| } | |
| // 2. Event log open -> close | |
| if (STATE.eventLogOpen) { | |
| toggleEventLog(); | |
| return; | |
| } | |
| // 3. In inspect state -> return to analysis | |
| if (STATE.current === 'inspect') { | |
| exitInspectState(); | |
| return; | |
| } | |
| return; | |
| } | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| togglePlayPause(); | |
| } else if (e.code === 'ArrowRight') { | |
| e.preventDefault(); | |
| seekFrames(STATE.fps * 5); // 5 seconds forward | |
| } else if (e.code === 'ArrowLeft') { | |
| e.preventDefault(); | |
| seekFrames(-STATE.fps * 5); // 5 seconds backward | |
| } else if (e.code === 'Period') { | |
| e.preventDefault(); | |
| seekFrames(1); // next frame | |
| } else if (e.code === 'Comma') { | |
| e.preventDefault(); | |
| seekFrames(-1); // previous frame | |
| } | |
| } | |
| /* ================================================================ | |
| * TASK 9: Canvas Hit-Testing + Hover Sync | |
| * ================================================================ */ | |
| function hitTestBoxes(clientX, clientY) { | |
| const container = document.getElementById('videoFeed'); | |
| const rect = container.getBoundingClientRect(); | |
| const relX = clientX - rect.left; | |
| const relY = clientY - rect.top; | |
| const w = rect.width; | |
| const h = rect.height; | |
| const tracks = getTracksAtFrame(STATE.playheadFrame); | |
| // Check in reverse order (top-most drawn last) | |
| for (let i = tracks.length - 1; i >= 0; i--) { | |
| const t = tracks[i]; | |
| const bx = (t.bbox.x / 100) * w; | |
| const by = (t.bbox.y / 100) * h; | |
| const bw = (t.bbox.w / 100) * w; | |
| const bh = (t.bbox.h / 100) * h; | |
| if (relX >= bx && relX <= bx + bw && relY >= by && relY <= by + bh) { | |
| return t; | |
| } | |
| } | |
| return null; | |
| } | |
| function handleCanvasMouseMove(e) { | |
| if (STATE.current === 'ready' || STATE.current === 'processing') return; | |
| const hit = hitTestBoxes(e.clientX, e.clientY); | |
| const overlay = document.getElementById('overlayCanvas'); | |
| if (hit) { | |
| overlay.style.cursor = 'pointer'; | |
| highlightedTrackId = hit.id; | |
| // Highlight matching card | |
| document.querySelectorAll('.track-card').forEach(c => { | |
| c.classList.toggle('highlighted', c.dataset.trackId === hit.id); | |
| }); | |
| } else { | |
| overlay.style.cursor = 'default'; | |
| highlightedTrackId = null; | |
| document.querySelectorAll('.track-card.highlighted').forEach(c => c.classList.remove('highlighted')); | |
| } | |
| } | |
| function handleCanvasClick(e) { | |
| if (STATE.current === 'ready' || STATE.current === 'processing') return; | |
| const hit = hitTestBoxes(e.clientX, e.clientY); | |
| if (hit) { | |
| enterInspectState(hit.id); | |
| } | |
| } | |
| /* ================================================================ | |
| * TASK 5: Drawer — Tabs + Track List | |
| * ================================================================ */ | |
| function switchDrawerTab(tabName) { | |
| STATE.drawerTab = tabName; | |
| const tabs = document.querySelectorAll('.drawer-tab'); | |
| tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); | |
| // Hide all panels first | |
| document.getElementById('configPanel').classList.add('hidden'); | |
| document.getElementById('tracksPanel').classList.add('hidden'); | |
| document.getElementById('inspectPanel').classList.add('hidden'); | |
| document.getElementById('metricsPanel').classList.add('hidden'); | |
| if (tabName === 'tracks' || tabName === 'config') { | |
| // Config panel only shows in ready state | |
| if (STATE.current === 'ready') { | |
| document.getElementById('configPanel').classList.remove('hidden'); | |
| } | |
| document.getElementById('tracksPanel').classList.remove('hidden'); | |
| } else if (tabName === 'inspect') { | |
| document.getElementById('inspectPanel').classList.remove('hidden'); | |
| } else if (tabName === 'metrics') { | |
| document.getElementById('metricsPanel').classList.remove('hidden'); | |
| } | |
| } | |
| function renderTrackList(tracks, filter) { | |
| const panel = document.getElementById('tracksPanel'); | |
| panel.innerHTML = ''; | |
| const filtered = filter ? tracks.filter(t => t.type === filter) : tracks; | |
| if (filtered.length === 0) { | |
| panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No tracks match the current filter.</div>'; | |
| return; | |
| } | |
| for (const t of filtered) { | |
| const card = document.createElement('div'); | |
| card.className = 'track-card' + (STATE.selectedTrackId === t.id ? ' selected' : ''); | |
| card.dataset.trackId = t.id; | |
| card.innerHTML = ` | |
| <div class="track-dot" style="background: ${t.color}; box-shadow: 0 0 4px ${t.color};"></div> | |
| <div class="track-info"> | |
| <div class="track-label">${t.label}</div> | |
| <div class="track-meta"> | |
| <span>${t.speed.toFixed(1)} kph</span> | |
| <span>${t.depth.toFixed(1)}m</span> | |
| <span>${t.id}</span> | |
| </div> | |
| </div> | |
| <span class="track-conf">${t.confidence.toFixed(2)}</span> | |
| `; | |
| card.addEventListener('click', () => { | |
| STATE.selectedTrackId = STATE.selectedTrackId === t.id ? null : t.id; | |
| renderTrackList(tracks, filter); | |
| }); | |
| panel.appendChild(card); | |
| } | |
| } | |
| /* ================================================================ | |
| * TASK 6: Timeline — Waveform, Events, Playhead | |
| * ================================================================ */ | |
| function renderWaveform(canvas, densityData) { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| const w = rect.width; | |
| const h = rect.height; | |
| const barW = w / densityData.length; | |
| ctx.clearRect(0, 0, w, h); | |
| for (let i = 0; i < densityData.length; i++) { | |
| const val = densityData[i]; | |
| const barH = val * h * 0.85; | |
| const x = i * barW; | |
| const y = h - barH; | |
| const color = getColorForType(DENSITY_TYPES[i]); | |
| ctx.fillStyle = color + '88'; | |
| ctx.fillRect(x, y, Math.max(barW - 1, 1), barH); | |
| } | |
| } | |
| function renderEventMarkers(container, events) { | |
| container.innerHTML = ''; | |
| const EVENT_TYPE_COLORS = { | |
| system: 'var(--text-tertiary)', | |
| detection: 'var(--accent)', | |
| tracking: 'var(--success)', | |
| alert: 'var(--danger)', | |
| }; | |
| for (const evt of events) { | |
| const pct = (evt.frame / STATE.totalFrames) * 100; | |
| const marker = document.createElement('div'); | |
| marker.className = 'event-marker'; | |
| marker.style.left = pct + '%'; | |
| marker.style.background = EVENT_TYPE_COLORS[evt.type] || 'var(--text-tertiary)'; | |
| if (evt.priority === 'high') { | |
| marker.style.boxShadow = `0 0 4px ${EVENT_TYPE_COLORS[evt.type]}`; | |
| } | |
| marker.title = `${evt.time} — ${evt.label}`; | |
| marker.addEventListener('click', () => { | |
| STATE.playheadFrame = evt.frame; | |
| updatePlayheadPosition(); | |
| updateFrameCounter(); | |
| updateTimeDisplay(); | |
| }); | |
| container.appendChild(marker); | |
| } | |
| } | |
| function updatePlayheadPosition() { | |
| const playhead = document.getElementById('playhead'); | |
| if (!playhead) return; | |
| const pct = (STATE.playheadFrame / STATE.totalFrames) * 100; | |
| playhead.style.left = pct + '%'; | |
| } | |
| function initPlayheadDrag() { | |
| const wrap = document.querySelector('.timeline-waveform-wrap'); | |
| if (!wrap) return; | |
| let dragging = false; | |
| function setFrameFromX(clientX) { | |
| const rect = wrap.getBoundingClientRect(); | |
| let pct = (clientX - rect.left) / rect.width; | |
| pct = Math.max(0, Math.min(1, pct)); | |
| STATE.playheadFrame = Math.round(pct * STATE.totalFrames); | |
| updatePlayheadPosition(); | |
| updateFrameCounter(); | |
| } | |
| wrap.addEventListener('mousedown', (e) => { | |
| dragging = true; | |
| setFrameFromX(e.clientX); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (dragging) setFrameFromX(e.clientX); | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| dragging = false; | |
| }); | |
| } | |
| function renderTimelineLegend() { | |
| const legend = document.getElementById('timelineLegend'); | |
| if (!legend) return; | |
| const counts = { system: 0, detection: 0, tracking: 0, alert: 0 }; | |
| const colors = { | |
| system: 'var(--text-tertiary)', | |
| detection: 'var(--accent)', | |
| tracking: 'var(--success)', | |
| alert: 'var(--danger)', | |
| }; | |
| MOCK_EVENTS.forEach(e => { if (counts[e.type] !== undefined) counts[e.type]++; }); | |
| legend.innerHTML = Object.entries(counts).map(([type, count]) => | |
| `<span class="timeline-legend-item"><span class="dot" style="background: ${colors[type]};"></span> ${type} (${count})</span>` | |
| ).join(''); | |
| } | |
| function toggleEventLog() { | |
| STATE.eventLogOpen = !STATE.eventLogOpen; | |
| const log = document.getElementById('eventLog'); | |
| const toggle = document.getElementById('eventLogToggle'); | |
| if (STATE.eventLogOpen) { | |
| log.classList.remove('hidden'); | |
| toggle.innerHTML = 'EVENT LOG ▲'; | |
| renderEventLog(); | |
| } else { | |
| log.classList.add('hidden'); | |
| toggle.innerHTML = 'EVENT LOG ▼'; | |
| } | |
| } | |
| function renderEventLog() { | |
| const log = document.getElementById('eventLog'); | |
| if (!log) return; | |
| const EVENT_TYPE_COLORS = { | |
| system: { bg: 'rgba(255,255,255,0.05)', color: 'var(--text-tertiary)' }, | |
| detection: { bg: 'rgba(59,130,246,0.12)', color: 'var(--accent-light)' }, | |
| tracking: { bg: 'rgba(34,197,94,0.12)', color: 'var(--success)' }, | |
| alert: { bg: 'rgba(239,68,68,0.12)', color: 'var(--danger)' }, | |
| }; | |
| log.innerHTML = MOCK_EVENTS.map(evt => { | |
| const tc = EVENT_TYPE_COLORS[evt.type] || EVENT_TYPE_COLORS.system; | |
| return ` | |
| <div class="event-log-entry" data-frame="${evt.frame}"> | |
| <span class="event-log-time">${evt.time}</span> | |
| <span class="event-log-type" style="background: ${tc.bg}; color: ${tc.color};">${evt.type}</span> | |
| <span class="event-log-label">${evt.label}</span> | |
| <span class="event-log-desc">${evt.description}</span> | |
| </div>`; | |
| }).join(''); | |
| log.querySelectorAll('.event-log-entry').forEach(entry => { | |
| entry.addEventListener('click', () => { | |
| STATE.playheadFrame = parseInt(entry.dataset.frame, 10); | |
| updatePlayheadPosition(); | |
| updateFrameCounter(); | |
| updateTimeDisplay(); | |
| }); | |
| }); | |
| } | |
| /* ================================================================ | |
| * TASK 7: Command Bar + Toast System | |
| * ================================================================ */ | |
| function handleCommand(text) { | |
| if (!text.trim()) return; | |
| askAI(text).then(response => { | |
| showToast(response.text); | |
| if (response.action) { | |
| executeAction(response.action); | |
| } | |
| }); | |
| } | |
| function showToast(text, duration = 8000) { | |
| const container = document.getElementById('toastContainer'); | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast'; | |
| const msg = document.createElement('span'); | |
| msg.style.flex = '1'; | |
| msg.textContent = text; | |
| const close = document.createElement('button'); | |
| close.className = 'toast-close'; | |
| close.innerHTML = '×'; | |
| close.addEventListener('click', () => dismissToast(toast)); | |
| toast.appendChild(msg); | |
| toast.appendChild(close); | |
| container.appendChild(toast); | |
| setTimeout(() => dismissToast(toast), duration); | |
| } | |
| function dismissToast(toast) { | |
| if (!toast || !toast.parentElement) return; | |
| toast.classList.add('toast-out'); | |
| toast.addEventListener('animationend', () => { | |
| if (toast.parentElement) toast.parentElement.removeChild(toast); | |
| }); | |
| } | |
| /* ================================================================ | |
| * TASK 12: Keyboard Shortcuts Hint | |
| * ================================================================ */ | |
| function showShortcutsHint() { | |
| // Remove existing hint if any | |
| const existing = document.getElementById('shortcutsHint'); | |
| if (existing) existing.remove(); | |
| const commandBar = document.getElementById('commandBar'); | |
| const hint = document.createElement('div'); | |
| hint.id = 'shortcutsHint'; | |
| hint.innerHTML = ` | |
| <span class="shortcut-item"><kbd>Space</kbd> Play/Pause</span> | |
| <span class="shortcut-item"><kbd>←</kbd><kbd>→</kbd> Seek 5s</span> | |
| <span class="shortcut-item"><kbd>,</kbd><kbd>.</kbd> Frame step</span> | |
| <span class="shortcut-item"><kbd>Esc</kbd> Back/Close</span> | |
| `; | |
| commandBar.style.position = 'relative'; | |
| commandBar.appendChild(hint); | |
| // Remove after animation completes (3s) | |
| setTimeout(() => { | |
| if (hint.parentElement) hint.remove(); | |
| }, 3100); | |
| } | |
| /* ================================================================ | |
| * TASK 12: Event Marker Tooltips | |
| * ================================================================ */ | |
| function wireEventMarkerTooltips() { | |
| document.querySelectorAll('.event-marker').forEach((marker, idx) => { | |
| const evt = MOCK_EVENTS[idx]; | |
| if (!evt) return; | |
| marker.addEventListener('mouseenter', () => { | |
| // Remove old tooltips | |
| document.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove()); | |
| const tooltip = document.createElement('div'); | |
| tooltip.className = 'event-marker-tooltip'; | |
| tooltip.textContent = `${evt.time} — ${evt.label}: ${evt.description}`; | |
| marker.style.position = 'absolute'; // ensure positioning context | |
| marker.appendChild(tooltip); | |
| }); | |
| marker.addEventListener('mouseleave', () => { | |
| marker.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove()); | |
| }); | |
| }); | |
| } | |
| /* ================================================================ | |
| * Initialization | |
| * ================================================================ */ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Task 8: Set initial state | |
| setState('ready'); | |
| // Task 3: Clock | |
| updateClock(); | |
| setInterval(updateClock, 1000); | |
| // Task 4: Canvases + render loop | |
| initCanvases(); | |
| window.addEventListener('resize', resizeCanvases); | |
| requestAnimationFrame(mainRenderLoop); | |
| // Task 8: Start button wired to processing simulation | |
| document.getElementById('startBtn').addEventListener('click', () => { | |
| startProcessingSimulation(); | |
| }); | |
| // Play/Pause | |
| document.getElementById('playPauseBtn').addEventListener('click', togglePlayPause); | |
| // Speed | |
| document.getElementById('speedSelect').addEventListener('change', (e) => { | |
| playbackSpeed = parseFloat(e.target.value); | |
| }); | |
| // Task 5: Drawer tabs | |
| document.querySelectorAll('.drawer-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| // Respect state-driven tab restrictions via CSS pointer-events | |
| switchDrawerTab(tab.dataset.tab); | |
| }); | |
| }); | |
| // Mode toggle | |
| document.querySelectorAll('.config-toggle-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.config-toggle-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| }); | |
| }); | |
| // Init drawer to tracks tab | |
| switchDrawerTab('tracks'); | |
| // Task 6: Timeline | |
| const waveformCanvas = document.getElementById('waveformCanvas'); | |
| if (waveformCanvas) { | |
| renderWaveform(waveformCanvas, MOCK_DENSITY); | |
| window.addEventListener('resize', () => renderWaveform(waveformCanvas, MOCK_DENSITY)); | |
| } | |
| renderEventMarkers(document.getElementById('eventMarkers'), MOCK_EVENTS); | |
| wireEventMarkerTooltips(); | |
| renderTimelineLegend(); | |
| initPlayheadDrag(); | |
| updatePlayheadPosition(); | |
| // Event log toggle | |
| document.getElementById('eventLogToggle').addEventListener('click', toggleEventLog); | |
| // Task 7: Command bar — wired with executeAction support | |
| const cmdInput = document.getElementById('commandInput'); | |
| cmdInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| const text = cmdInput.value.trim(); | |
| if (!text) return; | |
| cmdInput.value = ''; | |
| askAI(text).then(response => { | |
| showToast(response.text); | |
| if (response.action) { | |
| executeAction(response.action); | |
| } | |
| }); | |
| } | |
| }); | |
| // Cmd+K / Ctrl+K shortcut with keyboard hints | |
| document.addEventListener('keydown', (e) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
| e.preventDefault(); | |
| cmdInput.focus(); | |
| showShortcutsHint(); | |
| } | |
| }); | |
| // Task 9: Keyboard controls | |
| document.addEventListener('keydown', handleKeyboardControls); | |
| // Task 9: Canvas mouse interactions (hover + click) | |
| const overlayEl = document.getElementById('overlayCanvas'); | |
| overlayEl.addEventListener('mousemove', handleCanvasMouseMove); | |
| overlayEl.addEventListener('click', handleCanvasClick); | |
| console.log('[ISR Command Center] UI initialized. State:', STATE.current); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |