Zhen Ye Claude Opus 4.6 (1M context) commited on
Commit
0d1be5e
Β·
1 Parent(s): e410807

refactor(demo): split monolithic HTML into component files

Browse files

Split demo/index.html from 6890 lines into 176-line shell + 22 files:

CSS (9 files in demo/styles/):
variables, layout, components, video, timeline,
inspect, explain, state, animations

JS (13 files in demo/js/):
state, cache, helpers, api, render, state-machine,
analysis, explain, inspect, timeline, ui, real-backend, init

Uses window.ISR namespace for cross-module communication.
No logic changes β€” purely structural reorganization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

demo/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
demo/js/analysis.js ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 8: Analysis State Entry
5
+ * ================================================================ */
6
+
7
+ function enterAnalysisState() {
8
+ ISR.setState('analysis');
9
+
10
+ // Reset playhead to 0
11
+ ISR.STATE.playheadFrame = 0;
12
+ ISR.STATE.isPlaying = false;
13
+ ISR.lastFrameTime = 0;
14
+ ISR.updateFrameCounter();
15
+ ISR.updatePlayheadPosition();
16
+ ISR.updateTimeDisplay();
17
+ document.getElementById('playPauseBtn').innerHTML = '&#9654;';
18
+
19
+ // Render full waveform
20
+ const waveformCanvas = document.getElementById('waveformCanvas');
21
+ if (waveformCanvas) ISR.renderWaveform(waveformCanvas, ISR.MOCK_DENSITY);
22
+
23
+ // Show full track list with interaction wiring
24
+ renderTrackListFull(ISR.MOCK_TRACKS, ISR.STATE.trackFilter);
25
+
26
+ // Render metrics panel
27
+ renderMetricsPanel();
28
+
29
+ ISR.showToast('Processing complete. 20 tracks detected across 1847 frames. Use timeline to review.', 6000);
30
+ }
31
+
32
+ function appendTrackCard(track, container) {
33
+ const card = document.createElement('div');
34
+ card.className = 'track-card';
35
+ card.dataset.trackId = track.id;
36
+ card.style.opacity = '0';
37
+ card.style.transform = 'translateY(8px)';
38
+ card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
39
+ card.innerHTML = `
40
+ <div class="track-dot" style="background: ${track.color}; box-shadow: 0 0 4px ${track.color};"></div>
41
+ <div class="track-info">
42
+ <div class="track-label">${track.label}</div>
43
+ <div class="track-meta">
44
+ <span>${track.speed.toFixed(1)} kph</span>
45
+ <span>${track.depth.toFixed(1)}m</span>
46
+ <span>${track.id}</span>
47
+ </div>
48
+ </div>
49
+ <span class="track-conf">${track.confidence.toFixed(2)}</span>
50
+ `;
51
+ wireTrackCard(card, track);
52
+ container.appendChild(card);
53
+
54
+ // Trigger animation
55
+ requestAnimationFrame(() => {
56
+ card.style.opacity = '1';
57
+ card.style.transform = 'translateY(0)';
58
+ });
59
+ }
60
+
61
+ function wireTrackCard(card, track) {
62
+ card.addEventListener('click', () => {
63
+ if (ISR.STATE.current === 'analysis' || ISR.STATE.current === 'playing') {
64
+ ISR.enterInspectState(track.id);
65
+ } else if (ISR.STATE.current === 'inspect') {
66
+ ISR.enterInspectState(track.id);
67
+ } else {
68
+ ISR.STATE.selectedTrackId = ISR.STATE.selectedTrackId === track.id ? null : track.id;
69
+ document.querySelectorAll('.track-card').forEach(c => {
70
+ c.classList.toggle('selected', c.dataset.trackId === ISR.STATE.selectedTrackId);
71
+ });
72
+ }
73
+ });
74
+
75
+ // Hover sync: card -> canvas (use shared helper)
76
+ card.addEventListener('mouseenter', () => ISR._updateTrackHighlight(track.id));
77
+ card.addEventListener('mouseleave', () => ISR._updateTrackHighlight(null));
78
+ }
79
+
80
+ function renderTrackListFull(tracks, filter) {
81
+ const panel = document.getElementById('tracksPanel');
82
+ panel.innerHTML = '';
83
+
84
+ const filtered = filter ? tracks.filter(t => t.type === filter) : tracks;
85
+
86
+ if (filtered.length === 0) {
87
+ panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No tracks match the current filter.</div>';
88
+ return;
89
+ }
90
+
91
+ // If there's a filter, add a clear filter button
92
+ if (filter) {
93
+ const clearBtn = document.createElement('button');
94
+ clearBtn.className = 'inspect-back-btn';
95
+ clearBtn.style.marginBottom = '8px';
96
+ clearBtn.style.width = '100%';
97
+ clearBtn.textContent = 'CLEAR FILTER β€” SHOW ALL';
98
+ clearBtn.addEventListener('click', () => {
99
+ ISR.STATE.trackFilter = null;
100
+ renderTrackListFull(ISR.MOCK_TRACKS, null);
101
+ });
102
+ panel.appendChild(clearBtn);
103
+ }
104
+
105
+ for (const t of filtered) {
106
+ const card = document.createElement('div');
107
+ card.className = 'track-card' + (ISR.STATE.selectedTrackId === t.id ? ' selected' : '');
108
+ card.dataset.trackId = t.id;
109
+ card.innerHTML = `
110
+ <div class="track-dot" style="background: ${t.color}; box-shadow: 0 0 4px ${t.color};"></div>
111
+ <div class="track-info">
112
+ <div class="track-label">${t.label}</div>
113
+ <div class="track-meta">
114
+ <span>${t.speed.toFixed(1)} kph</span>
115
+ <span>${t.depth.toFixed(1)}m</span>
116
+ <span>${t.id}</span>
117
+ </div>
118
+ </div>
119
+ <span class="track-conf">${t.confidence.toFixed(2)}</span>
120
+ `;
121
+ wireTrackCard(card, t);
122
+ panel.appendChild(card);
123
+ }
124
+ }
125
+
126
+ function renderMetricsPanel() {
127
+ return; // Replaced by explainability graph
128
+ const panel = document.getElementById('metricsPanel');
129
+ if (!panel) return;
130
+
131
+ const personCount = ISR.MOCK_TRACKS.filter(t => t.type === 'person').length;
132
+ const vehicleCount = ISR.MOCK_TRACKS.filter(t => t.type === 'vehicle').length;
133
+ const droneCount = ISR.MOCK_TRACKS.filter(t => t.type === 'drone').length;
134
+ const stationaryCount = ISR.MOCK_TRACKS.filter(t => t.type === 'stationary').length;
135
+ const totalCount = ISR.MOCK_TRACKS.length;
136
+ const avgConf = (ISR.MOCK_TRACKS.reduce((s, t) => s + t.confidence, 0) / totalCount).toFixed(2);
137
+
138
+ const breakdown = [
139
+ { label: 'Person', count: personCount, color: ISR.TYPE_COLORS.person },
140
+ { label: 'Vehicle', count: vehicleCount, color: ISR.TYPE_COLORS.vehicle },
141
+ { label: 'Drone', count: droneCount, color: ISR.TYPE_COLORS.drone },
142
+ { label: 'Stationary', count: stationaryCount, color: ISR.TYPE_COLORS.stationary },
143
+ ];
144
+ const maxCount = Math.max(...breakdown.map(b => b.count));
145
+
146
+ panel.innerHTML = `
147
+ <div class="metric-hero">
148
+ <div class="metric-big-number">${totalCount}</div>
149
+ <div class="label">TOTAL DETECTIONS</div>
150
+ </div>
151
+ <div class="metric-breakdown">
152
+ ${breakdown.map(b => `
153
+ <div class="metric-bar-row">
154
+ <span class="metric-bar-label">${b.label}</span>
155
+ <div class="metric-bar-track">
156
+ <div class="metric-bar-fill" style="width: ${maxCount > 0 ? (b.count / maxCount) * 100 : 0}%; background: ${b.color};"></div>
157
+ </div>
158
+ <span class="metric-bar-count" style="color: ${b.color};">${b.count}</span>
159
+ </div>
160
+ `).join('')}
161
+ </div>
162
+ <div class="metric-stats">
163
+ <div class="metric-stat-row"><span class="metric-stat-label">AVG CONFIDENCE</span><span class="metric-stat-value">${avgConf}</span></div>
164
+ <div class="metric-stat-row"><span class="metric-stat-label">PROCESSING FPS</span><span class="metric-stat-value">48</span></div>
165
+ <div class="metric-stat-row"><span class="metric-stat-label">GPU UTILIZATION</span><span class="metric-stat-value">94%</span></div>
166
+ <div class="metric-stat-row"><span class="metric-stat-label">TOTAL FRAMES</span><span class="metric-stat-value">${ISR.STATE.totalFrames}</span></div>
167
+ <div class="metric-stat-row"><span class="metric-stat-label">PROCESS TIME</span><span class="metric-stat-value">38.5s</span></div>
168
+ </div>
169
+ `;
170
+ }
171
+
172
+ /* ================================================================
173
+ * TASK 9: Mission Report
174
+ * ================================================================ */
175
+
176
+ function renderMissionReport() {
177
+ const panel = document.getElementById('tracksPanel');
178
+ panel.innerHTML = '';
179
+
180
+ const report = document.createElement('div');
181
+ report.className = 'mission-report';
182
+
183
+ const highPriorityEvents = ISR.MOCK_EVENTS.filter(e => e.priority === 'high');
184
+ const personCount = ISR.MOCK_TRACKS.filter(t => t.type === 'person').length;
185
+ const vehicleCount = ISR.MOCK_TRACKS.filter(t => t.type === 'vehicle').length;
186
+ const droneCount = ISR.MOCK_TRACKS.filter(t => t.type === 'drone').length;
187
+ const stationaryCount = ISR.MOCK_TRACKS.filter(t => t.type === 'stationary').length;
188
+
189
+ report.innerHTML = `
190
+ <div class="report-header">MISSION REPORT β€” ISR-2026-03-20</div>
191
+
192
+ <div class="report-section">
193
+ <div class="report-section-title">SUMMARY STATISTICS</div>
194
+ <div class="report-stat"><span class="report-stat-label">Duration</span><span class="report-stat-value">61.6s (1847 frames)</span></div>
195
+ <div class="report-stat"><span class="report-stat-label">Frame Rate</span><span class="report-stat-value">30 fps</span></div>
196
+ <div class="report-stat"><span class="report-stat-label">Total Tracks</span><span class="report-stat-value">${ISR.MOCK_TRACKS.length}</span></div>
197
+ <div class="report-stat"><span class="report-stat-label">Persons</span><span class="report-stat-value">${personCount}</span></div>
198
+ <div class="report-stat"><span class="report-stat-label">Vehicles</span><span class="report-stat-value">${vehicleCount}</span></div>
199
+ <div class="report-stat"><span class="report-stat-label">Drones</span><span class="report-stat-value" style="color: var(--danger);">${droneCount}</span></div>
200
+ <div class="report-stat"><span class="report-stat-label">Stationary</span><span class="report-stat-value">${stationaryCount}</span></div>
201
+ <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>
202
+ </div>
203
+
204
+ <div class="report-section">
205
+ <div class="report-section-title">THREAT ASSESSMENT</div>
206
+ ${highPriorityEvents.map(evt => `
207
+ <div class="report-threat">
208
+ <span class="report-threat-time">${evt.time}</span>
209
+ <span class="report-threat-label">${evt.label} β€” ${evt.description}</span>
210
+ </div>
211
+ `).join('')}
212
+ </div>
213
+
214
+ <div class="report-section">
215
+ <div class="report-section-title">TACTICAL RECOMMENDATIONS</div>
216
+ <div class="report-rec">Deploy counter-UAS measures for aerial contacts D-001 and D-002. Erratic flight patterns suggest reconnaissance activity.</div>
217
+ <div class="report-rec">Establish speed enforcement at western approach. Vehicle Delta exceeded restricted zone speed limit by 3.5 kph.</div>
218
+ <div class="report-rec">Monitor crowd formation at grid 4-C. Five persons converging may indicate planned gathering.</div>
219
+ <div class="report-rec">Reinforce perimeter surveillance at sector where Drone Bravo approached restricted airspace.</div>
220
+ <div class="report-rec">Increase sensor coverage at FOV edges β€” two tracks lost due to exit from field of view.</div>
221
+ </div>
222
+ `;
223
+
224
+ // Back to tracks button
225
+ const backBtn = document.createElement('button');
226
+ backBtn.className = 'inspect-back-btn';
227
+ backBtn.style.marginTop = '8px';
228
+ backBtn.style.width = '100%';
229
+ backBtn.textContent = '\u2190 Back to Tracks';
230
+ backBtn.addEventListener('click', () => {
231
+ renderTrackListFull(ISR.MOCK_TRACKS, ISR.STATE.trackFilter);
232
+ });
233
+
234
+ panel.appendChild(report);
235
+ panel.appendChild(backBtn);
236
+ }
237
+
238
+ /* ================================================================
239
+ * TASK 9: Command Bar Actions
240
+ * ================================================================ */
241
+
242
+ function executeAction(action) {
243
+ if (action === 'filter-drones') {
244
+ ISR.STATE.trackFilter = 'drone';
245
+ renderTrackListFull(ISR.MOCK_TRACKS, 'drone');
246
+ // Highlight drone events on timeline
247
+ document.querySelectorAll('.event-marker').forEach(m => {
248
+ m.style.opacity = '0.3';
249
+ });
250
+ ISR.MOCK_EVENTS.forEach((evt, i) => {
251
+ if (evt.type === 'alert' || evt.label.toLowerCase().includes('drone')) {
252
+ const markers = document.querySelectorAll('.event-marker');
253
+ if (markers[i]) {
254
+ markers[i].style.opacity = '1';
255
+ markers[i].style.transform = 'translate(-50%, -50%) scale(1.4)';
256
+ }
257
+ }
258
+ });
259
+ } else if (action === 'show-report') {
260
+ renderMissionReport();
261
+ ISR.switchDrawerTab('tracks');
262
+ }
263
+ }
264
+
265
+ // ── Export to namespace ─────────────────────────────────────────
266
+
267
+ Object.assign(window.ISR, {
268
+ enterAnalysisState,
269
+ appendTrackCard,
270
+ wireTrackCard,
271
+ renderTrackListFull,
272
+ renderMetricsPanel,
273
+ renderMissionReport,
274
+ executeAction,
275
+ });
demo/js/api.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ // ── API Base URL ──────────────────────────────────────────────────
4
+ // Set to your HuggingFace Space URL for GPU access, or '' for same-origin
5
+ const API_BASE = 'https://biaslab2025-isr.hf.space';
6
+
7
+ // ── Async Data Functions (real backend API) ───────────────────────
8
+
9
+ async function startDetection(videoFile, config) {
10
+ const form = new FormData();
11
+ form.append('video', videoFile);
12
+ form.append('mode', config.mode);
13
+ if (config.queries) form.append('queries', config.queries);
14
+ form.append('detector', config.detector);
15
+ form.append('segmenter', config.segmenter);
16
+ form.append('enable_depth', config.enableDepth ? 'true' : 'false');
17
+ if (config.mission) form.append('mission', config.mission);
18
+ const res = await fetch(`${API_BASE}/detect/async`, { method: 'POST', body: form });
19
+ if (!res.ok) throw new Error(`Detection failed: ${res.status}`);
20
+ return res.json();
21
+ }
22
+
23
+ async function pollStatus(jobId) {
24
+ const url = ISR.STATE._statusUrl || `${API_BASE}/detect/status/${jobId}`;
25
+ const res = await fetch(url, { cache: 'no-store' });
26
+ if (!res.ok) throw new Error(`Status poll failed: ${res.status}`);
27
+ return res.json();
28
+ }
29
+
30
+ async function fetchTracks(jobId, frameIdx) {
31
+ const cached = ISR.getCachedTracks(frameIdx);
32
+ if (cached) return cached;
33
+ try {
34
+ const res = await fetch(`${API_BASE}/detect/tracks/${jobId}/${frameIdx}`);
35
+ if (!res.ok) {
36
+ if (res.status === 404 && ISR.STATE._analysisStartTime && Date.now() - ISR.STATE._analysisStartTime > 3600000) {
37
+ ISR.showToast('Job expired β€” results are no longer available');
38
+ ISR.STATE.jobId = null;
39
+ }
40
+ return [];
41
+ }
42
+ const data = await res.json();
43
+ ISR.cacheTrackData(frameIdx, data);
44
+ return data;
45
+ } catch { return []; }
46
+ }
47
+
48
+ async function fetchTimelineSummary(jobId) {
49
+ const res = await fetch(`${API_BASE}/detect/tracks/${jobId}/summary`);
50
+ if (!res.ok) throw new Error('Summary fetch failed');
51
+ return res.json();
52
+ }
53
+
54
+ async function fetchFrame(jobId, frameIdx, trackId) {
55
+ if (trackId === undefined) trackId = null;
56
+ try {
57
+ let url = `${API_BASE}/inspect/frame/${jobId}/${frameIdx}`;
58
+ if (trackId) url += `?track_id=${encodeURIComponent(trackId)}&padding=0.20`;
59
+ const res = await fetch(url);
60
+ if (!res.ok) return null;
61
+ return res.blob();
62
+ } catch { return null; }
63
+ }
64
+
65
+ async function fetchMask(jobId, frameIdx, trackId) {
66
+ try {
67
+ const url = `${API_BASE}/inspect/mask/${jobId}/${frameIdx}/${encodeURIComponent(trackId)}`;
68
+ const res = await fetch(url);
69
+ if (res.status === 404) {
70
+ // No pre-computed mask β€” generate one on-demand via SAM2
71
+ try {
72
+ const gen = await fetch(`${API_BASE}/inspect/generate-mask/${jobId}/${frameIdx}/${encodeURIComponent(trackId)}`, {
73
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ sam2_size: 'large' })
75
+ });
76
+ if (!gen.ok) return null;
77
+ return await gen.json();
78
+ } catch { return null; }
79
+ }
80
+ if (!res.ok) return null;
81
+ return await res.json();
82
+ } catch { return null; }
83
+ }
84
+
85
+ async function fetchDepth(jobId, frameIdx, trackId) {
86
+ if (trackId === undefined) trackId = null;
87
+ try {
88
+ let url = `${API_BASE}/inspect/depth/${jobId}/${frameIdx}?format=raw`;
89
+ if (trackId) url += `&track_id=${encodeURIComponent(trackId)}`;
90
+ const res = await fetch(url);
91
+ if (!res.ok) return null;
92
+
93
+ const contentType = res.headers.get('content-type') || '';
94
+ if (contentType.includes('application/octet-stream')) {
95
+ // Binary float32 format with metadata in headers
96
+ const w = parseInt(res.headers.get('X-Depth-Width'), 10);
97
+ const h = parseInt(res.headers.get('X-Depth-Height'), 10);
98
+ const minD = parseFloat(res.headers.get('X-Depth-Min'));
99
+ const maxD = parseFloat(res.headers.get('X-Depth-Max'));
100
+ const buf = await res.arrayBuffer();
101
+ const data = new Float32Array(buf);
102
+ if (isNaN(w) || isNaN(h)) {
103
+ // CORS stripped headers β€” fall back to JSON format
104
+ return await _fetchDepthJson(jobId, frameIdx, trackId);
105
+ }
106
+ return { width: w, height: h, min: isNaN(minD) ? 0 : minD, max: isNaN(maxD) ? 1 : maxD, data };
107
+ } else {
108
+ // JSON + base64 format
109
+ const json = await res.json();
110
+ return ISR._decodeDepthJson(json);
111
+ }
112
+ } catch { return null; }
113
+ }
114
+
115
+ async function _fetchDepthJson(jobId, frameIdx, trackId) {
116
+ let url = `${API_BASE}/inspect/depth/${jobId}/${frameIdx}?format=json`;
117
+ if (trackId) url += `&track_id=${encodeURIComponent(trackId)}`;
118
+ const res = await fetch(url);
119
+ if (!res.ok) return null;
120
+ return ISR._decodeDepthJson(await res.json());
121
+ }
122
+
123
+ async function fetchPointCloud(jobId, frameIdx, trackId) {
124
+ try {
125
+ const res = await fetch(`${API_BASE}/inspect/pointcloud/${jobId}/${frameIdx}`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ track_id: String(trackId), max_points: 50000, render_mode: 'mesh' })
129
+ });
130
+ if (!res.ok) return null;
131
+ return res.json();
132
+ } catch { return null; }
133
+ }
134
+
135
+ async function askAI(question, trackContext) {
136
+ if (trackContext === undefined) trackContext = null;
137
+ const body = {
138
+ message: question,
139
+ mission: ISR.STATE.mission || '',
140
+ track_context: trackContext || null,
141
+ history: (ISR.STATE.chatHistory && ISR.STATE.chatHistory.length > 0) ? ISR.STATE.chatHistory.slice(-20) : []
142
+ };
143
+ try {
144
+ const res = await fetch(`${API_BASE}/chat`, {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify(body)
148
+ });
149
+ if (!res.ok) throw new Error();
150
+ const data = await res.json();
151
+ ISR.STATE.chatHistory.push({ role: 'user', content: question });
152
+ ISR.STATE.chatHistory.push({ role: 'assistant', content: data.response });
153
+ return data;
154
+ } catch {
155
+ const fallback = ISR.matchAICommand(question);
156
+ return { response: fallback.text, action: fallback.action };
157
+ }
158
+ }
159
+
160
+ async function cancelJob(jobId) {
161
+ try { await fetch(`${API_BASE}/detect/job/${jobId}`, { method: 'DELETE' }); } catch {}
162
+ }
163
+
164
+ function resolveUrl(path) {
165
+ // If the path is already absolute, use it; otherwise prepend API_BASE
166
+ if (!path) return null;
167
+ return path.startsWith('http') ? path : `${API_BASE}${path}`;
168
+ }
169
+
170
+ // ── Export to namespace ─────────────────────────────────────────
171
+
172
+ Object.assign(window.ISR, {
173
+ API_BASE,
174
+ startDetection,
175
+ pollStatus,
176
+ fetchTracks,
177
+ fetchTimelineSummary,
178
+ fetchFrame,
179
+ fetchMask,
180
+ fetchDepth,
181
+ fetchPointCloud,
182
+ askAI,
183
+ cancelJob,
184
+ resolveUrl,
185
+ });
demo/js/cache.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ // ── LRU Track Cache ──────────────────────────────────────────────
4
+
5
+ const TRACK_CACHE = new Map();
6
+ const TRACK_CACHE_MAX = 100;
7
+ function cacheTrackData(frameIdx, data) {
8
+ if (TRACK_CACHE.size >= TRACK_CACHE_MAX) {
9
+ const oldest = TRACK_CACHE.keys().next().value;
10
+ TRACK_CACHE.delete(oldest);
11
+ }
12
+ TRACK_CACHE.set(frameIdx, data);
13
+ }
14
+ function getCachedTracks(frameIdx) {
15
+ if (!TRACK_CACHE.has(frameIdx)) return null;
16
+ const data = TRACK_CACHE.get(frameIdx);
17
+ TRACK_CACHE.delete(frameIdx);
18
+ TRACK_CACHE.set(frameIdx, data); // promote to most-recent
19
+ return data;
20
+ }
21
+ function clearTrackCache() { TRACK_CACHE.clear(); }
22
+
23
+ // ── Assessment Cache ───────────────────────────────────────────────
24
+ // Carries GPT assessment data forward across frames per track_id
25
+
26
+ const ASSESSMENT_CACHE = {}; // track_id β†’ {satisfies, reason, mission_relevant, features, gpt_raw, assessment_status}
27
+
28
+ function cacheAssessment(trackId, data) {
29
+ if (!trackId) return;
30
+ if (!ASSESSMENT_CACHE[trackId]) ASSESSMENT_CACHE[trackId] = {};
31
+ const c = ASSESSMENT_CACHE[trackId];
32
+ if (data.gpt_raw !== undefined) c.gpt_raw = data.gpt_raw;
33
+ if (data.satisfies !== undefined) c.satisfies = data.satisfies;
34
+ if (data.reason !== undefined) c.reason = data.reason;
35
+ if (data.mission_relevant !== undefined) c.mission_relevant = data.mission_relevant;
36
+ if (data.features !== undefined) c.features = data.features;
37
+ if (data.assessment_status !== undefined) c.assessment_status = data.assessment_status;
38
+ }
39
+
40
+ function applyAssessmentCache(track) {
41
+ const tid = track.track_id !== undefined ? track.track_id : track.id;
42
+ // Cache new data if present (matches frontend tracker.js Phase 1)
43
+ if (track.gpt_raw || track.assessment_status === 'ASSESSED') {
44
+ cacheAssessment(tid, track);
45
+ }
46
+ // Apply cached data if track is missing gpt_raw (matches frontend tracker.js Phase 2)
47
+ if (track.gpt_raw) return track; // Already has assessment data, skip
48
+ const cached = ASSESSMENT_CACHE[tid];
49
+ if (!cached) return track;
50
+ track.gpt_raw = cached.gpt_raw;
51
+ if (cached.satisfies !== undefined) track.satisfies = cached.satisfies ?? track.satisfies;
52
+ track.reason = cached.reason || track.reason;
53
+ if (cached.mission_relevant !== undefined) track.mission_relevant = cached.mission_relevant ?? track.mission_relevant;
54
+ track.assessment_status = cached.assessment_status || 'ASSESSED';
55
+ if (cached.features && Object.keys(cached.features).length > 0) track.features = cached.features;
56
+ return track;
57
+ }
58
+
59
+ function clearAssessmentCache() {
60
+ for (const k of Object.keys(ASSESSMENT_CACHE)) delete ASSESSMENT_CACHE[k];
61
+ }
62
+
63
+ // ── Export to namespace ─────────────────────────────────────────
64
+
65
+ Object.assign(window.ISR, {
66
+ TRACK_CACHE,
67
+ ASSESSMENT_CACHE,
68
+ cacheTrackData,
69
+ getCachedTracks,
70
+ clearTrackCache,
71
+ cacheAssessment,
72
+ applyAssessmentCache,
73
+ clearAssessmentCache,
74
+ });
demo/js/explain.js ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * Explainability Graph β€” Multi-LLM Interpretability Tree
5
+ * ================================================================ */
6
+
7
+ const EXPLAIN_COLORS = {
8
+ Structure: '#3b82f6', Function: '#06b6d4', Material: '#f59e0b',
9
+ Color: '#ef4444', Size: '#10b981', Type: '#8b5cf6',
10
+ Motion: '#ec4899', Context: '#64748b', Shape: '#f97316', Markings: '#a855f7',
11
+ };
12
+ const LIGHTEN_MAP = {
13
+ '#3b82f6':'#93c5fd','#06b6d4':'#a5f3fc','#f59e0b':'#fde68a',
14
+ '#ef4444':'#fca5a5','#10b981':'#6ee7b7','#8b5cf6':'#c4b5fd',
15
+ '#ec4899':'#f9a8d4','#64748b':'#94a3b8','#f97316':'#fdba74','#a855f7':'#d8b4fe',
16
+ };
17
+
18
+ let _explainAbort = null;
19
+ const _explainCache = {};
20
+
21
+ async function loadExplainability(jobId, trackId) {
22
+ const panel = document.getElementById('explainPanel');
23
+ if (!panel) return;
24
+
25
+ // Check cache
26
+ if (_explainCache[trackId]) {
27
+ renderExplainGraph(_explainCache[trackId], panel);
28
+ return;
29
+ }
30
+
31
+ // Abort previous
32
+ if (_explainAbort) _explainAbort.abort();
33
+ _explainAbort = new AbortController();
34
+
35
+ panel.innerHTML = '<div class="explain-loading"><div class="explain-spinner"></div><span>Analyzing with GPT-4o, Claude, and Gemini...</span></div>';
36
+
37
+ try {
38
+ const resp = await fetch(`${ISR.API_BASE}/inspect/explain/${jobId}/${encodeURIComponent(trackId)}`, { signal: _explainAbort.signal });
39
+ if (!resp.ok) {
40
+ const body = await resp.json().catch(() => ({}));
41
+ throw new Error(body.detail || `Explain failed: ${resp.status}`);
42
+ }
43
+ const data = await resp.json();
44
+ _explainCache[trackId] = data;
45
+ renderExplainGraph(data, panel);
46
+ } catch (err) {
47
+ if (err.name === 'AbortError') return;
48
+ panel.innerHTML = `<div class="explain-error">${ISR.escHtml(err.message)}</div>`;
49
+ }
50
+ }
51
+
52
+ function renderExplainGraph(data, container) {
53
+ container.innerHTML = '';
54
+ if (!data || !data.categories || data.categories.length === 0) {
55
+ container.innerHTML = '<div class="explain-error">No explanation data available</div>';
56
+ return;
57
+ }
58
+
59
+ var confPct = Math.round((data.confidence || 0) * 100);
60
+ var circumf = 113;
61
+ var dashOff = circumf - (circumf * confPct / 100);
62
+ var tree = document.createElement('div');
63
+ tree.className = 'ex-tree';
64
+
65
+ // Controls
66
+ var ctrlHtml = '<div class="ex-controls">'
67
+ + '<button class="ex-ctrl-btn active" data-action="tree">Tree</button>'
68
+ + '<button class="ex-ctrl-btn" data-action="graph">Graph</button>'
69
+ + '<button class="ex-ctrl-btn" data-action="expand">Expand All</button>'
70
+ + '<button class="ex-ctrl-btn" data-action="collapse">Collapse All</button>'
71
+ + '</div>';
72
+
73
+ // Root node
74
+ var rootHtml = '<div class="ex-node-root">'
75
+ + '<svg class="ex-conf-ring" viewBox="0 0 44 44">'
76
+ + '<circle class="ring-bg" cx="22" cy="22" r="18"/>'
77
+ + '<circle class="ring-fill" cx="22" cy="22" r="18" stroke-dasharray="' + circumf + '" stroke-dashoffset="' + dashOff + '"/>'
78
+ + '<text class="ring-text" x="22" y="22">' + confPct + '%</text>'
79
+ + '</svg>'
80
+ + '<div class="ex-root-info">'
81
+ + '<div class="ex-root-label">' + ISR.escHtml((data.object || 'OBJECT').toUpperCase()) + '</div>'
82
+ + (data.reasoning_summary ? '<div class="ex-root-summary">' + ISR.escHtml(data.reasoning_summary) + '</div>' : '')
83
+ + (data.satisfies === true ? '<span class="ex-root-badge match">MISSION MATCH</span>'
84
+ : data.satisfies === false ? '<span class="ex-root-badge no-match">NO MATCH</span>' : '')
85
+ + '</div></div>';
86
+
87
+ var llmHtml = '<div class="ex-llm-strip">'
88
+ + '<span class="ex-llm-tag active">GPT-4o</span>'
89
+ + '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 1 ? ' active' : '') + '">Claude</span>'
90
+ + '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 2 ? ' active' : '') + '">Gemini</span>'
91
+ + '</div>';
92
+
93
+ // Trunk line
94
+ var trunkHtml = '<div class="ex-trunk"></div>';
95
+
96
+ tree.innerHTML = ctrlHtml + rootHtml + llmHtml + trunkHtml;
97
+ container.appendChild(tree);
98
+
99
+ // Category nodes with branch lines
100
+ var cats = data.categories || [];
101
+ cats.forEach(function(cat, ci) {
102
+ var color = cat.color || EXPLAIN_COLORS[cat.name] || '#64748b';
103
+ var features = cat.features || [];
104
+ var catEl = document.createElement('div');
105
+ catEl.className = 'ex-node-cat open';
106
+ catEl.style.cssText = '--cat-color:' + color + ';animation-delay:' + (ci * 0.07) + 's';
107
+
108
+ var featHtml = '';
109
+ features.forEach(function(f, fi) {
110
+ var vals = f.validators || {};
111
+ var chips = '';
112
+ Object.keys(vals).forEach(function(name) {
113
+ var v = vals[name];
114
+ var cls = v.agree ? 'agree' : 'disagree';
115
+ var icon = v.agree ? 'βœ“' : 'βœ—';
116
+ chips += '<span class="ex-val-chip ' + cls + '">' + icon + ' ' + ISR.escHtml(name) + '</span>';
117
+ });
118
+
119
+ featHtml += '<div class="ex-node-feat" style="animation-delay:' + (fi * 0.05) + 's">'
120
+ + '<div class="ex-feat-branch" style="background:' + color + ';opacity:0.4"></div>'
121
+ + '<div class="ex-feat-name">' + ISR.escHtml(f.name) + '</div>'
122
+ + (f.reasoning ? '<div class="ex-feat-reasoning">' + ISR.escHtml(f.reasoning) + '</div>' : '')
123
+ + (chips ? '<div class="ex-feat-validators">' + chips + '</div>' : '')
124
+ + '</div>';
125
+ });
126
+
127
+ catEl.innerHTML = '<div class="ex-cat-head">'
128
+ + '<div class="ex-cat-dot"></div>'
129
+ + '<span class="ex-cat-name">' + ISR.escHtml(cat.name) + '</span>'
130
+ + '<span class="ex-cat-count">' + features.length + '</span>'
131
+ + '<svg class="ex-cat-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4l4 4-4 4"/></svg>'
132
+ + '</div>'
133
+ + '<div class="ex-features-wrap"><div class="ex-features-inner">'
134
+ + featHtml
135
+ + '</div></div>';
136
+
137
+ catEl.querySelector('.ex-cat-head').addEventListener('click', function() {
138
+ catEl.classList.toggle('open');
139
+ });
140
+
141
+ tree.appendChild(catEl);
142
+ });
143
+
144
+ // Consensus bar
145
+ if (data.consensus_bar) {
146
+ var bar = data.consensus_bar;
147
+ var pct = bar.total_features > 0 ? (bar.agreed / bar.total_features) * 100 : 0;
148
+ var consEl = document.createElement('div');
149
+ consEl.className = 'ex-consensus';
150
+ consEl.innerHTML = '<div class="ex-cons-bar-bg"><div class="ex-cons-bar-fill" style="width:0%"></div></div>'
151
+ + '<div class="ex-cons-labels">'
152
+ + '<span class="agreed">' + bar.agreed + '/' + bar.total_features + ' features agreed</span>'
153
+ + '<span>' + bar.validators_available + '/2 validators</span>'
154
+ + '</div>';
155
+ tree.appendChild(consEl);
156
+ requestAnimationFrame(function() {
157
+ requestAnimationFrame(function() {
158
+ consEl.querySelector('.ex-cons-bar-fill').style.width = pct + '%';
159
+ });
160
+ });
161
+ }
162
+
163
+ // Expand/Collapse all controls
164
+ tree.querySelector('[data-action="expand"]').addEventListener('click', function() {
165
+ tree.querySelectorAll('.ex-node-cat').forEach(function(c) { c.classList.add('open'); });
166
+ });
167
+ tree.querySelector('[data-action="collapse"]').addEventListener('click', function() {
168
+ tree.querySelectorAll('.ex-node-cat').forEach(function(c) { c.classList.remove('open'); });
169
+ });
170
+
171
+ // View toggle: Tree vs Graph
172
+ var treeBtn = tree.querySelector('[data-action="tree"]');
173
+ var graphBtn = tree.querySelector('[data-action="graph"]');
174
+ var treeContent = tree.querySelectorAll('.ex-node-root, .ex-llm-strip, .ex-trunk, .ex-node-cat, .ex-consensus');
175
+ var graphContainer = null;
176
+
177
+ treeBtn.addEventListener('click', function() {
178
+ treeBtn.classList.add('active'); graphBtn.classList.remove('active');
179
+ treeContent.forEach(function(el) { el.style.display = ''; });
180
+ if (graphContainer) graphContainer.style.display = 'none';
181
+ tree.querySelector('[data-action="expand"]').style.display = '';
182
+ tree.querySelector('[data-action="collapse"]').style.display = '';
183
+ });
184
+
185
+ graphBtn.addEventListener('click', function() {
186
+ graphBtn.classList.add('active'); treeBtn.classList.remove('active');
187
+ treeContent.forEach(function(el) { el.style.display = 'none'; });
188
+ tree.querySelector('[data-action="expand"]').style.display = 'none';
189
+ tree.querySelector('[data-action="collapse"]').style.display = 'none';
190
+ if (!graphContainer) {
191
+ graphContainer = document.createElement('div');
192
+ graphContainer.className = 'ex-graph-wrap';
193
+ tree.appendChild(graphContainer);
194
+ renderGraphNodes(data, graphContainer);
195
+ }
196
+ graphContainer.style.display = '';
197
+ });
198
+ }
199
+
200
+ function renderGraphNodes(data, wrap) {
201
+ wrap.innerHTML = '';
202
+ var cats = data.categories || [];
203
+ var allFeatures = [];
204
+ cats.forEach(function(c) { allFeatures = allFeatures.concat(c.features || []); });
205
+ var totalLeaves = Math.max(allFeatures.length, 1);
206
+
207
+ var nodeW = 96, nodeH = 28, catNodeW = 84, catNodeH = 24, featNodeW = 78, featNodeH = 20;
208
+ var padX = 50, padTop = 36, padBot = 50;
209
+ var levelGap = 80;
210
+ var leafSpacing = Math.max(featNodeW + 14, 90);
211
+ var svgW = Math.max(wrap.clientWidth || 340, totalLeaves * leafSpacing + padX * 2);
212
+ var svgH = 3 * levelGap + padTop + padBot;
213
+
214
+ var ns = 'http://www.w3.org/2000/svg';
215
+ var svg = document.createElementNS(ns, 'svg');
216
+ svg.setAttribute('width', svgW);
217
+ svg.setAttribute('height', svgH);
218
+ svg.setAttribute('class', 'ex-graph-svg');
219
+ wrap.appendChild(svg);
220
+
221
+ // Defs: glow filter + gradient
222
+ var defs = document.createElementNS(ns, 'defs');
223
+ defs.innerHTML = '<filter id="gnGlow"><feGaussianBlur stdDeviation="3" result="b"/>'
224
+ + '<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
225
+ svg.appendChild(defs);
226
+
227
+ // Layout positions
228
+ var rootX = svgW / 2, rootY = padTop;
229
+
230
+ // Compute leaf positions first, then center categories above their children
231
+ var leafIndex = 0;
232
+ var catPositions = [];
233
+ var featPositions = [];
234
+ var totalLeafWidth = totalLeaves * leafSpacing;
235
+ var leafStartX = (svgW - totalLeafWidth) / 2 + leafSpacing / 2;
236
+
237
+ cats.forEach(function(cat) {
238
+ var feats = cat.features || [];
239
+ var catFeats = [];
240
+ feats.forEach(function(f) {
241
+ var fx = leafStartX + leafIndex * leafSpacing;
242
+ var fy = padTop + 2 * levelGap;
243
+ catFeats.push({ x: fx, y: fy, feat: f, color: cat.color || EXPLAIN_COLORS[cat.name] || '#64748b' });
244
+ leafIndex++;
245
+ });
246
+ featPositions.push(catFeats);
247
+ // Category centered above its features
248
+ var cx = catFeats.length > 0
249
+ ? (catFeats[0].x + catFeats[catFeats.length - 1].x) / 2
250
+ : rootX;
251
+ catPositions.push({ x: cx, y: padTop + levelGap, cat: cat, color: cat.color || EXPLAIN_COLORS[cat.name] || '#64748b' });
252
+ });
253
+
254
+ // Draw edges: root β†’ categories
255
+ catPositions.forEach(function(cp, i) {
256
+ var edge = document.createElementNS(ns, 'path');
257
+ var mx = rootX, my = (rootY + nodeH / 2 + cp.y - catNodeH / 2) / 2;
258
+ edge.setAttribute('d', 'M' + rootX + ',' + (rootY + nodeH / 2)
259
+ + ' C' + rootX + ',' + my + ' ' + cp.x + ',' + my + ' ' + cp.x + ',' + (cp.y - catNodeH / 2));
260
+ edge.setAttribute('stroke', cp.color);
261
+ edge.setAttribute('class', 'gn-edge');
262
+ edge.setAttribute('stroke-dasharray', '200');
263
+ edge.style.animationDelay = (i * 0.08) + 's';
264
+ edge.setAttribute('filter', 'url(#gnGlow)');
265
+ svg.appendChild(edge);
266
+ });
267
+
268
+ // Draw edges: categories β†’ features
269
+ catPositions.forEach(function(cp, ci) {
270
+ var feats = featPositions[ci] || [];
271
+ feats.forEach(function(fp, fi) {
272
+ var edge = document.createElementNS(ns, 'path');
273
+ var my = (cp.y + catNodeH / 2 + fp.y - featNodeH / 2) / 2;
274
+ edge.setAttribute('d', 'M' + cp.x + ',' + (cp.y + catNodeH / 2)
275
+ + ' C' + cp.x + ',' + my + ' ' + fp.x + ',' + my + ' ' + fp.x + ',' + (fp.y - featNodeH / 2));
276
+ edge.setAttribute('stroke', fp.color);
277
+ edge.setAttribute('class', 'gn-edge');
278
+ edge.setAttribute('stroke-dasharray', '200');
279
+ edge.style.animationDelay = (ci * 0.08 + fi * 0.05 + 0.15) + 's';
280
+ edge.setAttribute('filter', 'url(#gnGlow)');
281
+ svg.appendChild(edge);
282
+ });
283
+ });
284
+
285
+ // Draw root node
286
+ var rootG = document.createElementNS(ns, 'g');
287
+ rootG.setAttribute('class', 'gn-node');
288
+ rootG.setAttribute('transform', 'translate(' + rootX + ',' + rootY + ')');
289
+ var rootRect = document.createElementNS(ns, 'rect');
290
+ rootRect.setAttribute('x', -nodeW / 2); rootRect.setAttribute('y', -nodeH / 2);
291
+ rootRect.setAttribute('width', nodeW); rootRect.setAttribute('height', nodeH);
292
+ rootRect.setAttribute('rx', 8); rootRect.setAttribute('fill', '#7c3aed');
293
+ rootRect.setAttribute('filter', 'url(#gnGlow)');
294
+ rootG.appendChild(rootRect);
295
+ var rootText = document.createElementNS(ns, 'text');
296
+ rootText.setAttribute('text-anchor', 'middle'); rootText.setAttribute('dy', '0.35em');
297
+ rootText.setAttribute('fill', 'white'); rootText.setAttribute('font-size', '10'); rootText.setAttribute('font-weight', '700');
298
+ rootText.textContent = (data.object || 'OBJECT').toUpperCase() + ' ' + Math.round((data.confidence || 0) * 100) + '%';
299
+ rootG.appendChild(rootText);
300
+ rootG.addEventListener('mouseenter', function(e) {
301
+ showGraphTip(wrap, e, '<strong>' + ISR.escHtml((data.object || '').toUpperCase()) + '</strong>'
302
+ + (data.reasoning_summary ? '<br>' + ISR.escHtml(data.reasoning_summary) : ''));
303
+ });
304
+ rootG.addEventListener('mouseleave', function() { hideGraphTip(wrap); });
305
+ svg.appendChild(rootG);
306
+
307
+ // Draw category nodes
308
+ catPositions.forEach(function(cp, ci) {
309
+ var cg = document.createElementNS(ns, 'g');
310
+ cg.setAttribute('class', 'gn-node');
311
+ cg.setAttribute('transform', 'translate(' + cp.x + ',' + cp.y + ')');
312
+ var cr = document.createElementNS(ns, 'rect');
313
+ cr.setAttribute('x', -catNodeW / 2); cr.setAttribute('y', -catNodeH / 2);
314
+ cr.setAttribute('width', catNodeW); cr.setAttribute('height', catNodeH);
315
+ cr.setAttribute('rx', 6); cr.setAttribute('fill', cp.color);
316
+ cr.setAttribute('opacity', '0.92'); cr.setAttribute('filter', 'url(#gnGlow)');
317
+ cg.appendChild(cr);
318
+ var ct = document.createElementNS(ns, 'text');
319
+ ct.setAttribute('text-anchor', 'middle'); ct.setAttribute('dy', '0.35em');
320
+ ct.setAttribute('fill', 'white'); ct.setAttribute('font-size', '9'); ct.setAttribute('font-weight', '600');
321
+ ct.textContent = cp.cat.name;
322
+ cg.appendChild(ct);
323
+ var feats = cp.cat.features || [];
324
+ var validated = feats.filter(function(f) {
325
+ var v = Object.values(f.validators || {});
326
+ return v.length > 0 && v.every(function(x) { return x.agree; });
327
+ }).length;
328
+ cg.addEventListener('mouseenter', function(e) {
329
+ showGraphTip(wrap, e, '<strong>' + ISR.escHtml(cp.cat.name) + '</strong><br>'
330
+ + feats.length + ' features, ' + validated + ' fully validated');
331
+ });
332
+ cg.addEventListener('mouseleave', function() { hideGraphTip(wrap); });
333
+ svg.appendChild(cg);
334
+ });
335
+
336
+ // Draw feature nodes
337
+ catPositions.forEach(function(cp, ci) {
338
+ var feats = featPositions[ci] || [];
339
+ feats.forEach(function(fp) {
340
+ var fg = document.createElementNS(ns, 'g');
341
+ fg.setAttribute('class', 'gn-node');
342
+ fg.setAttribute('transform', 'translate(' + fp.x + ',' + fp.y + ')');
343
+ var fr = document.createElementNS(ns, 'rect');
344
+ fr.setAttribute('x', -featNodeW / 2); fr.setAttribute('y', -featNodeH / 2);
345
+ fr.setAttribute('width', featNodeW); fr.setAttribute('height', featNodeH);
346
+ fr.setAttribute('rx', 5); fr.setAttribute('fill', '#0f172a');
347
+ fr.setAttribute('stroke', fp.color); fr.setAttribute('stroke-width', '1.5');
348
+ fg.appendChild(fr);
349
+ var ft = document.createElementNS(ns, 'text');
350
+ ft.setAttribute('text-anchor', 'middle'); ft.setAttribute('dy', '0.35em');
351
+ ft.setAttribute('fill', '#e2e8f0'); ft.setAttribute('font-size', '7.5');
352
+ var fname = fp.feat.name;
353
+ ft.textContent = fname.length > 12 ? fname.slice(0, 11) + '\u2026' : fname;
354
+ fg.appendChild(ft);
355
+
356
+ // Validator badge
357
+ var vals = fp.feat.validators || {};
358
+ var vkeys = Object.keys(vals);
359
+ if (vkeys.length > 0) {
360
+ var agreed = vkeys.filter(function(k) { return vals[k].agree; }).length;
361
+ var badge = document.createElementNS(ns, 'text');
362
+ badge.setAttribute('x', featNodeW / 2 - 2); badge.setAttribute('y', -featNodeH / 2 - 4);
363
+ badge.setAttribute('text-anchor', 'end'); badge.setAttribute('font-size', '7'); badge.setAttribute('font-weight', '600');
364
+ badge.setAttribute('fill', agreed === vkeys.length ? '#4ade80' : '#f87171');
365
+ badge.textContent = agreed + '/' + vkeys.length;
366
+ fg.appendChild(badge);
367
+ }
368
+
369
+ // Tooltip
370
+ fg.addEventListener('mouseenter', function(e) {
371
+ var html = '<strong>' + ISR.escHtml(fp.feat.name) + '</strong>';
372
+ if (fp.feat.reasoning) html += '<div class="gt-section"><span class="gt-label">GPT-4o:</span> ' + ISR.escHtml(fp.feat.reasoning) + '</div>';
373
+ Object.keys(vals).forEach(function(name) {
374
+ var v = vals[name];
375
+ var cls = v.agree ? 'gt-agree' : 'gt-disagree';
376
+ var icon = v.agree ? 'βœ“' : 'βœ—';
377
+ html += '<div class="gt-section"><span class="gt-label ' + cls + '">' + icon + ' ' + ISR.escHtml(name) + ':</span> ' + ISR.escHtml(v.note || '') + '</div>';
378
+ });
379
+ showGraphTip(wrap, e, html);
380
+ });
381
+ fg.addEventListener('mouseleave', function() { hideGraphTip(wrap); });
382
+ svg.appendChild(fg);
383
+ });
384
+ });
385
+
386
+ // Consensus bar as SVG at the bottom
387
+ if (data.consensus_bar) {
388
+ var bar = data.consensus_bar;
389
+ var barY = svgH - 28, barX = 24, barW = svgW - 48, barH = 5;
390
+ var ratio = bar.total_features > 0 ? bar.agreed / bar.total_features : 0;
391
+ var bg = document.createElementNS(ns, 'rect');
392
+ bg.setAttribute('x', barX); bg.setAttribute('y', barY);
393
+ bg.setAttribute('width', barW); bg.setAttribute('height', barH);
394
+ bg.setAttribute('rx', 2.5); bg.setAttribute('fill', 'rgba(255,255,255,0.06)');
395
+ svg.appendChild(bg);
396
+ var fill = document.createElementNS(ns, 'rect');
397
+ fill.setAttribute('x', barX); fill.setAttribute('y', barY);
398
+ fill.setAttribute('width', barW * ratio); fill.setAttribute('height', barH);
399
+ fill.setAttribute('rx', 2.5); fill.setAttribute('fill', '#7c3aed');
400
+ fill.setAttribute('filter', 'url(#gnGlow)');
401
+ svg.appendChild(fill);
402
+ var lbl = document.createElementNS(ns, 'text');
403
+ lbl.setAttribute('x', barX); lbl.setAttribute('y', barY + barH + 13);
404
+ lbl.setAttribute('fill', '#a78bfa'); lbl.setAttribute('font-size', '8.5');
405
+ lbl.textContent = bar.agreed + '/' + bar.total_features + ' features agreed';
406
+ svg.appendChild(lbl);
407
+ var vlbl = document.createElementNS(ns, 'text');
408
+ vlbl.setAttribute('x', barX + barW); vlbl.setAttribute('y', barY + barH + 13);
409
+ vlbl.setAttribute('text-anchor', 'end'); vlbl.setAttribute('fill', 'rgba(255,255,255,0.3)'); vlbl.setAttribute('font-size', '8.5');
410
+ vlbl.textContent = bar.validators_available + '/2 validators';
411
+ svg.appendChild(vlbl);
412
+ }
413
+ }
414
+
415
+ function showGraphTip(wrap, event, html) {
416
+ hideGraphTip(wrap);
417
+ var tip = document.createElement('div');
418
+ tip.className = 'ex-graph-tip';
419
+ tip.innerHTML = html;
420
+ var rect = event.target.closest('.gn-node').getBoundingClientRect();
421
+ var wr = wrap.getBoundingClientRect();
422
+ tip.style.left = (rect.left - wr.left + rect.width / 2) + 'px';
423
+ tip.style.top = (rect.top - wr.top - 6) + 'px';
424
+ tip.style.transform = 'translate(-50%, -100%)';
425
+ wrap.appendChild(tip);
426
+ // Clamp
427
+ var tr = tip.getBoundingClientRect();
428
+ if (tr.left < wr.left) tip.style.left = (parseFloat(tip.style.left) + wr.left - tr.left + 4) + 'px';
429
+ if (tr.right > wr.right) tip.style.left = (parseFloat(tip.style.left) - (tr.right - wr.right) - 4) + 'px';
430
+ }
431
+
432
+ function hideGraphTip(wrap) {
433
+ var old = wrap.querySelector('.ex-graph-tip');
434
+ if (old) old.remove();
435
+ }
436
+
437
+ // ── Export to namespace ─────────────────────────────────────────
438
+
439
+ Object.assign(window.ISR, {
440
+ EXPLAIN_COLORS,
441
+ LIGHTEN_MAP,
442
+ loadExplainability,
443
+ renderExplainGraph,
444
+ renderGraphNodes,
445
+ showGraphTip,
446
+ hideGraphTip,
447
+ });
demo/js/helpers.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ // ── Build Features from gpt_raw ─────────────────────────────────────
4
+
5
+ function buildFeatures(gptRaw) {
6
+ if (!gptRaw) return {};
7
+ const features = {};
8
+ for (const [key, value] of Object.entries(gptRaw)) {
9
+ if (key === 'satisfies' || key === 'reason') continue;
10
+ features[key] = String(value);
11
+ }
12
+ // Add Satisfies and Reason entries (matches frontend gptMapping.buildFeatures)
13
+ if (gptRaw.satisfies !== undefined) {
14
+ features['Satisfies'] = gptRaw.satisfies === true ? 'Yes' : gptRaw.satisfies === false ? 'No' : '\u2014';
15
+ }
16
+ if (gptRaw.reason) {
17
+ features['Reason'] = gptRaw.reason;
18
+ }
19
+ return features;
20
+ }
21
+
22
+ // ── Status Badge Helper ──────────────────────────────────────────────
23
+
24
+ function getStatusBadgeHTML(track) {
25
+ if (track.satisfies === true) return '<span class="status-badge match">Match</span>';
26
+ if (track.satisfies === false) return '<span class="status-badge no-match">No Match</span>';
27
+ if (track.mission_relevant === true) return '<span class="status-badge relevant">Relevant</span>';
28
+ if (track.assessment_status === 'STALE') return '<span class="status-badge pending">Stale</span>';
29
+ if (track.mission_relevant === false) return '<span class="status-badge not-relevant">N/R</span>';
30
+ // Default: confidence percentage
31
+ const conf = track.score || track.confidence || 0;
32
+ return `<span class="track-conf">${(conf * 100).toFixed(0)}%</span>`;
33
+ }
34
+
35
+ // ── Helpers ────────────────────────────────────────────────────────
36
+
37
+ function formatTime(seconds) {
38
+ const m = Math.floor(seconds / 60);
39
+ const s = (seconds % 60).toFixed(1);
40
+ return `${String(m).padStart(2, '0')}:${s.padStart(4, '0')}`;
41
+ }
42
+
43
+ function labelToType(label) {
44
+ const l = label.toLowerCase();
45
+ if (l.includes('drone') || l.includes('uav')) return 'drone';
46
+ if (l.includes('car') || l.includes('truck') || l.includes('vehicle') || l.includes('bus')) return 'vehicle';
47
+ if (l.includes('person') || l.includes('pedestrian')) return 'person';
48
+ return 'stationary';
49
+ }
50
+
51
+ function escHtml(str) {
52
+ const d = document.createElement('div');
53
+ d.textContent = str || '';
54
+ return d.innerHTML;
55
+ }
56
+
57
+ function _decodeDepthJson(json) {
58
+ const raw = atob(json.data_b64);
59
+ const buf = new ArrayBuffer(raw.length);
60
+ const view = new Uint8Array(buf);
61
+ for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
62
+ return { width: json.width, height: json.height, min: json.min_depth, max: json.max_depth, data: new Float32Array(buf) };
63
+ }
64
+
65
+ // Decode COCO compressed RLE string to array of counts
66
+ function _decodeRLEString(s) {
67
+ const counts = [];
68
+ let i = 0;
69
+ while (i < s.length) {
70
+ let more = true, shift = 0, val = 0;
71
+ while (more && i < s.length) {
72
+ const c = s.charCodeAt(i) - 48;
73
+ i++;
74
+ val |= (c & 0x1f) << shift;
75
+ more = (c & 0x20) !== 0;
76
+ shift += 5;
77
+ }
78
+ if (val & 1) val = -(val + 1) / 2;
79
+ else val = val / 2;
80
+ counts.push(val);
81
+ }
82
+ // Convert delta-encoded to absolute
83
+ for (let j = 1; j < counts.length; j++) counts[j] += counts[j - 1];
84
+ // Ensure positive values
85
+ return counts.map(c => Math.abs(c));
86
+ }
87
+
88
+ function applySobel(imageData) {
89
+ const { width, height, data } = imageData;
90
+ const gray = new Float32Array(width * height);
91
+ for (let i = 0; i < width * height; i++) {
92
+ gray[i] = data[i*4] * 0.299 + data[i*4+1] * 0.587 + data[i*4+2] * 0.114;
93
+ }
94
+ const output = new ImageData(width, height);
95
+ const gx = [-1,0,1,-2,0,2,-1,0,1];
96
+ const gy = [-1,-2,-1,0,0,0,1,2,1];
97
+ for (let y = 1; y < height - 1; y++) {
98
+ for (let x = 1; x < width - 1; x++) {
99
+ let sx = 0, sy = 0;
100
+ for (let ky = -1; ky <= 1; ky++) {
101
+ for (let kx = -1; kx <= 1; kx++) {
102
+ const idx = (y + ky) * width + (x + kx);
103
+ const ki = (ky + 1) * 3 + (kx + 1);
104
+ sx += gray[idx] * gx[ki];
105
+ sy += gray[idx] * gy[ki];
106
+ }
107
+ }
108
+ const mag = Math.min(255, Math.sqrt(sx*sx + sy*sy));
109
+ const oi = (y * width + x) * 4;
110
+ output.data[oi] = output.data[oi+1] = output.data[oi+2] = mag;
111
+ output.data[oi+3] = 255;
112
+ }
113
+ }
114
+ return output;
115
+ }
116
+
117
+ // Viridis-like colormap (matches frontend inspection-renders.js)
118
+ function _viridis(t) {
119
+ // Simplified viridis: dark purple β†’ teal β†’ yellow
120
+ const r = Math.round(Math.max(0, Math.min(255, (-876.7 * t + 1625.9) * t * t + 75.1 * t + 67.6)));
121
+ const g = Math.round(Math.max(0, Math.min(255, ((-1392.1 * t + 1010.9) * t + 311.1) * t + 1.5)));
122
+ const b = Math.round(Math.max(0, Math.min(255, ((479.1 * t - 1264.5) * t + 471.0) * t + 84.1)));
123
+ return [r, g, b];
124
+ }
125
+
126
+ function roundRect(ctx, x, y, w, h, r) {
127
+ ctx.beginPath();
128
+ ctx.moveTo(x + r, y);
129
+ ctx.lineTo(x + w - r, y);
130
+ ctx.arcTo(x + w, y, x + w, y + r, r);
131
+ ctx.lineTo(x + w, y + h - r);
132
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
133
+ ctx.lineTo(x + r, y + h);
134
+ ctx.arcTo(x, y + h, x, y + h - r, r);
135
+ ctx.lineTo(x, y + r);
136
+ ctx.arcTo(x, y, x + r, y, r);
137
+ ctx.closePath();
138
+ }
139
+
140
+ // ── getTracksAtFrame β€” with linear bbox interpolation ───────────
141
+
142
+ function getTracksAtFrame(frameIdx) {
143
+ const MOCK_TRACKS = ISR.MOCK_TRACKS;
144
+ const visible = [];
145
+
146
+ for (const track of MOCK_TRACKS) {
147
+ const kf = track.keyframes;
148
+ if (kf.length === 0) continue;
149
+
150
+ const firstFrame = kf[0].frame;
151
+ const lastFrame = kf[kf.length - 1].frame;
152
+
153
+ // Track not visible outside its keyframe range
154
+ if (frameIdx < firstFrame || frameIdx > lastFrame) continue;
155
+
156
+ // Find surrounding keyframes for interpolation
157
+ let before = null;
158
+ let after = null;
159
+
160
+ for (let i = 0; i < kf.length; i++) {
161
+ if (kf[i].frame <= frameIdx) {
162
+ before = kf[i];
163
+ }
164
+ if (kf[i].frame >= frameIdx && after === null) {
165
+ after = kf[i];
166
+ }
167
+ }
168
+
169
+ let bbox;
170
+ if (!before || !after || before.frame === after.frame) {
171
+ // Exact keyframe or edge case
172
+ bbox = { ...(before || after).bbox };
173
+ } else {
174
+ // Linear interpolation
175
+ const t = (frameIdx - before.frame) / (after.frame - before.frame);
176
+ bbox = {
177
+ x: before.bbox.x + (after.bbox.x - before.bbox.x) * t,
178
+ y: before.bbox.y + (after.bbox.y - before.bbox.y) * t,
179
+ w: before.bbox.w + (after.bbox.w - before.bbox.w) * t,
180
+ h: before.bbox.h + (after.bbox.h - before.bbox.h) * t,
181
+ };
182
+ }
183
+
184
+ visible.push({
185
+ ...track,
186
+ bbox,
187
+ interpolatedFrame: frameIdx,
188
+ });
189
+ }
190
+
191
+ return visible;
192
+ }
193
+
194
+ function prefetchTracksAround(jobId, frameIdx, range) {
195
+ if (range === undefined) range = 5;
196
+ const TRACK_CACHE = ISR.TRACK_CACHE;
197
+ const STATE = ISR.STATE;
198
+ for (let i = -range; i <= range; i++) {
199
+ const f = frameIdx + i;
200
+ if (f >= 0 && f < STATE.totalFrames && !TRACK_CACHE.has(f)) {
201
+ ISR.fetchTracks(jobId, f);
202
+ }
203
+ }
204
+ }
205
+
206
+ // ── AI Command Matching (fallback) ─────────────────────────────────
207
+
208
+ function matchAICommand(question) {
209
+ const MOCK_AI_RESPONSES_FALLBACK = ISR.MOCK_AI_RESPONSES_FALLBACK;
210
+ const key = question.toLowerCase().trim();
211
+ if (MOCK_AI_RESPONSES_FALLBACK[key]) {
212
+ return MOCK_AI_RESPONSES_FALLBACK[key];
213
+ }
214
+ for (const [keyword, response] of Object.entries(MOCK_AI_RESPONSES_FALLBACK)) {
215
+ if (key.includes(keyword)) {
216
+ return response;
217
+ }
218
+ }
219
+ return {
220
+ text: 'Command not recognized. Try: threat assessment, show all drones, generate report, summarize mission, count vehicles',
221
+ action: null,
222
+ };
223
+ }
224
+
225
+ // ── Export to namespace ─────────────────────────────────────────
226
+
227
+ Object.assign(window.ISR, {
228
+ buildFeatures,
229
+ getStatusBadgeHTML,
230
+ formatTime,
231
+ labelToType,
232
+ escHtml,
233
+ _decodeDepthJson,
234
+ _decodeRLEString,
235
+ applySobel,
236
+ _viridis,
237
+ roundRect,
238
+ getTracksAtFrame,
239
+ prefetchTracksAround,
240
+ matchAICommand,
241
+ });
demo/js/init.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * Initialization
5
+ * ================================================================ */
6
+
7
+ // Wire SVG deselect handler ONCE (not on every render)
8
+ (function() {
9
+ const svg = document.getElementById('detectionOverlay');
10
+ if (svg) {
11
+ svg.addEventListener('click', (e) => {
12
+ if (e.target === svg) {
13
+ ISR.STATE.selectedTrackId = null;
14
+ ISR.STATE.highlightTrackId = null;
15
+ document.dispatchEvent(new CustomEvent('track-selected', { detail: { id: null } }));
16
+ if (ISR.STATE._realTracks) ISR.renderTrackListFromData(ISR.STATE._realTracks);
17
+ }
18
+ });
19
+ }
20
+ })();
21
+
22
+ document.addEventListener('DOMContentLoaded', () => {
23
+ const {
24
+ STATE, initCanvases, resizeCanvases, mainRenderLoop, updateClock,
25
+ setState, startProcessingSimulation, startRealProcessing, startDetection,
26
+ showToast, cancelJob, resetToReady, togglePlayPause, switchDrawerTab,
27
+ renderWaveform, renderEventMarkers, wireEventMarkerTooltips,
28
+ renderTimelineLegend, initPlayheadDrag, updatePlayheadPosition,
29
+ handleKeyboardControls, handleCanvasMouseMove, handleCanvasClick,
30
+ handleCommand, showShortcutsHint, toggleEventLog,
31
+ MOCK_DENSITY, MOCK_EVENTS, renderRealDetections,
32
+ updateFrameCounter, updateTimeDisplay,
33
+ } = window.ISR;
34
+
35
+ // Task 8: Set initial state
36
+ setState('ready');
37
+
38
+ // Task 3: Clock
39
+ updateClock();
40
+ setInterval(updateClock, 1000);
41
+
42
+ // Task 4: Canvases + render loop
43
+ initCanvases();
44
+ window.addEventListener('resize', resizeCanvases);
45
+ requestAnimationFrame(mainRenderLoop);
46
+
47
+ // Task 3+4: Start button β€” real API if video file, mock fallback otherwise
48
+ document.getElementById('startBtn').addEventListener('click', async () => {
49
+ if (STATE.videoFile) {
50
+ // Gather config from controls
51
+ const activeMode = document.querySelector('#modeToggle .config-toggle-btn.active');
52
+ const mode = activeMode ? activeMode.dataset.mode : 'object_detection';
53
+ const config = {
54
+ mode: mode,
55
+ queries: document.getElementById('queriesInput').value || 'person,car,truck',
56
+ detector: document.getElementById('detectorSelect').value,
57
+ segmenter: document.getElementById('segmenterSelect').value,
58
+ enableDepth: document.getElementById('depthToggle').checked,
59
+ mission: document.getElementById('queriesInput').value || null,
60
+ };
61
+
62
+ STATE.jobConfig = config;
63
+ STATE.mission = config.mission;
64
+
65
+ try {
66
+ const result = await startDetection(STATE.videoFile, config);
67
+ const modelBadge = document.querySelector('#videoBadges .pill:first-child');
68
+ if (modelBadge && STATE.jobConfig) modelBadge.textContent = STATE.jobConfig.detector.toUpperCase();
69
+ startRealProcessing(result);
70
+ } catch (err) {
71
+ showToast('Failed to start detection: ' + err.message, 8000);
72
+ }
73
+ } else {
74
+ // Fallback to mock processing
75
+ startProcessingSimulation();
76
+ }
77
+ });
78
+
79
+ // Task 3: File upload handler
80
+ document.getElementById('videoInput').addEventListener('change', (e) => {
81
+ const file = e.target.files[0];
82
+ if (!file) return;
83
+ STATE.videoFile = file;
84
+ document.getElementById('fileName').textContent = file.name;
85
+ document.getElementById('fileUploadLabel').classList.add('has-file');
86
+ document.getElementById('startBtn').disabled = false;
87
+ });
88
+
89
+ // Task 4: Cancel button
90
+ document.getElementById('cancelBtn').addEventListener('click', () => {
91
+ if (STATE.jobId) {
92
+ cancelJob(STATE.jobId);
93
+ }
94
+ });
95
+
96
+ // Task 9: New Analysis button
97
+ document.getElementById('newAnalysisBtn').addEventListener('click', () => {
98
+ resetToReady();
99
+ });
100
+
101
+ // Play/Pause
102
+ document.getElementById('playPauseBtn').addEventListener('click', togglePlayPause);
103
+
104
+ // Speed
105
+ document.getElementById('speedSelect').addEventListener('change', (e) => {
106
+ ISR.playbackSpeed = parseFloat(e.target.value);
107
+ // Update video playback rate if playing real video
108
+ if (STATE.jobId) {
109
+ const processedVideo = document.getElementById('processedVideo');
110
+ if (processedVideo) processedVideo.playbackRate = ISR.playbackSpeed;
111
+ }
112
+ });
113
+
114
+ // Wire processedVideo timeupdate for real analysis β€” throttled to avoid flooding
115
+ let _lastTimeUpdate = 0;
116
+ let _renderInFlight = false;
117
+ document.getElementById('processedVideo').addEventListener('timeupdate', () => {
118
+ if (STATE.current !== 'analysis' && STATE.current !== 'playing' && STATE.current !== 'inspect') return;
119
+ const processedVideo = document.getElementById('processedVideo');
120
+ if (!processedVideo || !STATE.jobId) return;
121
+
122
+ const newFrame = Math.floor(processedVideo.currentTime * STATE.fps);
123
+ if (newFrame === STATE.playheadFrame) return; // same frame, skip
124
+
125
+ STATE.playheadFrame = newFrame;
126
+ ISR.updatePlayheadPosition();
127
+ updateTimeDisplay();
128
+ updateFrameCounter();
129
+
130
+ // Throttle detection rendering to max once per 333ms (matches frontend RAF sync interval)
131
+ const now = performance.now();
132
+ if (now - _lastTimeUpdate < 333 || _renderInFlight) return;
133
+ _lastTimeUpdate = now;
134
+ _renderInFlight = true;
135
+
136
+ const w = ISR.overlayCanvas.width;
137
+ const h = ISR.overlayCanvas.height;
138
+ renderRealDetections(ISR.overlayCtx, w, h).finally(() => { _renderInFlight = false; });
139
+ });
140
+
141
+ // Wire processedVideo ended event
142
+ document.getElementById('processedVideo').addEventListener('ended', () => {
143
+ if (!STATE.jobId) return;
144
+ STATE.isPlaying = false;
145
+ STATE.current = 'analysis';
146
+ document.body.setAttribute('data-state', 'analysis');
147
+ document.getElementById('playPauseBtn').innerHTML = '&#9654;';
148
+ });
149
+
150
+ // Task 5: Drawer tabs
151
+ document.querySelectorAll('.drawer-tab').forEach(tab => {
152
+ tab.addEventListener('click', () => {
153
+ // Respect state-driven tab restrictions via CSS pointer-events
154
+ switchDrawerTab(tab.dataset.tab);
155
+ });
156
+ });
157
+
158
+ // Task 3: Mode toggle with detector/segmenter visibility and default queries
159
+ document.querySelectorAll('#modeToggle .config-toggle-btn').forEach(btn => {
160
+ btn.addEventListener('click', () => {
161
+ document.querySelectorAll('#modeToggle .config-toggle-btn').forEach(b => b.classList.remove('active'));
162
+ btn.classList.add('active');
163
+
164
+ const mode = btn.dataset.mode;
165
+ const detectorGroup = document.getElementById('detectorGroup');
166
+ const segmenterGroup = document.getElementById('segmenterGroup');
167
+ const detectorSelect = document.getElementById('detectorSelect');
168
+ const queriesInput = document.getElementById('queriesInput');
169
+
170
+ if (mode === 'segmentation') {
171
+ detectorGroup.classList.add('hidden');
172
+ segmenterGroup.classList.remove('hidden');
173
+ queriesInput.value = 'object';
174
+ } else if (mode === 'drone_detection') {
175
+ detectorGroup.classList.remove('hidden');
176
+ segmenterGroup.classList.add('hidden');
177
+ detectorSelect.value = 'yolov8_visdrone';
178
+ queriesInput.value = 'drone';
179
+ } else {
180
+ // object_detection
181
+ detectorGroup.classList.remove('hidden');
182
+ segmenterGroup.classList.add('hidden');
183
+ queriesInput.value = 'person,car,truck';
184
+ }
185
+ });
186
+ });
187
+
188
+ // Init drawer to tracks tab
189
+ switchDrawerTab('tracks');
190
+
191
+ // Task 6: Timeline
192
+ const waveformCanvas = document.getElementById('waveformCanvas');
193
+ if (waveformCanvas) {
194
+ renderWaveform(waveformCanvas, MOCK_DENSITY);
195
+ window.addEventListener('resize', () => renderWaveform(waveformCanvas, MOCK_DENSITY));
196
+ }
197
+ renderEventMarkers(document.getElementById('eventMarkers'), MOCK_EVENTS);
198
+ wireEventMarkerTooltips();
199
+ renderTimelineLegend();
200
+ initPlayheadDrag();
201
+ updatePlayheadPosition();
202
+
203
+ // Event log toggle
204
+ document.getElementById('eventLogToggle').addEventListener('click', toggleEventLog);
205
+
206
+ // Task 7: Command bar β€” wired with executeAction support
207
+ const cmdInput = document.getElementById('commandInput');
208
+ cmdInput.addEventListener('keydown', (e) => {
209
+ if (e.key === 'Enter') {
210
+ const text = cmdInput.value.trim();
211
+ if (!text) return;
212
+ cmdInput.value = '';
213
+ handleCommand(text);
214
+ }
215
+ });
216
+
217
+ // Cmd+K / Ctrl+K shortcut with keyboard hints
218
+ document.addEventListener('keydown', (e) => {
219
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
220
+ e.preventDefault();
221
+ cmdInput.focus();
222
+ showShortcutsHint();
223
+ }
224
+ });
225
+
226
+ // Task 9: Keyboard controls
227
+ document.addEventListener('keydown', handleKeyboardControls);
228
+
229
+ // Task 9: Canvas mouse interactions (hover + click)
230
+ const overlayEl = document.getElementById('overlayCanvas');
231
+ overlayEl.addEventListener('mousemove', handleCanvasMouseMove);
232
+ overlayEl.addEventListener('click', handleCanvasClick);
233
+
234
+ console.log('[ISR Command Center] UI initialized. State:', STATE.current);
235
+ });
demo/js/inspect.js ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 10: Inspect State β€” 2x2 Quad View
5
+ * ================================================================ */
6
+
7
+ let threeScene = null;
8
+ let threeCamera = null;
9
+ let threeRenderer = null;
10
+ let threeMesh = null;
11
+ let threeAnimId = null;
12
+
13
+ function enterInspectState(trackId) {
14
+ // Clean up previous 3D viewer
15
+ destroy3DViewer();
16
+
17
+ ISR.STATE.selectedTrackId = trackId;
18
+ ISR.STATE.expandedQuadrant = null;
19
+ ISR.setState('inspect');
20
+
21
+ // Determine if we have real data
22
+ const hasRealData = !!(ISR.STATE.jobId);
23
+ let realTrack = null;
24
+ let mockTrack = null;
25
+
26
+ if (hasRealData && ISR.STATE._realTracks) {
27
+ realTrack = ISR.STATE._realTracks.find(t => t.track_id === trackId || t.track_id === String(trackId));
28
+ }
29
+ if (!realTrack) {
30
+ mockTrack = ISR.MOCK_TRACKS.find(t => t.id === trackId);
31
+ }
32
+
33
+ // If neither real nor mock track found, bail
34
+ const track = realTrack || mockTrack;
35
+ if (!track) return;
36
+
37
+ // Apply assessment cache to real track
38
+ if (realTrack) ISR.applyAssessmentCache(realTrack);
39
+
40
+ // Normalize track properties for display
41
+ const trackLabel = realTrack ? (realTrack.label || 'object').toUpperCase() : mockTrack.label.toUpperCase();
42
+ const trackColor = realTrack ? (realTrack.color || 'var(--accent)') : mockTrack.color;
43
+ const trackConf = realTrack ? (realTrack.score || 0).toFixed(2) : mockTrack.confidence.toFixed(2);
44
+
45
+ // Mission status badge for inspect header
46
+ let missionBadgeHTML = '';
47
+ if (realTrack) {
48
+ if (realTrack.satisfies === true) missionBadgeHTML = '<span class="status-badge match">MISSION MATCH</span>';
49
+ else if (realTrack.satisfies === false) missionBadgeHTML = '<span class="status-badge no-match">NO MATCH</span>';
50
+ else if (realTrack.assessment_status === 'PENDING_GPT') missionBadgeHTML = '<span class="status-badge pending">PENDING</span>';
51
+ }
52
+
53
+ // Reason text
54
+ const reasonText = realTrack && realTrack.reason ? realTrack.reason : '';
55
+
56
+ const panel = document.getElementById('inspectPanel');
57
+ panel.innerHTML = `
58
+ <div id="inspectHeader">
59
+ <button id="inspectBack">&larr; Back</button>
60
+ <span id="inspectLabel" style="color: ${trackColor};">${trackLabel}</span>
61
+ ${missionBadgeHTML}
62
+ <span id="inspectConf" class="value">${trackConf}</span>
63
+ </div>
64
+ ${reasonText ? `<div style="color: var(--text-secondary); font-size: 10px; margin-bottom: 8px; padding: 4px 0; line-height: 1.4; border-bottom: 1px solid var(--panel-border);">${reasonText}</div>` : ''}
65
+ <div id="quadGrid">
66
+ <div class="quadrant" data-quad="seg">
67
+ <div class="quad-label" style="color: var(--accent)">SEGMENTATION</div>
68
+ <canvas class="quad-canvas"></canvas>
69
+ <div class="quad-metric"></div>
70
+ </div>
71
+ <div class="quadrant" data-quad="edge">
72
+ <div class="quad-label" style="color: #94a3b8">EDGE</div>
73
+ <canvas class="quad-canvas"></canvas>
74
+ </div>
75
+ <div class="quadrant" data-quad="depth">
76
+ <div class="quad-label" style="color: var(--warning)">DEPTH</div>
77
+ <canvas class="quad-canvas"></canvas>
78
+ <div class="quad-metric"></div>
79
+ </div>
80
+ <div class="quadrant" data-quad="3d">
81
+ <div class="quad-label" style="color: var(--success)">3D MESH</div>
82
+ <div id="threeContainer"></div>
83
+ <div class="quad-metric"></div>
84
+ </div>
85
+ </div>
86
+ <div id="inspectMetrics"></div>
87
+ `;
88
+
89
+ // Render all 4 quadrants
90
+ requestAnimationFrame(() => {
91
+ const segCanvas = panel.querySelector('[data-quad="seg"] .quad-canvas');
92
+ const edgeCanvas = panel.querySelector('[data-quad="edge"] .quad-canvas');
93
+ const depthCanvas = panel.querySelector('[data-quad="depth"] .quad-canvas');
94
+ const threeContainer = document.getElementById('threeContainer');
95
+
96
+ if (hasRealData) {
97
+ // Real data: fetch from API in parallel
98
+ const frameIdx = ISR.STATE.playheadFrame || 0;
99
+ const tid = realTrack ? realTrack.track_id : trackId;
100
+ if (segCanvas) renderRealSegmentation(ISR.STATE.jobId, frameIdx, tid);
101
+ if (edgeCanvas) renderRealEdge(ISR.STATE.jobId, frameIdx, tid);
102
+ if (depthCanvas) renderRealDepth(ISR.STATE.jobId, frameIdx, tid);
103
+ if (threeContainer) renderReal3D(ISR.STATE.jobId, frameIdx, tid);
104
+ } else {
105
+ // Mock fallback
106
+ if (segCanvas) renderSegmentation(segCanvas, mockTrack);
107
+ if (edgeCanvas) renderEdgeDetection(edgeCanvas, mockTrack);
108
+ if (depthCanvas) renderDepthHeatmap(depthCanvas, mockTrack);
109
+ if (threeContainer) init3DViewer(threeContainer, mockTrack);
110
+
111
+ // Update quad metrics (mock)
112
+ const segMetric = panel.querySelector('[data-quad="seg"] .quad-metric');
113
+ const depthMetric = panel.querySelector('[data-quad="depth"] .quad-metric');
114
+ const meshMetric = panel.querySelector('[data-quad="3d"] .quad-metric');
115
+ if (segMetric) segMetric.textContent = `AREA ${mockTrack.area.toFixed(1)}%`;
116
+ if (depthMetric) depthMetric.textContent = `${mockTrack.depth.toFixed(1)}m`;
117
+ if (meshMetric) meshMetric.textContent = '12,847 pts';
118
+ }
119
+ });
120
+
121
+ // Inspect metrics strip
122
+ const metricsStrip = document.getElementById('inspectMetrics');
123
+ if (metricsStrip) {
124
+ if (hasRealData && realTrack) {
125
+ const speedStr = realTrack.speed_kph ? `${realTrack.speed_kph.toFixed(1)} kph` : '--';
126
+ const dirStr = realTrack.direction_clock ? `${realTrack.direction_clock}h` : '--';
127
+ const depthStr = realTrack.depth_est_m ? `${realTrack.depth_est_m.toFixed(1)}m` : '--';
128
+ const assessStr = realTrack.assessment_status || '--';
129
+ metricsStrip.innerHTML = `
130
+ <div class="inspect-metric-item"><div class="label">VELOCITY</div><div class="value">${speedStr}</div></div>
131
+ <div class="inspect-metric-item"><div class="label">CONFIDENCE</div><div class="value">${(realTrack.score || 0).toFixed(2)}</div></div>
132
+ <div class="inspect-metric-item"><div class="label">ID</div><div class="value">${realTrack.track_id}</div></div>
133
+ <div class="inspect-metric-item"><div class="label">DIRECTION</div><div class="value">${dirStr}</div></div>
134
+ <div class="inspect-metric-item"><div class="label">DEPTH</div><div class="value">${depthStr}</div></div>
135
+ <div class="inspect-metric-item"><div class="label">ASSESSMENT</div><div class="value">${assessStr}</div></div>
136
+ `;
137
+ } else if (mockTrack) {
138
+ metricsStrip.innerHTML = `
139
+ <div class="inspect-metric-item"><div class="label">VELOCITY</div><div class="value">${mockTrack.speed.toFixed(1)} kph</div></div>
140
+ <div class="inspect-metric-item"><div class="label">DEPTH</div><div class="value">${mockTrack.depth.toFixed(1)}m</div></div>
141
+ <div class="inspect-metric-item"><div class="label">AREA</div><div class="value">${mockTrack.area.toFixed(1)}%</div></div>
142
+ <div class="inspect-metric-item"><div class="label">CONFIDENCE</div><div class="value">${mockTrack.confidence.toFixed(2)}</div></div>
143
+ `;
144
+ }
145
+ }
146
+
147
+ // Force INSPECT tab when using real data
148
+ if (hasRealData) {
149
+ ISR.switchDrawerTab('inspect');
150
+ }
151
+
152
+ // Trigger explainability graph
153
+ if (hasRealData && ISR.STATE.jobId) {
154
+ const tid = realTrack ? realTrack.track_id : trackId;
155
+ ISR.loadExplainability(ISR.STATE.jobId, tid);
156
+ // Auto-switch to EXPLAIN tab after a brief delay so user sees it loading
157
+ setTimeout(() => ISR.switchDrawerTab('explain'), 300);
158
+ }
159
+
160
+ // Wire back button
161
+ document.getElementById('inspectBack').addEventListener('click', exitInspectState);
162
+
163
+ // Wire quadrant expand/collapse
164
+ panel.querySelectorAll('.quadrant').forEach(q => {
165
+ q.addEventListener('click', () => {
166
+ const quadName = q.dataset.quad;
167
+ if (ISR.STATE.expandedQuadrant === quadName) {
168
+ // Collapse
169
+ q.classList.remove('expanded');
170
+ ISR.STATE.expandedQuadrant = null;
171
+ // Show other quadrants
172
+ panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = '');
173
+ } else {
174
+ // Collapse any previous
175
+ panel.querySelectorAll('.quadrant.expanded').forEach(eq => eq.classList.remove('expanded'));
176
+ panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = '');
177
+ // Hide others, expand this one
178
+ panel.querySelectorAll('.quadrant').forEach(oq => {
179
+ if (oq.dataset.quad !== quadName) oq.style.display = 'none';
180
+ });
181
+ q.classList.add('expanded');
182
+ ISR.STATE.expandedQuadrant = quadName;
183
+ }
184
+ // Re-render canvases after layout change
185
+ requestAnimationFrame(() => {
186
+ if (hasRealData) {
187
+ const frameIdx = ISR.STATE.playheadFrame || 0;
188
+ const tid = realTrack ? realTrack.track_id : trackId;
189
+ const segC = panel.querySelector('[data-quad="seg"] .quad-canvas');
190
+ const edgeC = panel.querySelector('[data-quad="edge"] .quad-canvas');
191
+ const depthC = panel.querySelector('[data-quad="depth"] .quad-canvas');
192
+ if (segC && segC.offsetParent !== null) renderRealSegmentation(ISR.STATE.jobId, frameIdx, tid);
193
+ if (edgeC && edgeC.offsetParent !== null) renderRealEdge(ISR.STATE.jobId, frameIdx, tid);
194
+ if (depthC && depthC.offsetParent !== null) renderRealDepth(ISR.STATE.jobId, frameIdx, tid);
195
+ } else {
196
+ const segCanvas = panel.querySelector('[data-quad="seg"] .quad-canvas');
197
+ const edgeCanvas = panel.querySelector('[data-quad="edge"] .quad-canvas');
198
+ const depthCanvas = panel.querySelector('[data-quad="depth"] .quad-canvas');
199
+ if (segCanvas && segCanvas.offsetParent !== null) renderSegmentation(segCanvas, mockTrack);
200
+ if (edgeCanvas && edgeCanvas.offsetParent !== null) renderEdgeDetection(edgeCanvas, mockTrack);
201
+ if (depthCanvas && depthCanvas.offsetParent !== null) renderDepthHeatmap(depthCanvas, mockTrack);
202
+ }
203
+ // Resize 3D if visible
204
+ if (threeRenderer && document.getElementById('threeContainer') && document.getElementById('threeContainer').offsetParent !== null) {
205
+ const tc = document.getElementById('threeContainer');
206
+ threeRenderer.setSize(tc.clientWidth, tc.clientHeight);
207
+ if (threeCamera) {
208
+ threeCamera.aspect = tc.clientWidth / tc.clientHeight;
209
+ threeCamera.updateProjectionMatrix();
210
+ }
211
+ }
212
+ });
213
+ });
214
+ });
215
+
216
+ // Pause playback
217
+ ISR.STATE.isPlaying = false;
218
+ document.getElementById('playPauseBtn').innerHTML = '&#9654;';
219
+
220
+ if (hasRealData) {
221
+ // For real data, use current playhead frame
222
+ // Pause the processed video if playing
223
+ const processedVideo = document.getElementById('processedVideo');
224
+ if (processedVideo) processedVideo.pause();
225
+ } else if (mockTrack) {
226
+ // Jump to track midpoint for mock data
227
+ const midFrame = Math.round((mockTrack.keyframes[0].frame + mockTrack.keyframes[mockTrack.keyframes.length - 1].frame) / 2);
228
+ ISR.STATE.playheadFrame = midFrame;
229
+ }
230
+ ISR.updatePlayheadPosition();
231
+ ISR.updateFrameCounter();
232
+ ISR.updateTimeDisplay();
233
+ }
234
+
235
+ function exitInspectState() {
236
+ destroy3DViewer();
237
+ ISR.STATE.selectedTrackId = null;
238
+ ISR.STATE.expandedQuadrant = null;
239
+ ISR.setState('analysis');
240
+ ISR.switchDrawerTab('tracks');
241
+ // Use real tracks when jobId exists, otherwise mock
242
+ if (ISR.STATE.jobId && ISR.STATE._realTracks) {
243
+ ISR.renderTrackListFromData(ISR.STATE._realTracks);
244
+ } else {
245
+ ISR.renderTrackListFull(ISR.MOCK_TRACKS, ISR.STATE.trackFilter);
246
+ }
247
+ }
248
+
249
+ /* ── Segmentation Renderer ────────────────────────────────────── */
250
+
251
+ function renderSegmentation(canvas, track) {
252
+ const parent = canvas.parentElement;
253
+ const rect = parent.getBoundingClientRect();
254
+ const dpr = window.devicePixelRatio || 1;
255
+ canvas.width = rect.width * dpr;
256
+ canvas.height = rect.height * dpr;
257
+ canvas.style.width = rect.width + 'px';
258
+ canvas.style.height = rect.height + 'px';
259
+ const ctx = canvas.getContext('2d');
260
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
261
+ const w = rect.width;
262
+ const h = rect.height;
263
+
264
+ // Dark background
265
+ ctx.fillStyle = '#0a0f1a';
266
+ ctx.fillRect(0, 0, w, h);
267
+
268
+ // Grid
269
+ ctx.strokeStyle = 'rgba(255,255,255,0.03)';
270
+ ctx.lineWidth = 0.5;
271
+ for (let x = 0; x < w; x += 20) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
272
+ for (let y = 0; y < h; y += 20) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
273
+
274
+ // Draw mask silhouette based on type
275
+ const cx = w * 0.5;
276
+ const cy = h * 0.5;
277
+ ctx.beginPath();
278
+ if (track.type === 'person') {
279
+ // Organic blob for person
280
+ const s = Math.min(w, h) * 0.3;
281
+ ctx.moveTo(cx, cy - s);
282
+ ctx.bezierCurveTo(cx + s * 0.6, cy - s, cx + s * 0.7, cy - s * 0.3, cx + s * 0.5, cy);
283
+ ctx.bezierCurveTo(cx + s * 0.7, cy + s * 0.4, cx + s * 0.4, cy + s, cx, cy + s);
284
+ ctx.bezierCurveTo(cx - s * 0.4, cy + s, cx - s * 0.7, cy + s * 0.4, cx - s * 0.5, cy);
285
+ ctx.bezierCurveTo(cx - s * 0.7, cy - s * 0.3, cx - s * 0.6, cy - s, cx, cy - s);
286
+ } else if (track.type === 'vehicle') {
287
+ // Angular shape for vehicle
288
+ const sw = Math.min(w, h) * 0.4;
289
+ const sh = Math.min(w, h) * 0.25;
290
+ ctx.moveTo(cx - sw, cy - sh * 0.3);
291
+ ctx.lineTo(cx - sw * 0.6, cy - sh);
292
+ ctx.lineTo(cx + sw * 0.6, cy - sh);
293
+ ctx.lineTo(cx + sw, cy - sh * 0.3);
294
+ ctx.lineTo(cx + sw, cy + sh);
295
+ ctx.lineTo(cx - sw, cy + sh);
296
+ } else if (track.type === 'drone') {
297
+ // Triangular / cross shape for drone
298
+ const s = Math.min(w, h) * 0.25;
299
+ ctx.moveTo(cx, cy - s);
300
+ ctx.lineTo(cx + s * 1.2, cy + s * 0.3);
301
+ ctx.lineTo(cx + s * 0.3, cy + s * 0.1);
302
+ ctx.lineTo(cx, cy + s);
303
+ ctx.lineTo(cx - s * 0.3, cy + s * 0.1);
304
+ ctx.lineTo(cx - s * 1.2, cy + s * 0.3);
305
+ } else {
306
+ // Generic rectangle for stationary
307
+ const sw = Math.min(w, h) * 0.35;
308
+ const sh = Math.min(w, h) * 0.25;
309
+ ctx.rect(cx - sw, cy - sh, sw * 2, sh * 2);
310
+ }
311
+ ctx.closePath();
312
+
313
+ // Semi-transparent fill
314
+ ctx.fillStyle = 'rgba(59,130,246,0.3)';
315
+ ctx.fill();
316
+
317
+ // Glowing contour
318
+ ctx.shadowColor = '#3b82f6';
319
+ ctx.shadowBlur = 8;
320
+ ctx.strokeStyle = '#3b82f6';
321
+ ctx.lineWidth = 2;
322
+ ctx.stroke();
323
+ ctx.shadowBlur = 0;
324
+ }
325
+
326
+ /* ── Edge Detection Renderer ──────────────────────────────────── */
327
+
328
+ function renderEdgeDetection(canvas, track) {
329
+ const parent = canvas.parentElement;
330
+ const rect = parent.getBoundingClientRect();
331
+ const dpr = window.devicePixelRatio || 1;
332
+ canvas.width = rect.width * dpr;
333
+ canvas.height = rect.height * dpr;
334
+ canvas.style.width = rect.width + 'px';
335
+ canvas.style.height = rect.height + 'px';
336
+ const ctx = canvas.getContext('2d');
337
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
338
+ const w = rect.width;
339
+ const h = rect.height;
340
+
341
+ // Black background
342
+ ctx.fillStyle = '#000';
343
+ ctx.fillRect(0, 0, w, h);
344
+
345
+ const cx = w * 0.5;
346
+ const cy = h * 0.5;
347
+
348
+ function drawSilhouette(lineWidth, opacity) {
349
+ ctx.beginPath();
350
+ if (track.type === 'person') {
351
+ const s = Math.min(w, h) * 0.3;
352
+ ctx.moveTo(cx, cy - s);
353
+ ctx.bezierCurveTo(cx + s * 0.6, cy - s, cx + s * 0.7, cy - s * 0.3, cx + s * 0.5, cy);
354
+ ctx.bezierCurveTo(cx + s * 0.7, cy + s * 0.4, cx + s * 0.4, cy + s, cx, cy + s);
355
+ ctx.bezierCurveTo(cx - s * 0.4, cy + s, cx - s * 0.7, cy + s * 0.4, cx - s * 0.5, cy);
356
+ ctx.bezierCurveTo(cx - s * 0.7, cy - s * 0.3, cx - s * 0.6, cy - s, cx, cy - s);
357
+ } else if (track.type === 'vehicle') {
358
+ const sw = Math.min(w, h) * 0.4;
359
+ const sh = Math.min(w, h) * 0.25;
360
+ ctx.moveTo(cx - sw, cy - sh * 0.3);
361
+ ctx.lineTo(cx - sw * 0.6, cy - sh);
362
+ ctx.lineTo(cx + sw * 0.6, cy - sh);
363
+ ctx.lineTo(cx + sw, cy - sh * 0.3);
364
+ ctx.lineTo(cx + sw, cy + sh);
365
+ ctx.lineTo(cx - sw, cy + sh);
366
+ } else if (track.type === 'drone') {
367
+ const s = Math.min(w, h) * 0.25;
368
+ ctx.moveTo(cx, cy - s);
369
+ ctx.lineTo(cx + s * 1.2, cy + s * 0.3);
370
+ ctx.lineTo(cx + s * 0.3, cy + s * 0.1);
371
+ ctx.lineTo(cx, cy + s);
372
+ ctx.lineTo(cx - s * 0.3, cy + s * 0.1);
373
+ ctx.lineTo(cx - s * 1.2, cy + s * 0.3);
374
+ } else {
375
+ const sw = Math.min(w, h) * 0.35;
376
+ const sh = Math.min(w, h) * 0.25;
377
+ ctx.rect(cx - sw, cy - sh, sw * 2, sh * 2);
378
+ }
379
+ ctx.closePath();
380
+ ctx.strokeStyle = `rgba(255,255,255,${opacity})`;
381
+ ctx.lineWidth = lineWidth;
382
+ ctx.stroke();
383
+ }
384
+
385
+ // Outer edge β€” strong
386
+ drawSilhouette(2.5, 0.9);
387
+ // Inner detail lines β€” weaker
388
+ drawSilhouette(1, 0.35);
389
+
390
+ // Secondary inner detail: offset slightly inward
391
+ ctx.save();
392
+ ctx.translate(0, 0);
393
+ ctx.scale(0.88, 0.88);
394
+ ctx.translate(w * 0.5 * (1 - 1/0.88), h * 0.5 * (1 - 1/0.88));
395
+ drawSilhouette(0.8, 0.2);
396
+ ctx.restore();
397
+ }
398
+
399
+ /* ── Depth Heatmap Renderer ───────────────────────────────────── */
400
+
401
+ function renderDepthHeatmap(canvas, track) {
402
+ const parent = canvas.parentElement;
403
+ const rect = parent.getBoundingClientRect();
404
+ const dpr = window.devicePixelRatio || 1;
405
+ canvas.width = rect.width * dpr;
406
+ canvas.height = rect.height * dpr;
407
+ canvas.style.width = rect.width + 'px';
408
+ canvas.style.height = rect.height + 'px';
409
+ const ctx = canvas.getContext('2d');
410
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
411
+ const w = rect.width;
412
+ const h = rect.height;
413
+
414
+ // Viridis-style gradient background
415
+ const bgGrad = ctx.createLinearGradient(0, 0, w, h);
416
+ bgGrad.addColorStop(0, '#440154'); // deep purple
417
+ bgGrad.addColorStop(0.25, '#31688e'); // blue
418
+ bgGrad.addColorStop(0.5, '#35b779'); // green
419
+ bgGrad.addColorStop(0.75, '#90d743'); // lime
420
+ bgGrad.addColorStop(1, '#fde725'); // yellow
421
+ ctx.fillStyle = bgGrad;
422
+ ctx.fillRect(0, 0, w, h);
423
+
424
+ // Warm "closer" region at object center
425
+ const cx = w * 0.5;
426
+ const cy = h * 0.5;
427
+ const maxR = Math.min(w, h) * 0.45;
428
+
429
+ const radGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
430
+ radGrad.addColorStop(0, 'rgba(253,231,37,0.7)'); // warm yellow
431
+ radGrad.addColorStop(0.3, 'rgba(253,231,37,0.4)');
432
+ radGrad.addColorStop(0.6, 'rgba(144,215,67,0.2)');
433
+ radGrad.addColorStop(1, 'rgba(68,1,84,0)'); // transparent at edges
434
+ ctx.fillStyle = radGrad;
435
+ ctx.fillRect(0, 0, w, h);
436
+
437
+ // Second warm spot offset
438
+ const ox = cx + w * 0.15;
439
+ const oy = cy - h * 0.1;
440
+ const rad2 = ctx.createRadialGradient(ox, oy, 0, ox, oy, maxR * 0.5);
441
+ rad2.addColorStop(0, 'rgba(253,231,37,0.35)');
442
+ rad2.addColorStop(1, 'rgba(68,1,84,0)');
443
+ ctx.fillStyle = rad2;
444
+ ctx.fillRect(0, 0, w, h);
445
+ }
446
+
447
+ /* ── 3D Mesh Viewer ───────────────────────────────────────────── */
448
+
449
+ function init3DViewer(container, track) {
450
+ if (!window.THREE) return;
451
+
452
+ const w = container.clientWidth || 150;
453
+ const h = container.clientHeight || 120;
454
+
455
+ threeScene = new THREE.Scene();
456
+ threeScene.background = new THREE.Color('#0a0f1a');
457
+
458
+ threeCamera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
459
+ threeCamera.position.set(3, 2, 3);
460
+ threeCamera.lookAt(0, 0, 0);
461
+
462
+ threeRenderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
463
+ threeRenderer.setSize(w, h);
464
+ threeRenderer.setPixelRatio(window.devicePixelRatio || 1);
465
+ container.appendChild(threeRenderer.domElement);
466
+
467
+ // Geometry by type
468
+ let geometry;
469
+ if (track.type === 'vehicle') {
470
+ geometry = new THREE.BoxGeometry(1.6, 0.8, 1);
471
+ } else if (track.type === 'person') {
472
+ geometry = new THREE.CylinderGeometry(0.4, 0.4, 1.6, 12);
473
+ } else if (track.type === 'drone') {
474
+ geometry = new THREE.ConeGeometry(0.6, 1.2, 6);
475
+ } else {
476
+ geometry = new THREE.BoxGeometry(1.2, 0.6, 1.2);
477
+ }
478
+
479
+ const color = new THREE.Color(track.color);
480
+ const material = new THREE.MeshBasicMaterial({
481
+ color: color,
482
+ wireframe: true,
483
+ transparent: true,
484
+ opacity: 0.7,
485
+ });
486
+
487
+ threeMesh = new THREE.Mesh(geometry, material);
488
+ threeScene.add(threeMesh);
489
+
490
+ // Lighting
491
+ const ambient = new THREE.AmbientLight(0x404040);
492
+ threeScene.add(ambient);
493
+ const point = new THREE.PointLight(0xffffff, 0.8, 50);
494
+ point.position.set(5, 5, 5);
495
+ threeScene.add(point);
496
+
497
+ // Auto-rotation animation
498
+ let angle = 0;
499
+ function animate() {
500
+ threeAnimId = requestAnimationFrame(animate);
501
+ angle += 0.01;
502
+ threeCamera.position.x = 3 * Math.cos(angle);
503
+ threeCamera.position.z = 3 * Math.sin(angle);
504
+ threeCamera.lookAt(0, 0, 0);
505
+ threeMesh.rotation.y += 0.005;
506
+ threeRenderer.render(threeScene, threeCamera);
507
+ }
508
+ animate();
509
+ }
510
+
511
+ function destroy3DViewer() {
512
+ if (threeAnimId) {
513
+ cancelAnimationFrame(threeAnimId);
514
+ threeAnimId = null;
515
+ }
516
+ if (threeRenderer) {
517
+ threeRenderer.dispose();
518
+ if (threeRenderer.domElement && threeRenderer.domElement.parentElement) {
519
+ threeRenderer.domElement.parentElement.removeChild(threeRenderer.domElement);
520
+ }
521
+ threeRenderer = null;
522
+ }
523
+ if (threeMesh) {
524
+ if (threeMesh.geometry) threeMesh.geometry.dispose();
525
+ if (threeMesh.material) threeMesh.material.dispose();
526
+ threeMesh = null;
527
+ }
528
+ threeScene = null;
529
+ threeCamera = null;
530
+ }
531
+
532
+ /* ── Real Inspect Renderers (API-backed) ──────────────────────── */
533
+
534
+ function showQuadPlaceholder(ctx, canvas, text) {
535
+ ctx.fillStyle = '#111';
536
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
537
+ ctx.fillStyle = 'rgba(255,255,255,0.2)';
538
+ ctx.font = '12px Inter, sans-serif';
539
+ ctx.textAlign = 'center';
540
+ ctx.fillText(text, canvas.width / 2, canvas.height / 2);
541
+ }
542
+
543
+ async function renderRealSegmentation(jobId, frameIdx, trackId) {
544
+ const canvas = document.querySelector('[data-quad="seg"] .quad-canvas');
545
+ if (!canvas) return;
546
+ const ctx = canvas.getContext('2d');
547
+ const metric = document.querySelector('[data-quad="seg"] .quad-metric');
548
+ const dpr = window.devicePixelRatio || 1;
549
+ canvas.width = canvas.offsetWidth * dpr;
550
+ canvas.height = canvas.offsetHeight * dpr;
551
+ try {
552
+ // Fetch the cropped frame image first (for overlay rendering)
553
+ const frameBlob = await ISR.fetchFrame(jobId, frameIdx, trackId);
554
+ const maskData = await ISR.fetchMask(jobId, frameIdx, trackId);
555
+ if (!maskData) { showQuadPlaceholder(ctx, canvas, 'MASK UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
556
+
557
+ // If we have a frame, render mask overlay on it (like frontend)
558
+ if (frameBlob && maskData.rle) {
559
+ const frameImg = await createImageBitmap(frameBlob);
560
+ const w = canvas.width, h = canvas.height;
561
+
562
+ // Draw dimmed frame
563
+ ctx.drawImage(frameImg, 0, 0, w, h);
564
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
565
+ ctx.fillRect(0, 0, w, h);
566
+
567
+ // Decode COCO RLE mask (column-major)
568
+ const rle = maskData.rle;
569
+ const maskH = rle.size[0], maskW = rle.size[1];
570
+ const mask = new Uint8Array(maskH * maskW);
571
+ let pos = 0, val = 0;
572
+ const counts = typeof rle.counts === 'string' ? ISR._decodeRLEString(rle.counts) : rle.counts;
573
+ for (const count of counts) {
574
+ for (let i = 0; i < count && pos < mask.length; i++) mask[pos++] = val;
575
+ val = 1 - val;
576
+ }
577
+
578
+ // Compute crop region matching backend's crop_frame(bbox, padding=0.20)
579
+ const padding = 0.20;
580
+ let crop = null;
581
+ if (maskData.bbox) {
582
+ const [bx1, by1, bx2, by2] = maskData.bbox;
583
+ const bw = bx2 - bx1, bh = by2 - by1;
584
+ const padX = Math.floor(bw * padding), padY = Math.floor(bh * padding);
585
+ crop = { cx1: Math.max(0, bx1 - padX), cy1: Math.max(0, by1 - padY),
586
+ cx2: Math.min(maskW, bx2 + padX), cy2: Math.min(maskH, by2 + padY) };
587
+ }
588
+
589
+ const color = maskData.color || [59, 130, 246];
590
+ // Get frame pixel data for blending
591
+ const frameCanvas2 = document.createElement('canvas');
592
+ frameCanvas2.width = w; frameCanvas2.height = h;
593
+ const fctx = frameCanvas2.getContext('2d');
594
+ fctx.drawImage(frameImg, 0, 0, w, h);
595
+ const framePixels = fctx.getImageData(0, 0, w, h);
596
+
597
+ const outPixels = ctx.getImageData(0, 0, w, h);
598
+ const out = outPixels.data, fd = framePixels.data;
599
+
600
+ for (let py = 0; py < h; py++) {
601
+ for (let px = 0; px < w; px++) {
602
+ let mx, my;
603
+ if (crop) {
604
+ mx = Math.floor(crop.cx1 + px * (crop.cx2 - crop.cx1) / w);
605
+ my = Math.floor(crop.cy1 + py * (crop.cy2 - crop.cy1) / h);
606
+ } else {
607
+ mx = Math.floor(px * maskW / w);
608
+ my = Math.floor(py * maskH / h);
609
+ }
610
+ const maskIdx = mx * maskH + my; // COCO RLE is column-major
611
+ if (maskIdx >= 0 && maskIdx < mask.length && mask[maskIdx]) {
612
+ const i = (py * w + px) * 4;
613
+ out[i] = Math.round(fd[i] * 0.5 + color[0] * 0.5);
614
+ out[i + 1] = Math.round(fd[i + 1] * 0.5 + color[1] * 0.5);
615
+ out[i + 2] = Math.round(fd[i + 2] * 0.5 + color[2] * 0.5);
616
+ out[i + 3] = 255;
617
+ }
618
+ }
619
+ }
620
+ ctx.putImageData(outPixels, 0, 0);
621
+ } else if (frameBlob) {
622
+ // Fallback: just draw the frame
623
+ const img = await createImageBitmap(frameBlob);
624
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
625
+ }
626
+ if (metric) metric.textContent = maskData.area ? `${maskData.area.toLocaleString()} px` : 'MASK';
627
+ } catch { showQuadPlaceholder(ctx, canvas, 'ERROR'); }
628
+ }
629
+
630
+ async function renderRealEdge(jobId, frameIdx, trackId) {
631
+ const canvas = document.querySelector('[data-quad="edge"] .quad-canvas');
632
+ if (!canvas) return;
633
+ const ctx = canvas.getContext('2d');
634
+ const dpr = window.devicePixelRatio || 1;
635
+ canvas.width = canvas.offsetWidth * dpr;
636
+ canvas.height = canvas.offsetHeight * dpr;
637
+ try {
638
+ const blob = await ISR.fetchFrame(jobId, frameIdx, trackId);
639
+ if (!blob) { showQuadPlaceholder(ctx, canvas, 'FRAME UNAVAILABLE'); return; }
640
+ const img = await createImageBitmap(blob);
641
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
642
+ // Apply Sobel filter
643
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
644
+ const sobelData = ISR.applySobel(imageData);
645
+ ctx.putImageData(sobelData, 0, 0);
646
+ } catch { showQuadPlaceholder(ctx, canvas, 'ERROR'); }
647
+ }
648
+
649
+ async function renderRealDepth(jobId, frameIdx, trackId) {
650
+ const canvas = document.querySelector('[data-quad="depth"] .quad-canvas');
651
+ if (!canvas) return;
652
+ const ctx = canvas.getContext('2d');
653
+ const metric = document.querySelector('[data-quad="depth"] .quad-metric');
654
+ const dpr = window.devicePixelRatio || 1;
655
+ canvas.width = canvas.offsetWidth * dpr;
656
+ canvas.height = canvas.offsetHeight * dpr;
657
+ try {
658
+ const depthData = await ISR.fetchDepth(jobId, frameIdx, trackId);
659
+ if (!depthData || !depthData.data) { showQuadPlaceholder(ctx, canvas, 'DEPTH UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
660
+
661
+ // Render viridis-like colormap from float32 depth values
662
+ const dw = depthData.width, dh = depthData.height;
663
+ const dd = depthData.data;
664
+ const minD = depthData.min, maxD = depthData.max;
665
+ const range = maxD - minD || 1;
666
+
667
+ const depthCanvas = document.createElement('canvas');
668
+ depthCanvas.width = dw; depthCanvas.height = dh;
669
+ const dctx = depthCanvas.getContext('2d');
670
+ const img = dctx.createImageData(dw, dh);
671
+ const id = img.data;
672
+
673
+ for (let i = 0; i < dd.length; i++) {
674
+ const val = dd[i];
675
+ if (!isFinite(val)) { const oi = i * 4; id[oi] = 0; id[oi+1] = 0; id[oi+2] = 0; id[oi+3] = 255; continue; }
676
+ const t = Math.max(0, Math.min(1, (val - minD) / range));
677
+ const rgb = ISR._viridis(t);
678
+ const oi = i * 4;
679
+ id[oi] = rgb[0]; id[oi+1] = rgb[1]; id[oi+2] = rgb[2]; id[oi+3] = 255;
680
+ }
681
+ dctx.putImageData(img, 0, 0);
682
+ ctx.drawImage(depthCanvas, 0, 0, canvas.width, canvas.height);
683
+ if (metric) metric.textContent = `${minD.toFixed(1)}–${maxD.toFixed(1)}m`;
684
+ } catch { showQuadPlaceholder(ctx, canvas, 'ERROR'); }
685
+ }
686
+
687
+ async function renderReal3D(jobId, frameIdx, trackId) {
688
+ const container = document.getElementById('threeContainer');
689
+ if (!container) return;
690
+ const metric = document.querySelector('[data-quad="3d"] .quad-metric');
691
+ try {
692
+ const data = await ISR.fetchPointCloud(jobId, frameIdx, trackId);
693
+ if (!data) { if (metric) metric.textContent = 'UNAVAILABLE'; return; }
694
+ destroy3DViewer();
695
+
696
+ // Decode base64 to typed arrays
697
+ const positions = new Float32Array(Uint8Array.from(atob(data.positions), c => c.charCodeAt(0)).buffer);
698
+ const colors = new Uint8Array(Uint8Array.from(atob(data.colors), c => c.charCodeAt(0)).buffer);
699
+
700
+ const geometry = new THREE.BufferGeometry();
701
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
702
+ // Convert uint8 colors to float [0,1]
703
+ const floatColors = new Float32Array(colors.length);
704
+ for (let i = 0; i < colors.length; i++) floatColors[i] = colors[i] / 255;
705
+ geometry.setAttribute('color', new THREE.BufferAttribute(floatColors, 3));
706
+
707
+ if (data.indices) {
708
+ const indices = new Uint32Array(Uint8Array.from(atob(data.indices), c => c.charCodeAt(0)).buffer);
709
+ geometry.setIndex(new THREE.BufferAttribute(indices, 1));
710
+ }
711
+ geometry.computeBoundingSphere();
712
+
713
+ const scene = new THREE.Scene();
714
+ scene.background = new THREE.Color(0x0a0f1a);
715
+ const material = data.indices
716
+ ? new THREE.MeshBasicMaterial({ vertexColors: true, wireframe: true, transparent: true, opacity: 0.7 })
717
+ : new THREE.PointsMaterial({ vertexColors: true, size: 0.02 });
718
+ const obj = data.indices ? new THREE.Mesh(geometry, material) : new THREE.Points(geometry, material);
719
+ scene.add(obj);
720
+
721
+ const center = geometry.boundingSphere.center.clone();
722
+ const radius = geometry.boundingSphere.radius || 2;
723
+ const camera = new THREE.PerspectiveCamera(45, container.offsetWidth / container.offsetHeight, 0.1, 1000);
724
+ camera.position.set(center.x + radius * 2, center.y + radius, center.z + radius * 2);
725
+ camera.lookAt(center);
726
+
727
+ const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
728
+ renderer.setSize(container.offsetWidth, container.offsetHeight);
729
+ renderer.setPixelRatio(window.devicePixelRatio);
730
+ container.innerHTML = '';
731
+ container.appendChild(renderer.domElement);
732
+
733
+ // Store for cleanup
734
+ threeRenderer = renderer;
735
+ threeScene = scene;
736
+ threeCamera = camera;
737
+
738
+ let angle = 0;
739
+ function animate() {
740
+ threeAnimId = requestAnimationFrame(animate);
741
+ angle += 0.01;
742
+ camera.position.x = center.x + Math.cos(angle) * radius * 2;
743
+ camera.position.z = center.z + Math.sin(angle) * radius * 2;
744
+ camera.lookAt(center);
745
+ renderer.render(scene, camera);
746
+ }
747
+ animate();
748
+ if (metric) metric.textContent = `${(data.num_vertices || 0).toLocaleString()} pts`;
749
+ } catch { if (metric) metric.textContent = 'ERROR'; }
750
+ }
751
+
752
+ // ── Export to namespace ─────────────────────────────────────────
753
+
754
+ Object.assign(window.ISR, {
755
+ enterInspectState,
756
+ exitInspectState,
757
+ renderSegmentation,
758
+ renderEdgeDetection,
759
+ renderDepthHeatmap,
760
+ init3DViewer,
761
+ destroy3DViewer,
762
+ showQuadPlaceholder,
763
+ renderRealSegmentation,
764
+ renderRealEdge,
765
+ renderRealDepth,
766
+ renderReal3D,
767
+ });
demo/js/real-backend.js ADDED
@@ -0,0 +1,1337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 4: Real Processing β€” MJPEG Stream + Polling + Cancel
5
+ * ================================================================ */
6
+
7
+ let pollingInterval = null;
8
+ let pulseAnimId = null;
9
+
10
+ function startRealProcessing(result) {
11
+ const jobId = result.job_id;
12
+ ISR.STATE.jobId = jobId;
13
+ // Store resolved URLs from the response (matches frontend pattern)
14
+ ISR.STATE._statusUrl = ISR.resolveUrl(result.status_url);
15
+ ISR.STATE._videoUrl = ISR.resolveUrl(result.video_url);
16
+ ISR.STATE._streamUrl = ISR.resolveUrl(result.stream_url);
17
+ ISR.STATE._depthVideoUrl = ISR.resolveUrl(result.depth_video_url);
18
+
19
+ // Fade out start button
20
+ const startBtn = document.getElementById('startBtn');
21
+ if (startBtn) startBtn.classList.add('fading-out');
22
+
23
+ // Clear box animation tracking
24
+ Object.keys(ISR.boxFirstSeen).forEach(k => delete ISR.boxFirstSeen[k]);
25
+
26
+ setTimeout(() => {
27
+ ISR.setState('processing');
28
+
29
+ // Hide detection overlay during processing (MJPEG already has boxes baked in)
30
+ const detOverlay = document.getElementById('detectionOverlay');
31
+ if (detOverlay) detOverlay.innerHTML = '';
32
+ const overlayCanvas = document.getElementById('overlayCanvas');
33
+ if (overlayCanvas) overlayCanvas.style.display = 'none';
34
+
35
+ // Clear timeline waveform and event markers (will be rebuilt from real data)
36
+ const waveformCanvas = document.getElementById('waveformCanvas');
37
+ if (waveformCanvas) {
38
+ const wCtx = waveformCanvas.getContext('2d');
39
+ if (wCtx) wCtx.clearRect(0, 0, waveformCanvas.width, waveformCanvas.height);
40
+ }
41
+ const eventMarkers = document.getElementById('eventMarkers');
42
+ if (eventMarkers) eventMarkers.innerHTML = '';
43
+ const timelineLegend = document.getElementById('timelineLegend');
44
+ if (timelineLegend) timelineLegend.innerHTML = '';
45
+ // Reset playhead to start
46
+ ISR.STATE.playheadFrame = 0;
47
+ ISR.updatePlayheadPosition();
48
+ const timeStart = document.getElementById('timeStart');
49
+ if (timeStart) timeStart.textContent = '00:00';
50
+ const timeEnd = document.getElementById('timeEnd');
51
+ if (timeEnd) timeEnd.textContent = '--:--';
52
+
53
+ // Show MJPEG stream
54
+ const mjpeg = document.getElementById('mjpegStream');
55
+ if (mjpeg) {
56
+ mjpeg.src = ISR.STATE._streamUrl || `${ISR.API_BASE}/detect/stream/${jobId}`;
57
+ mjpeg.classList.remove('hidden');
58
+ // Hide progress circle once MJPEG stream loads its first frame
59
+ mjpeg.onload = () => {
60
+ const progressOverlay = document.getElementById('progressOverlay');
61
+ if (progressOverlay) progressOverlay.classList.add('hidden');
62
+ };
63
+ }
64
+
65
+ // Show cancel button
66
+ const cancelBtn = document.getElementById('cancelBtn');
67
+ if (cancelBtn) cancelBtn.classList.remove('hidden');
68
+
69
+ // Indeterminate progress animation (pulsing ring) β€” shown initially, hidden when stream loads
70
+ const circle = document.getElementById('progressCircle');
71
+ const pctEl = document.getElementById('progressPct');
72
+ if (circle) circle.classList.add('indeterminate');
73
+ if (pctEl) pctEl.textContent = 'PROCESSING...';
74
+
75
+ // Top bar progress β€” indeterminate sweep
76
+ const topBarProgress = document.getElementById('topBarProgress');
77
+
78
+ let consecutiveErrors = 0;
79
+
80
+ // Poll status every 3000ms
81
+ pollingInterval = setInterval(async () => {
82
+ try {
83
+ const status = await ISR.pollStatus(jobId);
84
+ consecutiveErrors = 0; // reset on success
85
+
86
+ // Update progress if available
87
+ if (status.progress !== undefined && status.progress !== null) {
88
+ const pct = Math.round(status.progress * 100);
89
+ if (pctEl) pctEl.textContent = pct + '%';
90
+ if (circle) {
91
+ circle.classList.remove('indeterminate');
92
+ const circumference = 2 * Math.PI * 34;
93
+ circle.style.strokeDashoffset = circumference - (status.progress * circumference);
94
+ }
95
+ if (topBarProgress) topBarProgress.style.width = pct + '%';
96
+ }
97
+
98
+ // Update frame count if available
99
+ if (status.frames_processed !== undefined) {
100
+ ISR.STATE.playheadFrame = status.frames_processed;
101
+ ISR.updateFrameCounter();
102
+ }
103
+ if (status.total_frames !== undefined && status.total_frames > 0) {
104
+ ISR.STATE.totalFrames = status.total_frames;
105
+ }
106
+
107
+ if (status.status === 'completed') {
108
+ clearInterval(pollingInterval);
109
+ pollingInterval = null;
110
+ await enterRealAnalysis(jobId, status);
111
+ } else if (status.status === 'failed') {
112
+ clearInterval(pollingInterval);
113
+ pollingInterval = null;
114
+ ISR.showToast('Detection failed: ' + (status.error || 'Unknown error'), 8000);
115
+ resetToReady();
116
+ } else if (status.status === 'cancelled') {
117
+ clearInterval(pollingInterval);
118
+ pollingInterval = null;
119
+ ISR.showToast('Detection cancelled.', 5000);
120
+ resetToReady();
121
+ }
122
+ } catch (err) {
123
+ consecutiveErrors++;
124
+ console.warn('[ISR] Poll error:', err.message, `(${consecutiveErrors}/6)`);
125
+ if (consecutiveErrors >= 6) {
126
+ clearInterval(pollingInterval);
127
+ pollingInterval = null;
128
+ ISR.showToast('Lost connection to server. Returning to ready state.', 8000);
129
+ resetToReady();
130
+ }
131
+ }
132
+ }, 3000);
133
+ }, 300);
134
+ }
135
+
136
+ function cleanupProcessing() {
137
+ // Remove MJPEG src
138
+ const mjpeg = document.getElementById('mjpegStream');
139
+ if (mjpeg) {
140
+ mjpeg.src = '';
141
+ mjpeg.classList.add('hidden');
142
+ }
143
+
144
+ // Hide cancel button
145
+ const cancelBtn = document.getElementById('cancelBtn');
146
+ if (cancelBtn) cancelBtn.classList.add('hidden');
147
+
148
+ // Reset progress ring
149
+ const circle = document.getElementById('progressCircle');
150
+ const pctEl = document.getElementById('progressPct');
151
+ if (circle) {
152
+ circle.classList.remove('indeterminate');
153
+ circle.style.strokeDashoffset = 2 * Math.PI * 34;
154
+ }
155
+ if (pctEl) pctEl.textContent = '0%';
156
+
157
+ // Reset top bar progress
158
+ const topBarProgress = document.getElementById('topBarProgress');
159
+ if (topBarProgress) {
160
+ topBarProgress.style.width = '0%';
161
+ topBarProgress.style.opacity = '';
162
+ }
163
+
164
+ // Restore canvas opacity
165
+ const videoCanvas = document.getElementById('videoCanvas');
166
+ if (videoCanvas) videoCanvas.style.opacity = '';
167
+
168
+ // Clear polling
169
+ if (pollingInterval) {
170
+ clearInterval(pollingInterval);
171
+ pollingInterval = null;
172
+ }
173
+ }
174
+
175
+ function resetToReady() {
176
+ if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; }
177
+ ISR.STATE.jobId = null;
178
+ ISR.STATE.jobStatus = null;
179
+ ISR.STATE._statusUrl = null;
180
+ ISR.STATE._videoUrl = null;
181
+ ISR.STATE._streamUrl = null;
182
+ ISR.STATE._depthVideoUrl = null;
183
+ ISR.STATE._realTracks = null;
184
+ ISR.STATE._realTrackIndex = null;
185
+ ISR.STATE._timelineSummary = null;
186
+ ISR.STATE._analysisStartTime = null;
187
+ ISR.STATE._lastRenderedFrame = -1;
188
+ ISR.STATE.chatHistory = [];
189
+ ISR.STATE.selectedTrackId = null;
190
+ ISR.STATE.highlightTrackId = null;
191
+ ISR.STATE.trackFilter = null;
192
+ ISR.STATE.isPlaying = false;
193
+ ISR.STATE._videoBlobUrl = null;
194
+ ISR.STATE._currentFrameTracks = null;
195
+ ISR.clearTrackCache();
196
+ ISR.clearAssessmentCache();
197
+
198
+ // Clear SVG overlay
199
+ const svgOverlay = document.getElementById('detectionOverlay');
200
+ if (svgOverlay) while (svgOverlay.firstChild) svgOverlay.removeChild(svgOverlay.firstChild);
201
+
202
+ // Stop MJPEG
203
+ const mjpeg = document.getElementById('mjpegStream');
204
+ if (mjpeg) { mjpeg.src = ''; mjpeg.classList.add('hidden'); }
205
+
206
+ // Stop and cleanup video
207
+ const video = document.getElementById('processedVideo');
208
+ if (video) {
209
+ video.pause();
210
+ if (video.src && video.src.startsWith('blob:')) URL.revokeObjectURL(video.src);
211
+ video.src = '';
212
+ video.classList.add('hidden');
213
+ }
214
+
215
+ // Cleanup 3D viewer
216
+ ISR.destroy3DViewer();
217
+
218
+ // Restore UI
219
+ document.getElementById('videoCanvas').style.opacity = '1';
220
+ const oc = document.getElementById('overlayCanvas');
221
+ if (oc) { oc.style.display = ''; oc.style.opacity = '0'; }
222
+ document.getElementById('progressOverlay').classList.add('hidden');
223
+ const cancelBtn = document.getElementById('cancelBtn');
224
+ if (cancelBtn) cancelBtn.classList.add('hidden');
225
+
226
+ // Reset start button
227
+ const startBtn = document.getElementById('startBtn');
228
+ if (startBtn) startBtn.classList.remove('fading-out');
229
+
230
+ // Reset progress ring
231
+ const circle = document.getElementById('progressCircle');
232
+ const pctEl = document.getElementById('progressPct');
233
+ if (circle) {
234
+ circle.classList.remove('indeterminate');
235
+ circle.style.strokeDashoffset = 2 * Math.PI * 34;
236
+ }
237
+ if (pctEl) pctEl.textContent = '0%';
238
+
239
+ // Reset top bar progress
240
+ const topBarProgress = document.getElementById('topBarProgress');
241
+ if (topBarProgress) {
242
+ topBarProgress.style.width = '0%';
243
+ topBarProgress.style.opacity = '';
244
+ }
245
+
246
+ ISR.setState('ready');
247
+ }
248
+
249
+ async function enterRealAnalysis(jobId, status) {
250
+ cleanupProcessing();
251
+
252
+ // 1. Stop MJPEG stream
253
+ const mjpeg = document.getElementById('mjpegStream');
254
+ if (mjpeg) { mjpeg.src = ''; mjpeg.classList.add('hidden'); }
255
+
256
+ // 2. Hide progress overlay and cancel button
257
+ const progressOverlay = document.getElementById('progressOverlay');
258
+ if (progressOverlay) progressOverlay.classList.add('hidden');
259
+ const cancelBtnEl = document.getElementById('cancelBtn');
260
+ if (cancelBtnEl) cancelBtnEl.classList.add('hidden');
261
+ const startBtn = document.getElementById('startBtn');
262
+ if (startBtn) startBtn.classList.remove('fading-out');
263
+
264
+ ISR.showToast('Detection complete! Loading video...', 4000);
265
+
266
+ // === PHASE 1: Load video + summary (FAST β€” 2 requests max) ===
267
+
268
+ // 3. Fetch processed video with retry
269
+ const processedVideo = document.getElementById('processedVideo');
270
+ let videoBlobUrl = null;
271
+ for (let attempt = 0; attempt < 10; attempt++) {
272
+ try {
273
+ const videoUrl = ISR.STATE._videoUrl || `${ISR.API_BASE}/detect/video/${jobId}`;
274
+ const res = await fetch(videoUrl, { cache: 'no-store' });
275
+ if (res.status === 410) { ISR.showToast('Job was cancelled.', 6000); resetToReady(); return; }
276
+ if (res.status === 202) { await new Promise(r => setTimeout(r, 1000)); continue; }
277
+ if (res.ok) {
278
+ const blob = await res.blob();
279
+ videoBlobUrl = URL.createObjectURL(blob);
280
+ ISR.STATE._videoBlobUrl = videoBlobUrl;
281
+ break;
282
+ }
283
+ } catch (err) {
284
+ console.warn('[ISR] Video fetch attempt', attempt, err);
285
+ await new Promise(r => setTimeout(r, 1000));
286
+ }
287
+ }
288
+
289
+ if (!videoBlobUrl) {
290
+ ISR.showToast('Failed to load processed video.', 6000);
291
+ }
292
+
293
+ // 4. Set video src and wait for canplay
294
+ if (videoBlobUrl && processedVideo) {
295
+ processedVideo.src = videoBlobUrl;
296
+ processedVideo.style.opacity = '0';
297
+ processedVideo.style.transition = 'opacity 0.3s ease';
298
+ processedVideo.classList.remove('hidden');
299
+ processedVideo.style.display = 'block';
300
+ await new Promise(resolve => {
301
+ const timeout = setTimeout(resolve, 5000); // safety timeout
302
+ processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
303
+ processedVideo.load();
304
+ });
305
+ processedVideo.style.opacity = '1';
306
+ }
307
+
308
+ // 5. Fetch timeline summary (1 request)
309
+ try {
310
+ const summary = await ISR.fetchTimelineSummary(jobId);
311
+ ISR.STATE._timelineSummary = summary;
312
+ if (summary.total_frames) ISR.STATE.totalFrames = summary.total_frames;
313
+ if (summary.fps) ISR.STATE.fps = summary.fps;
314
+
315
+ // Render waveform immediately from density data (no extra fetches)
316
+ const frames = summary.frames || {};
317
+ const frameCounts = Object.entries(frames).map(([k, v]) => [parseInt(k, 10), v]).sort((a, b) => a[0] - b[0]);
318
+ const segments = 100;
319
+ const segSize = ISR.STATE.totalFrames / segments;
320
+ const density = new Array(segments).fill(0);
321
+ for (const [fi, count] of frameCounts) {
322
+ const seg = Math.min(Math.floor(fi / segSize), segments - 1);
323
+ density[seg] += (typeof count === 'number' ? count : 0);
324
+ }
325
+ const maxD = Math.max(...density, 1);
326
+ const normalized = density.map(v => v / maxD);
327
+ const waveformCanvas = document.getElementById('waveformCanvas');
328
+ if (waveformCanvas) renderWaveformFromDensity(waveformCanvas, normalized);
329
+ } catch (err) {
330
+ console.warn('[ISR] Timeline summary fetch failed:', err);
331
+ }
332
+
333
+ // === PHASE 2: Transition to analysis state IMMEDIATELY ===
334
+
335
+ const overlayEl = document.getElementById('overlayCanvas');
336
+ if (overlayEl) { overlayEl.style.display = ''; overlayEl.style.opacity = '1'; }
337
+ const videoCanvasEl = document.getElementById('videoCanvas');
338
+ if (videoCanvasEl) videoCanvasEl.style.opacity = '0';
339
+
340
+ ISR.STATE.playheadFrame = 0;
341
+ ISR.STATE.isPlaying = false;
342
+ ISR.STATE._lastRenderedFrame = -1;
343
+ ISR.STATE._analysisStartTime = Date.now();
344
+ ISR.lastFrameTime = 0;
345
+ ISR.setState('analysis');
346
+
347
+ ISR.updateFrameCounter();
348
+ ISR.updatePlayheadPosition();
349
+ ISR.updateTimeDisplay();
350
+ document.getElementById('playPauseBtn').innerHTML = '&#9654;';
351
+
352
+ const timeEndEl = document.getElementById('timeEnd');
353
+ if (timeEndEl) timeEndEl.textContent = ISR.formatTime(ISR.STATE.totalFrames / ISR.STATE.fps);
354
+
355
+ ISR.showToast(`Analysis ready. ${ISR.STATE.totalFrames} frames at ${ISR.STATE.fps.toFixed(0)} fps. Loading tracks...`, 5000);
356
+
357
+ // === PHASE 3: Load track data in background (non-blocking) ===
358
+ // Fetch first frame tracks immediately for initial display
359
+ try {
360
+ const firstFrameTracks = await ISR.fetchTracks(jobId, 0);
361
+ if (firstFrameTracks.length > 0) {
362
+ const enriched = firstFrameTracks.map(t => {
363
+ const et = ISR.applyAssessmentCache({...t});
364
+ et.type = ISR.labelToType(et.label_name || et.label || '');
365
+ et.color = ISR.getColorForType(et.type);
366
+ return et;
367
+ });
368
+ ISR.STATE._realTracks = enriched;
369
+ ISR.STATE._realTrackIndex = null; // force rebuild on next merge
370
+ ISR.STATE._currentFrameTracks = enriched;
371
+ renderTrackListFromData(enriched);
372
+ renderSvgOverlay(enriched);
373
+ }
374
+ } catch (err) {
375
+ console.warn('[ISR] Initial track fetch failed:', err);
376
+ }
377
+
378
+ // Defer heavy operations β€” run in background without blocking UI
379
+ deferredAnalysisWork(jobId, status);
380
+ }
381
+
382
+ // Background work β€” runs after analysis state is active and interactive
383
+ async function deferredAnalysisWork(jobId, status) {
384
+ // Build full track list from sampled keyframes (reuses cache)
385
+ try {
386
+ await renderRealTrackList(jobId);
387
+ } catch (err) { console.warn('[ISR] Deferred track list failed:', err); }
388
+
389
+ // Derive timeline events (uses cached track data from renderRealTrackList)
390
+ if (ISR.STATE._timelineSummary) {
391
+ try {
392
+ await buildRealTimeline(jobId, ISR.STATE._timelineSummary);
393
+ } catch (err) { console.warn('[ISR] Deferred timeline events failed:', err); }
394
+ }
395
+
396
+ // Compute metrics (uses cached track data)
397
+ try {
398
+ await computeRealMetrics(jobId, status, ISR.STATE._timelineSummary);
399
+ } catch (err) { console.warn('[ISR] Deferred metrics failed:', err); }
400
+ }
401
+
402
+ async function computeRealMetrics(jobId, status, summary) {
403
+ return; // Replaced by explainability graph
404
+ const metricsPanel = document.getElementById('metricsPanel');
405
+ if (!metricsPanel) return;
406
+
407
+ // Collect unique tracks from sampled keyframes (batch fetch, up to 30 frames)
408
+ const trackMap = new Map();
409
+ const framesToSample = getKeyframesToSample();
410
+ const sampleFrames = framesToSample.slice(0, 30);
411
+
412
+ for (let i = 0; i < sampleFrames.length; i += 10) {
413
+ const batch = sampleFrames.slice(i, i + 10);
414
+ const results = await Promise.all(batch.map(f => ISR.fetchTracks(jobId, f)));
415
+ for (const tracks of results) {
416
+ if (!tracks) continue;
417
+ for (const t of tracks) {
418
+ const tid = t.track_id !== undefined ? t.track_id : t.id;
419
+ if (tid === undefined) continue;
420
+ const existing = trackMap.get(tid);
421
+ if (!existing || (t.score || 0) > (existing.score || 0)) {
422
+ trackMap.set(tid, {
423
+ track_id: tid,
424
+ label: t.label_name || t.label || 'object',
425
+ score: t.score || t.confidence || 0,
426
+ type: ISR.labelToType(t.label_name || t.label || ''),
427
+ });
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ const allTracks = Array.from(trackMap.values());
434
+ const totalTracks = allTracks.length;
435
+
436
+ // Count by type
437
+ const typeCounts = {};
438
+ for (const t of allTracks) {
439
+ typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
440
+ }
441
+
442
+ // Avg confidence
443
+ const avgConf = totalTracks > 0
444
+ ? (allTracks.reduce((s, t) => s + t.score, 0) / totalTracks).toFixed(2)
445
+ : '0.00';
446
+
447
+ // Processing time and FPS from status timestamps
448
+ let processTime = '--';
449
+ let fps = '--';
450
+ const totalFrames = summary && summary.total_frames ? summary.total_frames : ISR.STATE.totalFrames;
451
+
452
+ if (status && status.created_at && status.completed_at) {
453
+ const startMs = new Date(status.created_at).getTime();
454
+ const endMs = new Date(status.completed_at).getTime();
455
+ const durationSec = (endMs - startMs) / 1000;
456
+ if (durationSec > 0) {
457
+ processTime = durationSec < 60
458
+ ? `${durationSec.toFixed(1)}s`
459
+ : `${Math.floor(durationSec / 60)}m ${(durationSec % 60).toFixed(0)}s`;
460
+ fps = (totalFrames / durationSec).toFixed(1);
461
+ }
462
+ }
463
+
464
+ // Build breakdown bars
465
+ const breakdownTypes = ['person', 'vehicle', 'drone', 'stationary'];
466
+ const maxCount = Math.max(1, ...breakdownTypes.map(t => typeCounts[t] || 0));
467
+ const bars = breakdownTypes.map(type => {
468
+ const count = typeCounts[type] || 0;
469
+ const color = ISR.TYPE_COLORS[type] || '#666';
470
+ const pct = maxCount > 0 ? (count / maxCount) * 100 : 0;
471
+ return `
472
+ <div class="metric-bar-row">
473
+ <span class="metric-bar-label">${type.charAt(0).toUpperCase() + type.slice(1)}</span>
474
+ <div class="metric-bar-track">
475
+ <div class="metric-bar-fill" style="width: ${pct}%; background: ${color};"></div>
476
+ </div>
477
+ <span class="metric-bar-count" style="color: ${color};">${count}</span>
478
+ </div>`;
479
+ }).join('');
480
+
481
+ metricsPanel.innerHTML = `
482
+ <div class="metric-hero">
483
+ <div class="metric-big-number">${totalTracks}</div>
484
+ <div class="label">TOTAL TRACKS</div>
485
+ </div>
486
+ <div class="metric-breakdown">
487
+ ${bars}
488
+ </div>
489
+ <div class="metric-stats">
490
+ <div class="metric-stat-row"><span class="metric-stat-label">AVG CONFIDENCE</span><span class="metric-stat-value">${avgConf}</span></div>
491
+ <div class="metric-stat-row"><span class="metric-stat-label">PROCESSING FPS</span><span class="metric-stat-value">${fps}</span></div>
492
+ <div class="metric-stat-row"><span class="metric-stat-label">TOTAL FRAMES</span><span class="metric-stat-value">${totalFrames}</span></div>
493
+ <div class="metric-stat-row"><span class="metric-stat-label">PROCESS TIME</span><span class="metric-stat-value">${processTime}</span></div>
494
+ </div>`;
495
+ }
496
+
497
+ /* ================================================================
498
+ * TASK 5: Real Detection Rendering on Overlay Canvas
499
+ * ================================================================ */
500
+
501
+ async function renderRealDetections(ctx, w, h) {
502
+ if (!ISR.STATE.jobId) return;
503
+ if (ISR.STATE.current !== 'analysis' && ISR.STATE.current !== 'playing' && ISR.STATE.current !== 'inspect') return;
504
+
505
+ const processedVideo = document.getElementById('processedVideo');
506
+ if (!processedVideo || !processedVideo.duration) return;
507
+
508
+ const frameIdx = Math.floor(processedVideo.currentTime * ISR.STATE.fps);
509
+
510
+ // Skip if same frame as last render
511
+ if (frameIdx === ISR.STATE._lastRenderedFrame) return;
512
+ ISR.STATE._lastRenderedFrame = frameIdx;
513
+
514
+ // Fetch tracks for this frame
515
+ const tracks = await ISR.fetchTracks(ISR.STATE.jobId, frameIdx);
516
+
517
+ // Prefetch nearby frames (only Β±2 to avoid flooding)
518
+ ISR.prefetchTracksAround(ISR.STATE.jobId, frameIdx, 2);
519
+
520
+ // Clear overlay canvas (no longer rendering boxes on canvas)
521
+ ctx.clearRect(0, 0, w, h);
522
+
523
+ if (!tracks || tracks.length === 0) {
524
+ // Clear SVG overlay too
525
+ const svg = document.getElementById('detectionOverlay');
526
+ if (svg) while (svg.firstChild) svg.removeChild(svg.firstChild);
527
+ return;
528
+ }
529
+
530
+ // Apply assessment cache + enrich tracks
531
+ const enriched = tracks.map(t => {
532
+ const et = ISR.applyAssessmentCache({...t});
533
+ et.type = ISR.labelToType(et.label_name || et.label || '');
534
+ et.color = ISR.getColorForType(et.type);
535
+ return et;
536
+ });
537
+
538
+ // Store current frame tracks separately β€” don't replace the stable _realTracks list
539
+ ISR.STATE._currentFrameTracks = enriched;
540
+
541
+ // Merge new tracks into the stable list (accumulate, don't replace)
542
+ if (ISR.STATE._realTracks) {
543
+ // O(n) merge using index map instead of O(nΒ²) findIndex loop
544
+ if (!ISR.STATE._realTrackIndex) {
545
+ ISR.STATE._realTrackIndex = new Map();
546
+ ISR.STATE._realTracks.forEach((t, i) => ISR.STATE._realTrackIndex.set(t.track_id, i));
547
+ }
548
+ const idx_map = ISR.STATE._realTrackIndex;
549
+ for (const t of enriched) {
550
+ const existingIdx = idx_map.get(t.track_id);
551
+ if (existingIdx !== undefined) {
552
+ ISR.STATE._realTracks[existingIdx] = t;
553
+ } else {
554
+ const newIdx = ISR.STATE._realTracks.length;
555
+ ISR.STATE._realTracks.push(t);
556
+ idx_map.set(t.track_id, newIdx);
557
+ }
558
+ }
559
+ } else {
560
+ ISR.STATE._realTracks = [...enriched];
561
+ ISR.STATE._realTrackIndex = new Map(enriched.map((t, i) => [t.track_id, i]));
562
+ }
563
+
564
+ // Render SVG overlay with CURRENT FRAME tracks only
565
+ renderSvgOverlay(enriched);
566
+ }
567
+
568
+ /* ================================================================
569
+ * TASK 5: Real Track List Rendering
570
+ * ================================================================ */
571
+
572
+ async function renderRealTrackList(jobId) {
573
+ // Collect unique tracks from sampled keyframes
574
+ const trackMap = new Map();
575
+
576
+ const framesToSample = getKeyframesToSample();
577
+ // Sample in batches of 10
578
+ for (let i = 0; i < framesToSample.length; i += 10) {
579
+ const batch = framesToSample.slice(i, i + 10);
580
+ const results = await Promise.all(batch.map(f => ISR.fetchTracks(jobId, f)));
581
+ for (const tracks of results) {
582
+ if (!tracks) continue;
583
+ for (const t of tracks) {
584
+ const tid = t.track_id !== undefined ? t.track_id : t.id;
585
+ if (tid === undefined) continue;
586
+ const existing = trackMap.get(tid);
587
+ if (!existing || (t.score || 0) > (existing.score || 0)) {
588
+ trackMap.set(tid, {
589
+ track_id: tid,
590
+ label: t.label_name || t.label || 'object',
591
+ score: t.score || t.confidence || 0,
592
+ speed_kph: t.speed_kph || 0,
593
+ direction_clock: t.direction_clock || null,
594
+ type: ISR.labelToType(t.label_name || t.label || ''),
595
+ color: ISR.getColorForType(ISR.labelToType(t.label_name || t.label || '')),
596
+ bbox: t.bbox || null,
597
+ satisfies: t.satisfies,
598
+ reason: t.reason,
599
+ mission_relevant: t.mission_relevant,
600
+ gpt_raw: t.gpt_raw,
601
+ features: t.features,
602
+ assessment_status: t.assessment_status,
603
+ depth_est_m: t.depth_est_m,
604
+ });
605
+ }
606
+ // Update assessment cache
607
+ if (t.gpt_raw || t.assessment_status === 'ASSESSED') {
608
+ ISR.cacheAssessment(tid, t);
609
+ }
610
+ }
611
+ }
612
+ }
613
+
614
+ ISR.STATE._realTracks = Array.from(trackMap.values());
615
+ ISR.STATE._realTrackIndex = null; // force rebuild on next merge
616
+ renderTrackListFromData(ISR.STATE._realTracks);
617
+ }
618
+
619
+ function getKeyframesToSample() {
620
+ if (!ISR.STATE._timelineSummary || !ISR.STATE._timelineSummary.frames) {
621
+ // Fallback: sample evenly across total frames
622
+ const samples = [];
623
+ const step = Math.max(1, Math.floor(ISR.STATE.totalFrames / 30));
624
+ for (let i = 0; i < ISR.STATE.totalFrames; i += step) {
625
+ samples.push(i);
626
+ }
627
+ return samples;
628
+ }
629
+ const allFrames = Object.keys(ISR.STATE._timelineSummary.frames)
630
+ .map(k => parseInt(k, 10))
631
+ .sort((a, b) => a - b);
632
+
633
+ if (allFrames.length <= 60) return allFrames;
634
+
635
+ // Subsample every Nth
636
+ const n = Math.ceil(allFrames.length / 60);
637
+ return allFrames.filter((_, i) => i % n === 0);
638
+ }
639
+
640
+ function renderTrackListFromData(tracks) {
641
+ const panel = document.getElementById('tracksPanel');
642
+ panel.innerHTML = '';
643
+
644
+ // Apply assessment cache to all tracks
645
+ const enriched = tracks.map(t => ISR.applyAssessmentCache({...t}));
646
+
647
+ const filtered = ISR.STATE.trackFilter ? enriched.filter(t => t.type === ISR.STATE.trackFilter) : enriched;
648
+
649
+ if (filtered.length === 0) {
650
+ panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No tracks detected.</div>';
651
+ return;
652
+ }
653
+
654
+ // Sort: mission-relevant first, then assessed before unassessed before stale, then by confidence descending
655
+ const _statusOrder = { 'ASSESSED': 0, 'UNASSESSED': 1, 'STALE': 2 };
656
+ filtered.sort((a, b) => {
657
+ const aMR = a.mission_relevant === true ? 0 : 1;
658
+ const bMR = b.mission_relevant === true ? 0 : 1;
659
+ if (aMR !== bMR) return aMR - bMR;
660
+ const aStatus = _statusOrder[a.assessment_status] ?? 1;
661
+ const bStatus = _statusOrder[b.assessment_status] ?? 1;
662
+ if (aStatus !== bStatus) return aStatus - bStatus;
663
+ return (b.score || 0) - (a.score || 0);
664
+ });
665
+
666
+ // If there's a filter, add a clear filter button
667
+ if (ISR.STATE.trackFilter) {
668
+ const clearBtn = document.createElement('button');
669
+ clearBtn.className = 'inspect-back-btn';
670
+ clearBtn.style.marginBottom = '8px';
671
+ clearBtn.style.width = '100%';
672
+ clearBtn.textContent = 'CLEAR FILTER β€” SHOW ALL';
673
+ clearBtn.addEventListener('click', () => {
674
+ ISR.STATE.trackFilter = null;
675
+ renderTrackListFromData(ISR.STATE._realTracks || []);
676
+ });
677
+ panel.appendChild(clearBtn);
678
+ }
679
+
680
+ filtered.forEach((t, idx) => {
681
+ const isSelected = ISR.STATE.selectedTrackId === t.track_id;
682
+ const isNotRelevant = t.mission_relevant === false;
683
+
684
+ const card = document.createElement('div');
685
+ card.className = 'track-card' + (isSelected ? ' active' : '') + (isNotRelevant ? ' not-relevant' : '');
686
+ card.dataset.trackId = t.track_id;
687
+ card.style.animation = `track-card-enter 0.3s ease ${idx * 30}ms both`;
688
+
689
+ const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
690
+ const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
691
+ const idStr = `ID:${t.track_id}`;
692
+
693
+ // Status badge
694
+ const badge = ISR.getStatusBadgeHTML(t);
695
+
696
+ // Reason text
697
+ const reasonHTML = t.reason ? `<div class="reason">${t.reason}</div>` : '';
698
+
699
+ // Features table (only visible when card is active/selected β€” matches frontend)
700
+ const features = ISR.buildFeatures(t.gpt_raw);
701
+ const featureEntries = Object.entries(features).slice(0, 12);
702
+ let featuresHTML = '';
703
+ if (isSelected && featureEntries.length > 0) {
704
+ featuresHTML = '<div class="features-table">' +
705
+ featureEntries.map(([k, v]) =>
706
+ `<span class="feat-key">${k.replace(/_/g, ' ')}</span><span class="feat-val">${v}</span>`
707
+ ).join('') +
708
+ '</div>';
709
+ }
710
+
711
+ card.innerHTML = `
712
+ <div class="track-dot" style="background: ${t.color}; box-shadow: 0 0 4px ${t.color};"></div>
713
+ <div class="track-info">
714
+ <div class="track-label" style="display:flex;align-items:center;gap:6px;">
715
+ <span>${t.label}</span>
716
+ ${badge}
717
+ </div>
718
+ <div class="track-meta">
719
+ <span>${speedStr}</span>
720
+ ${dirStr ? `<span>${dirStr}</span>` : ''}
721
+ <span>${idStr}</span>
722
+ </div>
723
+ ${reasonHTML}
724
+ ${featuresHTML}
725
+ </div>
726
+ `;
727
+
728
+ panel.appendChild(card);
729
+ });
730
+
731
+ // Attach event listeners via delegation on the panel (once, not per-card)
732
+ panel.onclick = (e) => {
733
+ const card = e.target.closest('.track-card');
734
+ if (!card) return;
735
+ const trackId = card.dataset.trackId;
736
+ ISR.STATE.selectedTrackId = trackId;
737
+ document.dispatchEvent(new CustomEvent('track-selected', { detail: { id: trackId } }));
738
+ if (ISR.STATE.current === 'analysis' || ISR.STATE.current === 'playing' || ISR.STATE.current === 'inspect') {
739
+ ISR.enterInspectState(trackId);
740
+ }
741
+ // Toggle active class without full re-render + remove old feature tables
742
+ panel.querySelectorAll('.track-card.active').forEach(c => {
743
+ c.classList.remove('active');
744
+ const oldTable = c.querySelector('.features-table');
745
+ if (oldTable) oldTable.remove();
746
+ });
747
+ card.classList.add('active');
748
+ // Expand features on newly active card (use Map index for O(1) lookup)
749
+ const trackIdx = ISR.STATE._realTrackIndex?.get(trackId);
750
+ const track = trackIdx !== undefined ? ISR.STATE._realTracks[trackIdx] : (ISR.STATE._realTracks || []).find(t => t.track_id === trackId);
751
+ const features = ISR.buildFeatures(track?.gpt_raw);
752
+ const featureEntries = Object.entries(features).slice(0, 12);
753
+ let existingTable = card.querySelector('.features-table');
754
+ if (!existingTable && featureEntries.length > 0) {
755
+ const tbl = document.createElement('div');
756
+ tbl.className = 'features-table';
757
+ tbl.style.display = 'grid';
758
+ tbl.innerHTML = featureEntries.map(([k, v]) =>
759
+ `<span class="feat-key">${k.replace(/_/g, ' ')}</span><span class="feat-val">${v}</span>`
760
+ ).join('');
761
+ card.querySelector('.track-info').appendChild(tbl);
762
+ }
763
+ // Update SVG overlay selection (targeted update, not full rebuild)
764
+ _updateSvgSelection(trackId);
765
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
766
+ };
767
+ // Use assignment (not addEventListener) to avoid stacking on repeated calls
768
+ panel.onmouseover = (e) => {
769
+ const card = e.target.closest('.track-card');
770
+ if (!card) return;
771
+ ISR._updateTrackHighlight(card.dataset.trackId);
772
+ };
773
+ panel.onmouseout = (e) => {
774
+ const card = e.target.closest('.track-card');
775
+ if (!card) return;
776
+ ISR._updateTrackHighlight(null);
777
+ };
778
+
779
+ // Also update SVG overlay when track list re-renders
780
+ if (ISR.STATE.jobId && ISR.STATE._realTracks) {
781
+ renderSvgOverlay(enriched);
782
+ }
783
+ }
784
+
785
+ /* ================================================================
786
+ * SVG Overlay β€” Mission-colored detection boxes
787
+ * ================================================================ */
788
+
789
+ let _svgContainerCache = null;
790
+ let _svgCacheTime = 0;
791
+
792
+ function renderSvgOverlay(tracks) {
793
+ const svg = document.getElementById('detectionOverlay');
794
+ if (!svg) return;
795
+ // Use innerHTML for fastest clear (single reflow vs per-child removal)
796
+ svg.innerHTML = '';
797
+
798
+ if (!tracks || tracks.length === 0) return;
799
+
800
+ const processedVideo = document.getElementById('processedVideo');
801
+ if (!processedVideo || !processedVideo.videoWidth) return;
802
+
803
+ const videoW = processedVideo.videoWidth;
804
+ const videoH = processedVideo.videoHeight;
805
+
806
+ // Cache container rect (expensive getBoundingClientRect) β€” refresh every 500ms
807
+ const now = performance.now();
808
+ const container = document.getElementById('videoFeed');
809
+ if (!_svgContainerCache || now - _svgCacheTime > 500) {
810
+ _svgContainerCache = container.getBoundingClientRect();
811
+ _svgCacheTime = now;
812
+ }
813
+ const containerRect = _svgContainerCache;
814
+ const containerW = containerRect.width;
815
+ const containerH = containerRect.height;
816
+
817
+ const videoAspect = videoW / videoH;
818
+ const containerAspect = containerW / containerH;
819
+ let renderW, renderH, offsetX, offsetY;
820
+
821
+ if (videoAspect > containerAspect) {
822
+ renderW = containerW;
823
+ renderH = containerW / videoAspect;
824
+ offsetX = 0;
825
+ offsetY = (containerH - renderH) / 2;
826
+ } else {
827
+ renderH = containerH;
828
+ renderW = containerH * videoAspect;
829
+ offsetX = (containerW - renderW) / 2;
830
+ offsetY = 0;
831
+ }
832
+
833
+ // Position SVG to cover only the video rendered area (single write to avoid reflows)
834
+ svg.style.cssText = `position:absolute;top:${offsetY}px;left:${offsetX}px;width:${renderW}px;height:${renderH}px;z-index:3;pointer-events:all;`;
835
+
836
+ for (const t of tracks) {
837
+ const bbox = t.bbox; // [x1, y1, x2, y2] in pixels
838
+ if (!bbox || bbox.length < 4) continue;
839
+
840
+ // Normalize to 0-1 using video dimensions
841
+ const nx1 = bbox[0] / videoW;
842
+ const ny1 = bbox[1] / videoH;
843
+ const nx2 = bbox[2] / videoW;
844
+ const ny2 = bbox[3] / videoH;
845
+ const nw = nx2 - nx1;
846
+ const nh = ny2 - ny1;
847
+
848
+ // Determine color by mission status (matches frontend overlays.js)
849
+ let strokeColor = 'rgba(255, 255, 255, 0.3)';
850
+ if (t.satisfies === true) {
851
+ strokeColor = 'rgba(40, 167, 69, 0.8)';
852
+ } else if (t.satisfies === false) {
853
+ strokeColor = 'rgba(220, 53, 69, 0.8)';
854
+ }
855
+
856
+ const isSelected = ISR.STATE.selectedTrackId === t.track_id;
857
+ const isHighlighted = ISR.STATE.highlightTrackId === t.track_id;
858
+
859
+ const ns = 'http://www.w3.org/2000/svg';
860
+ const g = document.createElementNS(ns, 'g');
861
+ g.dataset.trackId = t.track_id;
862
+
863
+ const rect = document.createElementNS(ns, 'rect');
864
+ rect.setAttribute('x', nx1);
865
+ rect.setAttribute('y', ny1);
866
+ rect.setAttribute('width', nw);
867
+ rect.setAttribute('height', nh);
868
+ rect.setAttribute('fill', isSelected ? 'rgba(59, 130, 246, 0.12)' : 'none');
869
+ rect.setAttribute('stroke', isSelected ? 'rgba(59, 130, 246, 0.7)' : strokeColor);
870
+ rect.setAttribute('stroke-width', isSelected ? '0.003' : '0.002');
871
+ rect.setAttribute('vector-effect', 'non-scaling-stroke');
872
+ rect.dataset.baseStroke = strokeColor; // cached for targeted selection updates
873
+
874
+ g.appendChild(rect);
875
+
876
+ // Selected glow border (matches frontend)
877
+ if (isSelected) {
878
+ const glow = document.createElementNS(ns, 'rect');
879
+ glow.setAttribute('x', nx1);
880
+ glow.setAttribute('y', ny1);
881
+ glow.setAttribute('width', nw);
882
+ glow.setAttribute('height', nh);
883
+ glow.setAttribute('fill', 'none');
884
+ glow.setAttribute('stroke', 'rgba(96, 165, 250, 0.35)');
885
+ glow.setAttribute('stroke-width', '1');
886
+ glow.setAttribute('vector-effect', 'non-scaling-stroke');
887
+ glow.classList.add('selection-glow');
888
+ g.appendChild(glow);
889
+ }
890
+
891
+ // Click handler β†’ inspect
892
+ g.addEventListener('click', (e) => {
893
+ e.stopPropagation();
894
+ ISR.STATE.selectedTrackId = t.track_id;
895
+ document.dispatchEvent(new CustomEvent('track-selected', { detail: { id: t.track_id } }));
896
+ ISR.enterInspectState(t.track_id);
897
+ });
898
+
899
+ // Hover β†’ highlight via shared helper
900
+ g.addEventListener('mouseenter', () => ISR._updateTrackHighlight(t.track_id));
901
+ g.addEventListener('mouseleave', () => ISR._updateTrackHighlight(null));
902
+
903
+ svg.appendChild(g);
904
+ }
905
+ }
906
+
907
+ /** Update SVG selection styling without full rebuild. */
908
+ function _updateSvgSelection(selectedId) {
909
+ const svg = document.getElementById('detectionOverlay');
910
+ if (!svg) return;
911
+ const ns = 'http://www.w3.org/2000/svg';
912
+ svg.querySelectorAll('g[data-track-id]').forEach(g => {
913
+ const tid = g.dataset.trackId;
914
+ const isSelected = tid === selectedId;
915
+ const rect = g.querySelector('rect');
916
+ if (!rect) return;
917
+ // Determine base stroke from data attribute (set during full render)
918
+ const baseStroke = rect.dataset.baseStroke || 'rgba(255, 255, 255, 0.3)';
919
+ rect.setAttribute('fill', isSelected ? 'rgba(59, 130, 246, 0.12)' : 'none');
920
+ rect.setAttribute('stroke', isSelected ? 'rgba(59, 130, 246, 0.7)' : baseStroke);
921
+ rect.setAttribute('stroke-width', isSelected ? '0.003' : '0.002');
922
+ // Handle glow rect (second child if exists)
923
+ const glow = g.querySelector('.selection-glow');
924
+ if (isSelected && !glow) {
925
+ const glowRect = document.createElementNS(ns, 'rect');
926
+ glowRect.setAttribute('x', rect.getAttribute('x'));
927
+ glowRect.setAttribute('y', rect.getAttribute('y'));
928
+ glowRect.setAttribute('width', rect.getAttribute('width'));
929
+ glowRect.setAttribute('height', rect.getAttribute('height'));
930
+ glowRect.setAttribute('fill', 'none');
931
+ glowRect.setAttribute('stroke', 'rgba(96, 165, 250, 0.35)');
932
+ glowRect.setAttribute('stroke-width', '1');
933
+ glowRect.setAttribute('vector-effect', 'non-scaling-stroke');
934
+ glowRect.classList.add('selection-glow');
935
+ g.appendChild(glowRect);
936
+ } else if (!isSelected && glow) {
937
+ glow.remove();
938
+ }
939
+ });
940
+ }
941
+
942
+ /* ================================================================
943
+ * TASK 6: Timeline β€” Real Summary β†’ Waveform + Event Derivation
944
+ * ================================================================ */
945
+
946
+ async function buildRealTimeline(jobId, summary) {
947
+ // 1. Parse summary.frames
948
+ const frames = summary.frames || {};
949
+ const frameCounts = Object.entries(frames)
950
+ .map(([k, v]) => [parseInt(k, 10), v])
951
+ .sort((a, b) => a[0] - b[0]);
952
+
953
+ // 2. Build density array (100 segments)
954
+ const totalFrames = summary.total_frames || ISR.STATE.totalFrames;
955
+ const segments = 100;
956
+ const segSize = totalFrames / segments;
957
+ const density = new Array(segments).fill(0);
958
+
959
+ for (const [frameIdx, count] of frameCounts) {
960
+ const seg = Math.min(Math.floor(frameIdx / segSize), segments - 1);
961
+ density[seg] += (typeof count === 'number' ? count : (count.count || 0));
962
+ }
963
+
964
+ // Normalize 0-1
965
+ const maxDensity = Math.max(...density, 1);
966
+ const normalizedDensity = density.map(v => v / maxDensity);
967
+
968
+ // 3. Render waveform
969
+ const waveformCanvas = document.getElementById('waveformCanvas');
970
+ if (waveformCanvas) {
971
+ renderWaveformFromDensity(waveformCanvas, normalizedDensity);
972
+ }
973
+
974
+ // 4. Derive events (awaited so timeline is fully built before state transition)
975
+ try {
976
+ const events = await deriveTimelineEvents(jobId, summary, frameCounts);
977
+ // 5. Render event markers
978
+ renderEventMarkersFromData(events);
979
+
980
+ // 6. Render event log
981
+ ISR.STATE._derivedEvents = events;
982
+ if (ISR.STATE.eventLogOpen) renderEventLogFromData(events);
983
+
984
+ // 7. Render legend from track type counts
985
+ renderRealTimelineLegend(events);
986
+ } catch (err) {
987
+ console.warn('[ISR] Event derivation failed:', err);
988
+ }
989
+
990
+ // 8. Update time end display
991
+ const fps = summary.fps || ISR.STATE.fps;
992
+ const timeEndEl = document.getElementById('timeEnd');
993
+ if (timeEndEl) timeEndEl.textContent = ISR.formatTime(totalFrames / fps);
994
+ }
995
+
996
+ function renderWaveformFromDensity(canvas, densityData) {
997
+ const rect = canvas.parentElement.getBoundingClientRect();
998
+ const dpr = window.devicePixelRatio || 1;
999
+ canvas.width = rect.width * dpr;
1000
+ canvas.height = rect.height * dpr;
1001
+ canvas.style.width = rect.width + 'px';
1002
+ canvas.style.height = rect.height + 'px';
1003
+
1004
+ const ctx = canvas.getContext('2d');
1005
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1006
+
1007
+ const w = rect.width;
1008
+ const h = rect.height;
1009
+ const barW = w / densityData.length;
1010
+
1011
+ ctx.clearRect(0, 0, w, h);
1012
+
1013
+ for (let i = 0; i < densityData.length; i++) {
1014
+ const val = densityData[i];
1015
+ const barH = val * h * 0.85;
1016
+ const x = i * barW;
1017
+ const y = h - barH;
1018
+
1019
+ // Color gradient based on density value
1020
+ const hue = 220 - val * 180; // blue(220) β†’ amber range
1021
+ ctx.fillStyle = val > 0.7 ? '#f59e0b88' : val > 0.4 ? '#3b82f688' : '#22c55e88';
1022
+ ctx.fillRect(x, y, Math.max(barW - 1, 1), barH);
1023
+ }
1024
+ }
1025
+
1026
+ async function deriveTimelineEvents(jobId, summary, frameCounts) {
1027
+ const events = [];
1028
+ const fps = summary.fps || ISR.STATE.fps;
1029
+ const framesToSample = getKeyframesToSample();
1030
+
1031
+ const seenIds = new Set();
1032
+ const seenTypes = new Set();
1033
+ const trackSpeeds = new Map();
1034
+ const lostTracks = new Map(); // trackId β†’ frame when lost
1035
+
1036
+ // Fetch track data for sampled keyframes (batched in groups of 10)
1037
+ let prevTracks = [];
1038
+ let prevFrame = 0;
1039
+
1040
+ for (let i = 0; i < framesToSample.length; i += 10) {
1041
+ const batch = framesToSample.slice(i, i + 10);
1042
+ const results = await Promise.all(batch.map(f => ISR.fetchTracks(jobId, f)));
1043
+
1044
+ for (let j = 0; j < batch.length; j++) {
1045
+ const frameIdx = batch[j];
1046
+ const tracks = results[j] || [];
1047
+ const currentIds = new Set();
1048
+
1049
+ for (const t of tracks) {
1050
+ const tid = t.track_id !== undefined ? t.track_id : t.id;
1051
+ if (tid === undefined) continue;
1052
+ currentIds.add(tid);
1053
+ const label = t.label_name || t.label || 'object';
1054
+ const type = ISR.labelToType(label);
1055
+ const time = ISR.formatTime(frameIdx / fps);
1056
+
1057
+ // New track
1058
+ if (!seenIds.has(tid)) {
1059
+ seenIds.add(tid);
1060
+ events.push({
1061
+ frame: frameIdx, time, type: 'detection',
1062
+ label: `New ${label}`, description: `Track ${tid} first detected.`,
1063
+ priority: 'normal',
1064
+ });
1065
+
1066
+ // First of type
1067
+ if (!seenTypes.has(type)) {
1068
+ seenTypes.add(type);
1069
+ events.push({
1070
+ frame: frameIdx, time, type: 'detection',
1071
+ label: `First ${type}`, description: `First ${type} class object detected.`,
1072
+ priority: 'normal',
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ // Track reappeared (was lost)
1078
+ if (lostTracks.has(tid)) {
1079
+ lostTracks.delete(tid);
1080
+ events.push({
1081
+ frame: frameIdx, time, type: 'tracking',
1082
+ label: `Track Reappeared`, description: `${label} (${tid}) re-identified after occlusion.`,
1083
+ priority: 'low',
1084
+ });
1085
+ }
1086
+
1087
+ // Speed alerts
1088
+ if (t.speed_kph !== undefined && t.speed_kph !== null) {
1089
+ if (t.speed_kph > 60) {
1090
+ events.push({
1091
+ frame: frameIdx, time, type: 'alert',
1092
+ label: `High Speed`, description: `${label} (${tid}) traveling at ${t.speed_kph.toFixed(1)} kph.`,
1093
+ priority: 'high',
1094
+ });
1095
+ }
1096
+
1097
+ const prevSpeed = trackSpeeds.get(tid);
1098
+ if (prevSpeed !== undefined && Math.abs(t.speed_kph - prevSpeed) > 20) {
1099
+ events.push({
1100
+ frame: frameIdx, time, type: 'alert',
1101
+ label: `Speed Change`, description: `${label} (${tid}): ${prevSpeed.toFixed(0)} β†’ ${t.speed_kph.toFixed(0)} kph.`,
1102
+ priority: 'high',
1103
+ });
1104
+ }
1105
+ trackSpeeds.set(tid, t.speed_kph);
1106
+ }
1107
+
1108
+ // Mission relevance
1109
+ if (t.mission_relevant !== undefined) {
1110
+ if (t.mission_relevant === true) {
1111
+ events.push({
1112
+ frame: frameIdx, time, type: 'alert',
1113
+ label: `MISSION MATCH`, description: `${label} (${tid}) matches mission criteria.`,
1114
+ priority: 'critical',
1115
+ });
1116
+ } else if (t.mission_relevant === false) {
1117
+ events.push({
1118
+ frame: frameIdx, time, type: 'system',
1119
+ label: `Assessed`, description: `${label} (${tid}) assessed β€” not relevant.`,
1120
+ priority: 'normal',
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ // Features
1126
+ if (t.features) {
1127
+ const featureDesc = [];
1128
+ if (t.features.posture) featureDesc.push(`posture: ${t.features.posture}`);
1129
+ if (t.features.gait) featureDesc.push(`gait: ${t.features.gait}`);
1130
+ if (t.features.clothing) featureDesc.push(`clothing: ${t.features.clothing}`);
1131
+ if (featureDesc.length > 0) {
1132
+ events.push({
1133
+ frame: frameIdx, time, type: 'detection',
1134
+ label: `Features`, description: `${label} (${tid}): ${featureDesc.join(', ')}`,
1135
+ priority: 'low',
1136
+ });
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ // Track lost: was in prev but not in current
1142
+ for (const t of prevTracks) {
1143
+ const tid = t.track_id !== undefined ? t.track_id : t.id;
1144
+ if (tid !== undefined && !currentIds.has(tid) && !lostTracks.has(tid)) {
1145
+ lostTracks.set(tid, frameIdx);
1146
+ const label = t.label_name || t.label || 'object';
1147
+ events.push({
1148
+ frame: frameIdx, time: ISR.formatTime(frameIdx / fps), type: 'tracking',
1149
+ label: `Track Lost`, description: `${label} (${tid}) lost from view.`,
1150
+ priority: 'normal',
1151
+ });
1152
+ }
1153
+ }
1154
+
1155
+ // Density events
1156
+ if (frameCounts.length > 0) {
1157
+ const fc = frameCounts.find(([f]) => f === frameIdx);
1158
+ if (fc) {
1159
+ const currentCount = typeof fc[1] === 'number' ? fc[1] : (fc[1].count || 0);
1160
+ // Find previous count
1161
+ const prevFcIdx = frameCounts.findIndex(([f]) => f === frameIdx);
1162
+ if (prevFcIdx > 0) {
1163
+ const prevCount = typeof frameCounts[prevFcIdx - 1][1] === 'number'
1164
+ ? frameCounts[prevFcIdx - 1][1]
1165
+ : (frameCounts[prevFcIdx - 1][1].count || 0);
1166
+ // Surge: >2x previous
1167
+ if (prevCount > 0 && currentCount > prevCount * 2) {
1168
+ events.push({
1169
+ frame: frameIdx, time: ISR.formatTime(frameIdx / fps), type: 'alert',
1170
+ label: `Activity Surge`, description: `Detections surged from ${prevCount} to ${currentCount}.`,
1171
+ priority: 'high',
1172
+ });
1173
+ }
1174
+ }
1175
+ // Activity gap: 0 detections for >2s worth of frames
1176
+ if (currentCount === 0 && prevFcIdx > 0) {
1177
+ const prevFrame2 = frameCounts[prevFcIdx - 1][0];
1178
+ if (frameIdx - prevFrame2 > fps * 2) {
1179
+ events.push({
1180
+ frame: frameIdx, time: ISR.formatTime(frameIdx / fps), type: 'system',
1181
+ label: `Activity Gap`, description: `No detections for ${((frameIdx - prevFrame2) / fps).toFixed(1)}s.`,
1182
+ priority: 'low',
1183
+ });
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ prevTracks = tracks;
1190
+ prevFrame = frameIdx;
1191
+ }
1192
+ }
1193
+
1194
+ // Deduplicate events within 30 frames with same description
1195
+ const deduped = [];
1196
+ for (const evt of events) {
1197
+ const isDupe = deduped.some(e =>
1198
+ Math.abs(e.frame - evt.frame) < 30 && e.description === evt.description
1199
+ );
1200
+ if (!isDupe) deduped.push(evt);
1201
+ }
1202
+
1203
+ // Sort by frame
1204
+ deduped.sort((a, b) => a.frame - b.frame);
1205
+
1206
+ return deduped;
1207
+ }
1208
+
1209
+ function renderEventMarkersFromData(events) {
1210
+ const container = document.getElementById('eventMarkers');
1211
+ if (!container) return;
1212
+ container.innerHTML = '';
1213
+
1214
+ const PRIORITY_COLORS = {
1215
+ critical: '#ef4444',
1216
+ high: '#f59e0b',
1217
+ normal: '#3b82f6',
1218
+ low: '#22c55e',
1219
+ };
1220
+
1221
+ for (const evt of events) {
1222
+ const pct = (evt.frame / ISR.STATE.totalFrames) * 100;
1223
+ const marker = document.createElement('div');
1224
+ marker.className = 'event-marker';
1225
+ marker.style.left = pct + '%';
1226
+ marker.style.background = PRIORITY_COLORS[evt.priority] || PRIORITY_COLORS.normal;
1227
+ if (evt.priority === 'critical' || evt.priority === 'high') {
1228
+ marker.style.boxShadow = `0 0 4px ${PRIORITY_COLORS[evt.priority]}`;
1229
+ }
1230
+ marker.title = `${evt.time} β€” ${evt.label}`;
1231
+
1232
+ marker.addEventListener('click', () => {
1233
+ ISR.STATE.playheadFrame = evt.frame;
1234
+ ISR.updatePlayheadPosition();
1235
+ ISR.updateFrameCounter();
1236
+ ISR.updateTimeDisplay();
1237
+ // Also seek video
1238
+ const processedVideo = document.getElementById('processedVideo');
1239
+ if (ISR.STATE.jobId && processedVideo) {
1240
+ processedVideo.currentTime = evt.frame / ISR.STATE.fps;
1241
+ }
1242
+ });
1243
+
1244
+ // Tooltip on hover
1245
+ marker.addEventListener('mouseenter', () => {
1246
+ document.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove());
1247
+ const tooltip = document.createElement('div');
1248
+ tooltip.className = 'event-marker-tooltip';
1249
+ tooltip.textContent = `${evt.time} β€” ${evt.label}: ${evt.description}`;
1250
+ marker.appendChild(tooltip);
1251
+ });
1252
+ marker.addEventListener('mouseleave', () => {
1253
+ marker.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove());
1254
+ });
1255
+
1256
+ container.appendChild(marker);
1257
+ }
1258
+ }
1259
+
1260
+ function renderEventLogFromData(events) {
1261
+ const log = document.getElementById('eventLog');
1262
+ if (!log) return;
1263
+
1264
+ const PRIORITY_STYLES = {
1265
+ critical: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444' },
1266
+ high: { bg: 'rgba(245,158,11,0.12)', color: '#f59e0b' },
1267
+ normal: { bg: 'rgba(59,130,246,0.12)', color: '#3b82f6' },
1268
+ low: { bg: 'rgba(34,197,94,0.12)', color: '#22c55e' },
1269
+ };
1270
+
1271
+ log.innerHTML = events.map(evt => {
1272
+ const ps = PRIORITY_STYLES[evt.priority] || PRIORITY_STYLES.normal;
1273
+ return `
1274
+ <div class="event-log-entry" data-frame="${evt.frame}">
1275
+ <span class="event-log-time">${evt.time}</span>
1276
+ <span class="event-log-type" style="background: ${ps.bg}; color: ${ps.color};">${evt.type}</span>
1277
+ <span class="event-log-label">${evt.label}</span>
1278
+ <span class="event-log-desc">${evt.description}</span>
1279
+ </div>`;
1280
+ }).join('');
1281
+
1282
+ log.querySelectorAll('.event-log-entry').forEach(entry => {
1283
+ entry.addEventListener('click', () => {
1284
+ const frame = parseInt(entry.dataset.frame, 10);
1285
+ ISR.STATE.playheadFrame = frame;
1286
+ ISR.updatePlayheadPosition();
1287
+ ISR.updateFrameCounter();
1288
+ ISR.updateTimeDisplay();
1289
+ const processedVideo = document.getElementById('processedVideo');
1290
+ if (ISR.STATE.jobId && processedVideo) {
1291
+ processedVideo.currentTime = frame / ISR.STATE.fps;
1292
+ }
1293
+ });
1294
+ });
1295
+ }
1296
+
1297
+ function renderRealTimelineLegend(events) {
1298
+ const legend = document.getElementById('timelineLegend');
1299
+ if (!legend) return;
1300
+
1301
+ const typeCounts = {};
1302
+ for (const evt of events) {
1303
+ typeCounts[evt.type] = (typeCounts[evt.type] || 0) + 1;
1304
+ }
1305
+
1306
+ const TYPE_COLORS = {
1307
+ system: 'var(--text-tertiary)',
1308
+ detection: 'var(--accent)',
1309
+ tracking: 'var(--success)',
1310
+ alert: 'var(--danger)',
1311
+ };
1312
+
1313
+ legend.innerHTML = Object.entries(typeCounts).map(([type, count]) =>
1314
+ `<span class="timeline-legend-item"><span class="dot" style="background: ${TYPE_COLORS[type] || 'var(--text-tertiary)'};"></span> ${type} (${count})</span>`
1315
+ ).join('');
1316
+ }
1317
+
1318
+ // ── Export to namespace ────────────────────────────────────────��
1319
+
1320
+ Object.assign(window.ISR, {
1321
+ startRealProcessing,
1322
+ cleanupProcessing,
1323
+ resetToReady,
1324
+ enterRealAnalysis,
1325
+ renderRealDetections,
1326
+ renderRealTrackList,
1327
+ getKeyframesToSample,
1328
+ renderTrackListFromData,
1329
+ renderSvgOverlay,
1330
+ _updateSvgSelection,
1331
+ buildRealTimeline,
1332
+ deriveEventsFromTracks: deriveTimelineEvents,
1333
+ renderWaveformFromDensity,
1334
+ renderEventMarkersFromData,
1335
+ renderEventLogFromData,
1336
+ renderRealTimelineLegend,
1337
+ });
demo/js/render.js ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 3: Top Bar β€” Clock
5
+ * ================================================================ */
6
+
7
+ function updateClock() {
8
+ const el = document.getElementById('liveClock');
9
+ if (!el) return;
10
+ const now = new Date();
11
+ const h = String(now.getUTCHours()).padStart(2, '0');
12
+ const m = String(now.getUTCMinutes()).padStart(2, '0');
13
+ const s = String(now.getUTCSeconds()).padStart(2, '0');
14
+ el.textContent = `${h}:${m}:${s}Z`;
15
+ }
16
+
17
+ /* ================================================================
18
+ * TASK 4: Video Feed β€” Rendering
19
+ * ================================================================ */
20
+
21
+ let videoCanvas, videoCtx, overlayCanvas, overlayCtx;
22
+ let scanLineY = 0;
23
+ let animFrame = 0;
24
+ let playbackSpeed = 1;
25
+ let lastFrameTime = 0;
26
+
27
+ function initCanvases() {
28
+ videoCanvas = document.getElementById('videoCanvas');
29
+ overlayCanvas = document.getElementById('overlayCanvas');
30
+ videoCtx = videoCanvas.getContext('2d');
31
+ overlayCtx = overlayCanvas.getContext('2d');
32
+ resizeCanvases();
33
+ }
34
+
35
+ function resizeCanvases() {
36
+ const container = document.getElementById('videoFeed');
37
+ const rect = container.getBoundingClientRect();
38
+ const dpr = window.devicePixelRatio || 1;
39
+ [videoCanvas, overlayCanvas].forEach(c => {
40
+ c.width = rect.width * dpr;
41
+ c.height = rect.height * dpr;
42
+ c.style.width = rect.width + 'px';
43
+ c.style.height = rect.height + 'px';
44
+ c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
45
+ });
46
+ }
47
+
48
+ function renderVideoBackground(ctx, w, h, frame) {
49
+ ctx.fillStyle = '#0a0f1a';
50
+ ctx.fillRect(0, 0, w, h);
51
+
52
+ // Grid lines
53
+ ctx.strokeStyle = 'rgba(255,255,255,0.02)';
54
+ ctx.lineWidth = 0.5;
55
+ for (let x = 0; x < w; x += 40) {
56
+ ctx.beginPath();
57
+ ctx.moveTo(x, 0);
58
+ ctx.lineTo(x, h);
59
+ ctx.stroke();
60
+ }
61
+ for (let y = 0; y < h; y += 40) {
62
+ ctx.beginPath();
63
+ ctx.moveTo(0, y);
64
+ ctx.lineTo(w, y);
65
+ ctx.stroke();
66
+ }
67
+
68
+ // Scan line β€” slow horizontal sweep
69
+ scanLineY = (scanLineY + 0.5) % h;
70
+ const scanGrad = ctx.createLinearGradient(0, scanLineY - 30, 0, scanLineY + 30);
71
+ scanGrad.addColorStop(0, 'rgba(255,255,255,0)');
72
+ scanGrad.addColorStop(0.5, 'rgba(255,255,255,0.015)');
73
+ scanGrad.addColorStop(1, 'rgba(255,255,255,0)');
74
+ ctx.fillStyle = scanGrad;
75
+ ctx.fillRect(0, scanLineY - 30, w, 60);
76
+
77
+ // Vignette effect β€” darker at edges
78
+ 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);
79
+ vignetteGrad.addColorStop(0, 'rgba(0,0,0,0)');
80
+ vignetteGrad.addColorStop(1, 'rgba(0,0,0,0.35)');
81
+ ctx.fillStyle = vignetteGrad;
82
+ ctx.fillRect(0, 0, w, h);
83
+
84
+ // Corner crosshair markers β€” military tactical HUD
85
+ const crossLen = 18;
86
+ const crossOff = 12;
87
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
88
+ ctx.lineWidth = 1;
89
+
90
+ // Top-left
91
+ ctx.beginPath();
92
+ ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff + crossLen, crossOff);
93
+ ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff, crossOff + crossLen);
94
+ ctx.stroke();
95
+
96
+ // Top-right
97
+ ctx.beginPath();
98
+ ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff - crossLen, crossOff);
99
+ ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff, crossOff + crossLen);
100
+ ctx.stroke();
101
+
102
+ // Bottom-left
103
+ ctx.beginPath();
104
+ ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff + crossLen, h - crossOff);
105
+ ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff, h - crossOff - crossLen);
106
+ ctx.stroke();
107
+
108
+ // Bottom-right
109
+ ctx.beginPath();
110
+ ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff - crossLen, h - crossOff);
111
+ ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff, h - crossOff - crossLen);
112
+ ctx.stroke();
113
+ }
114
+
115
+ // Track which boxes have been seen, for fade-in animation
116
+ const boxFirstSeen = {};
117
+ let boxAnimTime = 0;
118
+
119
+ function renderDetections(ctx, w, h, frame) {
120
+ const STATE = ISR.STATE;
121
+ ctx.clearRect(0, 0, w, h);
122
+ const tracks = ISR.getTracksAtFrame(frame);
123
+ boxAnimTime = performance.now();
124
+
125
+ for (const t of tracks) {
126
+ const bx = (t.bbox.x / 100) * w;
127
+ const by = (t.bbox.y / 100) * h;
128
+ const bw = (t.bbox.w / 100) * w;
129
+ const bh = (t.bbox.h / 100) * h;
130
+
131
+ // Fade-in: track when boxes first appear
132
+ if (!boxFirstSeen[t.id]) {
133
+ boxFirstSeen[t.id] = boxAnimTime;
134
+ }
135
+ const age = boxAnimTime - boxFirstSeen[t.id];
136
+ const fadeAlpha = Math.min(1, age / 300); // 300ms fade-in
137
+
138
+ const isSelected = (STATE.selectedTrackId === t.id);
139
+ const isHighlighted = (ISR.highlightedTrackId === t.id) || isSelected;
140
+ const lineWidth = isHighlighted ? 2.5 : 1.5;
141
+
142
+ ctx.globalAlpha = fadeAlpha;
143
+
144
+ // Pulsing glow for highlighted boxes
145
+ if (isHighlighted) {
146
+ const pulse = 0.6 + 0.4 * Math.sin(boxAnimTime / 300);
147
+ ctx.shadowColor = t.color;
148
+ ctx.shadowBlur = 8 + 8 * pulse;
149
+ }
150
+
151
+ // Selected box: animated dashed border
152
+ if (isSelected) {
153
+ ctx.setLineDash([6, 4]);
154
+ ctx.lineDashOffset = -(boxAnimTime / 50); // marching ants
155
+ ctx.strokeStyle = '#fff';
156
+ ctx.lineWidth = 2;
157
+ ISR.roundRect(ctx, bx, by, bw, bh, 3);
158
+ ctx.stroke();
159
+ ctx.setLineDash([]);
160
+ ctx.lineDashOffset = 0;
161
+ }
162
+
163
+ // Rounded rect border
164
+ ctx.strokeStyle = isHighlighted ? '#fff' : t.color;
165
+ ctx.lineWidth = lineWidth;
166
+ ISR.roundRect(ctx, bx, by, bw, bh, 3);
167
+ ctx.stroke();
168
+
169
+ ctx.shadowColor = 'transparent';
170
+ ctx.shadowBlur = 0;
171
+
172
+ // Faint fill
173
+ ctx.fillStyle = t.color + (isHighlighted ? '1A' : '0D');
174
+ ISR.roundRect(ctx, bx, by, bw, bh, 3);
175
+ ctx.fill();
176
+
177
+ // Label badge above
178
+ const labelText = `${t.label.split(' ').pop()} ${t.confidence.toFixed(2)}`;
179
+ ctx.font = '500 9px Inter, sans-serif';
180
+ const tm = ctx.measureText(labelText);
181
+ const lw = tm.width + 8;
182
+ const lh = 14;
183
+ const lx = bx;
184
+ const ly = by - lh - 3;
185
+
186
+ ctx.fillStyle = t.color + 'CC';
187
+ ISR.roundRect(ctx, lx, ly, lw, lh, 2);
188
+ ctx.fill();
189
+
190
+ ctx.fillStyle = '#fff';
191
+ ctx.textBaseline = 'middle';
192
+ ctx.fillText(labelText, lx + 4, ly + lh / 2);
193
+
194
+ ctx.globalAlpha = 1;
195
+ }
196
+ }
197
+
198
+ function updateFrameCounter() {
199
+ const STATE = ISR.STATE;
200
+ const el = document.getElementById('frameCounter');
201
+ if (el) el.textContent = `FRM ${STATE.playheadFrame} / ${STATE.totalFrames}`;
202
+ }
203
+
204
+ function mainRenderLoop(timestamp) {
205
+ const STATE = ISR.STATE;
206
+ if (!videoCanvas) { requestAnimationFrame(mainRenderLoop); return; }
207
+ const container = document.getElementById('videoFeed');
208
+ const rect = container.getBoundingClientRect();
209
+ const w = rect.width;
210
+ const h = rect.height;
211
+
212
+ renderVideoBackground(videoCtx, w, h, STATE.playheadFrame);
213
+
214
+ const svgOverlay = document.getElementById('detectionOverlay');
215
+ if (STATE.current !== 'ready') {
216
+ // Use real detection rendering when we have a jobId in analysis/inspect/playing
217
+ if (STATE.jobId && (STATE.current === 'analysis' || STATE.current === 'playing' || STATE.current === 'inspect')) {
218
+ // Real detection rendering is driven by video timeupdate, NOT the render loop
219
+ // Only update SVG pointer events here
220
+ if (svgOverlay) svgOverlay.style.pointerEvents = 'all';
221
+ } else {
222
+ renderDetections(overlayCtx, w, h, STATE.playheadFrame);
223
+ // Hide SVG overlay in mock mode
224
+ if (svgOverlay) {
225
+ while (svgOverlay.firstChild) svgOverlay.removeChild(svgOverlay.firstChild);
226
+ svgOverlay.style.pointerEvents = 'none';
227
+ }
228
+ }
229
+ } else {
230
+ overlayCtx.clearRect(0, 0, w, h);
231
+ // Clear and disable SVG overlay in ready state
232
+ if (svgOverlay) {
233
+ while (svgOverlay.firstChild) svgOverlay.removeChild(svgOverlay.firstChild);
234
+ svgOverlay.style.pointerEvents = 'none';
235
+ }
236
+ }
237
+
238
+ // Advance playhead if playing (mock playback only β€” video element handles real playback)
239
+ if (STATE.isPlaying && !STATE.jobId && (STATE.current === 'playing' || STATE.current === 'analysis')) {
240
+ if (!lastFrameTime) lastFrameTime = timestamp;
241
+ const elapsed = timestamp - lastFrameTime;
242
+ const framesPerMs = (STATE.fps * playbackSpeed) / 1000;
243
+ const framesToAdvance = Math.floor(elapsed * framesPerMs);
244
+ if (framesToAdvance > 0) {
245
+ STATE.playheadFrame = Math.min(STATE.playheadFrame + framesToAdvance, STATE.totalFrames);
246
+ lastFrameTime = timestamp;
247
+ updateFrameCounter();
248
+ ISR.updatePlayheadPosition();
249
+ ISR.updateTimeDisplay();
250
+ if (STATE.playheadFrame >= STATE.totalFrames) {
251
+ STATE.isPlaying = false;
252
+ STATE.playheadFrame = STATE.totalFrames;
253
+ document.getElementById('playPauseBtn').innerHTML = '&#9654;';
254
+ }
255
+ }
256
+ }
257
+
258
+ requestAnimationFrame(mainRenderLoop);
259
+ }
260
+
261
+ // ── Export to namespace ─────────────────────────────────────────
262
+
263
+ Object.assign(window.ISR, {
264
+ updateClock,
265
+ initCanvases,
266
+ resizeCanvases,
267
+ renderVideoBackground,
268
+ renderDetections,
269
+ updateFrameCounter,
270
+ mainRenderLoop,
271
+ boxFirstSeen,
272
+ get videoCanvas() { return videoCanvas; },
273
+ get videoCtx() { return videoCtx; },
274
+ get overlayCanvas() { return overlayCanvas; },
275
+ get overlayCtx() { return overlayCtx; },
276
+ get playbackSpeed() { return playbackSpeed; },
277
+ set playbackSpeed(v) { playbackSpeed = v; },
278
+ get lastFrameTime() { return lastFrameTime; },
279
+ set lastFrameTime(v) { lastFrameTime = v; },
280
+ });
demo/js/state-machine.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 8: State Machine
5
+ * ================================================================ */
6
+
7
+ let processingInterval = null;
8
+ let playbackAnimFrame = null;
9
+ let highlightedTrackId = null;
10
+
11
+ /** Shared helper: update highlight state + toggle card CSS class. */
12
+ let _tracksPanelEl = null;
13
+ function _updateTrackHighlight(trackId) {
14
+ highlightedTrackId = trackId;
15
+ ISR.STATE.highlightTrackId = trackId;
16
+ if (!_tracksPanelEl || !_tracksPanelEl.isConnected) {
17
+ _tracksPanelEl = document.getElementById('tracksPanel');
18
+ }
19
+ const panel = _tracksPanelEl;
20
+ if (!panel) return;
21
+ const prev = panel.querySelector('.track-card.highlighted');
22
+ if (prev) prev.classList.remove('highlighted');
23
+ if (trackId) {
24
+ const next = panel.querySelector(`.track-card[data-track-id="${trackId}"]`);
25
+ if (next) next.classList.add('highlighted');
26
+ }
27
+ }
28
+
29
+ let _prevHighlightHit = null;
30
+
31
+ function setState(newState) {
32
+ const prev = ISR.STATE.current;
33
+ ISR.STATE.current = newState;
34
+ _prevHighlightHit = null; // reset highlight cache on state transition
35
+ document.body.setAttribute('data-state', newState);
36
+ onStateChange(prev, newState);
37
+ }
38
+
39
+ function onStateChange(prev, newState) {
40
+ const topDot = document.querySelector('.topbar-left .dot');
41
+ const missionLabel = document.querySelector('.topbar-mission');
42
+
43
+ // Update top bar status
44
+ if (newState === 'ready') {
45
+ if (topDot) {
46
+ topDot.style.color = 'var(--accent)';
47
+ topDot.style.background = 'var(--accent)';
48
+ topDot.classList.remove('topbar-dot-processing');
49
+ }
50
+ if (missionLabel) missionLabel.textContent = 'MISSION ACTIVE';
51
+ } else if (newState === 'processing') {
52
+ if (topDot) {
53
+ topDot.style.color = 'var(--warning)';
54
+ topDot.style.background = 'var(--warning)';
55
+ topDot.classList.add('topbar-dot-processing');
56
+ }
57
+ if (missionLabel) {
58
+ missionLabel.style.color = 'var(--warning)';
59
+ missionLabel.textContent = 'PROCESSING';
60
+ }
61
+ } else if (newState === 'analysis' || newState === 'playing') {
62
+ if (topDot) {
63
+ topDot.style.color = 'var(--success)';
64
+ topDot.style.background = 'var(--success)';
65
+ topDot.classList.remove('topbar-dot-processing');
66
+ }
67
+ if (missionLabel) {
68
+ missionLabel.style.color = 'var(--success)';
69
+ missionLabel.textContent = 'ANALYSIS';
70
+ }
71
+ } else if (newState === 'inspect') {
72
+ if (topDot) {
73
+ topDot.style.color = 'var(--accent-light)';
74
+ topDot.style.background = 'var(--accent-light)';
75
+ topDot.classList.remove('topbar-dot-processing');
76
+ }
77
+ if (missionLabel) {
78
+ missionLabel.style.color = 'var(--accent-light)';
79
+ missionLabel.textContent = 'INSPECT';
80
+ }
81
+ }
82
+
83
+ // Drawer tab availability managed by CSS, but switch to appropriate tab
84
+ if (newState === 'ready') {
85
+ ISR.switchDrawerTab('tracks');
86
+ } else if (newState === 'processing') {
87
+ ISR.switchDrawerTab('tracks');
88
+ } else if (newState === 'analysis' || newState === 'playing') {
89
+ ISR.switchDrawerTab('tracks');
90
+ } else if (newState === 'inspect') {
91
+ ISR.switchDrawerTab('inspect');
92
+ }
93
+ }
94
+
95
+ /* ================================================================
96
+ * TASK 8: Processing Simulation
97
+ * ================================================================ */
98
+
99
+ function startProcessingSimulation() {
100
+ // Fade out start button before transitioning
101
+ const startBtn = document.getElementById('startBtn');
102
+ if (startBtn) startBtn.classList.add('fading-out');
103
+
104
+ // Clear box animation tracking for fresh detection appearances
105
+ Object.keys(ISR.boxFirstSeen).forEach(k => delete ISR.boxFirstSeen[k]);
106
+
107
+ setTimeout(() => {
108
+ setState('processing');
109
+
110
+ const circumference = 2 * Math.PI * 34;
111
+ const circle = document.getElementById('progressCircle');
112
+ const pctEl = document.getElementById('progressPct');
113
+ const waveformCanvas = document.getElementById('waveformCanvas');
114
+ const tracksPanel = document.getElementById('tracksPanel');
115
+ const topBarProgress = document.getElementById('topBarProgress');
116
+ const fpsDisplay = document.getElementById('fpsDisplay');
117
+
118
+ ISR.STATE.playheadFrame = 0;
119
+ let startTime = performance.now();
120
+ const duration = 10000; // 10 seconds
121
+ let lastTrackIndex = 0;
122
+ const trackStaggerInterval = 500; // one every 0.5s
123
+ let lastFpsFlicker = 0;
124
+
125
+ // Clear track list
126
+ tracksPanel.innerHTML = '';
127
+
128
+ processingInterval = setInterval(() => {
129
+ const elapsed = performance.now() - startTime;
130
+ const progress = Math.min(elapsed / duration, 1.0);
131
+
132
+ // Update playhead frame linearly
133
+ ISR.STATE.playheadFrame = Math.round(progress * ISR.STATE.totalFrames);
134
+ ISR.updateFrameCounter();
135
+ ISR.updatePlayheadPosition();
136
+
137
+ // Update time display
138
+ ISR.updateTimeDisplay();
139
+
140
+ // Update progress ring
141
+ const offset = circumference - progress * circumference;
142
+ circle.style.strokeDashoffset = offset;
143
+ pctEl.textContent = Math.round(progress * 100) + '%';
144
+
145
+ // Update top bar progress bar
146
+ if (topBarProgress) {
147
+ topBarProgress.style.width = (progress * 100) + '%';
148
+ }
149
+
150
+ // FPS flicker effect β€” every ~800ms
151
+ if (fpsDisplay && elapsed - lastFpsFlicker > 800) {
152
+ lastFpsFlicker = elapsed;
153
+ const fpsVal = 45 + Math.floor(Math.random() * 8);
154
+ fpsDisplay.textContent = fpsVal + ' FPS';
155
+ fpsDisplay.classList.add('fps-updating');
156
+ setTimeout(() => fpsDisplay.classList.remove('fps-updating'), 300);
157
+ }
158
+
159
+ // Progressively fill waveform β€” render partial density
160
+ if (waveformCanvas) {
161
+ const visibleBars = Math.round(progress * ISR.MOCK_DENSITY.length);
162
+ const partialDensity = ISR.MOCK_DENSITY.map((v, i) => i < visibleBars ? v : 0);
163
+ ISR.renderWaveform(waveformCanvas, partialDensity);
164
+ }
165
+
166
+ // Stagger track cards
167
+ const tracksToShow = Math.min(Math.floor(elapsed / trackStaggerInterval), ISR.MOCK_TRACKS.length);
168
+ while (lastTrackIndex < tracksToShow) {
169
+ ISR.appendTrackCard(ISR.MOCK_TRACKS[lastTrackIndex], tracksPanel);
170
+ lastTrackIndex++;
171
+ }
172
+
173
+ // Completion
174
+ if (progress >= 1.0) {
175
+ clearInterval(processingInterval);
176
+ processingInterval = null;
177
+
178
+ // Reset top bar progress
179
+ if (topBarProgress) {
180
+ topBarProgress.style.width = '100%';
181
+ setTimeout(() => {
182
+ topBarProgress.style.opacity = '0';
183
+ setTimeout(() => { topBarProgress.style.width = '0%'; }, 500);
184
+ }, 300);
185
+ }
186
+
187
+ // Reset start button state and FPS display
188
+ if (startBtn) startBtn.classList.remove('fading-out');
189
+ if (fpsDisplay) fpsDisplay.textContent = '48 FPS';
190
+
191
+ // Brief pause then transition to analysis
192
+ setTimeout(() => {
193
+ ISR.enterAnalysisState();
194
+ }, 400);
195
+ }
196
+ }, 1000 / 60); // ~60fps
197
+ }, 300); // Wait for start button fade
198
+ }
199
+
200
+ // ── Export to namespace ─────────────────────────────────────────
201
+
202
+ Object.assign(window.ISR, {
203
+ get highlightedTrackId() { return highlightedTrackId; },
204
+ set highlightedTrackId(v) { highlightedTrackId = v; },
205
+ get processingInterval() { return processingInterval; },
206
+ set processingInterval(v) { processingInterval = v; },
207
+ get playbackAnimFrame() { return playbackAnimFrame; },
208
+ set playbackAnimFrame(v) { playbackAnimFrame = v; },
209
+ get _prevHighlightHit() { return _prevHighlightHit; },
210
+ set _prevHighlightHit(v) { _prevHighlightHit = v; },
211
+ _updateTrackHighlight,
212
+ setState,
213
+ onStateChange,
214
+ startProcessingSimulation,
215
+ });
demo/js/state.js ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * ISR COMMAND CENTER β€” Mock Data Layer
5
+ * ================================================================ */
6
+
7
+ // ── State ───────────────────────────────────────────────────────
8
+
9
+ const STATE = {
10
+ current: 'ready',
11
+ selectedTrackId: null,
12
+ expandedQuadrant: null,
13
+ playheadFrame: 0,
14
+ totalFrames: 1847,
15
+ fps: 30,
16
+ isPlaying: false,
17
+ drawerTab: 'config',
18
+ eventLogOpen: false,
19
+ trackFilter: null,
20
+ jobId: null,
21
+ mission: null,
22
+ videoFile: null,
23
+ chatHistory: [],
24
+ jobConfig: null,
25
+ jobStatus: null,
26
+ _realTracks: null,
27
+ _timelineSummary: null,
28
+ _analysisStartTime: null,
29
+ _lastRenderedFrame: -1,
30
+ _derivedEvents: null,
31
+ highlightTrackId: null,
32
+ };
33
+
34
+ // ── Color Map ───────────────────────────────────────────────────
35
+
36
+ const TYPE_COLORS = {
37
+ person: '#3b82f6',
38
+ vehicle: '#f59e0b',
39
+ drone: '#ef4444',
40
+ stationary: '#22c55e',
41
+ };
42
+
43
+ function getColorForType(type) {
44
+ return TYPE_COLORS[type] || '#8b5cf6';
45
+ }
46
+
47
+ // ── Track Schema ────────────────────────────────────────────────
48
+
49
+ function createTrack(id, label, type, confidence, speed, depth, area, keyframes) {
50
+ return {
51
+ id,
52
+ label,
53
+ type,
54
+ color: getColorForType(type),
55
+ confidence,
56
+ speed, // kph
57
+ depth, // meters
58
+ area, // percentage of frame
59
+ keyframes, // [{ frame, bbox: { x, y, w, h } }] β€” values as %
60
+ active: true,
61
+ };
62
+ }
63
+
64
+ // ── 20 Mock Tracks ──────────────────────────────────────────────
65
+
66
+ const MOCK_TRACKS = [
67
+ // 8 persons
68
+ createTrack('P-001', 'Person Alpha', 'person', 0.94, 5.2, 12.4, 1.8, [
69
+ { frame: 0, bbox: { x: 10, y: 30, w: 4, h: 10 } },
70
+ { frame: 400, bbox: { x: 20, y: 32, w: 4, h: 10 } },
71
+ { frame: 800, bbox: { x: 35, y: 34, w: 4.5, h: 11 } },
72
+ { frame: 1200, bbox: { x: 48, y: 33, w: 4, h: 10 } },
73
+ { frame: 1600, bbox: { x: 60, y: 31, w: 4, h: 10 } },
74
+ ]),
75
+ createTrack('P-002', 'Person Bravo', 'person', 0.91, 8.7, 18.1, 1.5, [
76
+ { frame: 100, bbox: { x: 55, y: 45, w: 3.5, h: 9 } },
77
+ { frame: 500, bbox: { x: 50, y: 43, w: 3.5, h: 9 } },
78
+ { frame: 900, bbox: { x: 42, y: 40, w: 4, h: 10 } },
79
+ { frame: 1300, bbox: { x: 33, y: 38, w: 4, h: 10 } },
80
+ { frame: 1700, bbox: { x: 25, y: 36, w: 3.5, h: 9 } },
81
+ ]),
82
+ createTrack('P-003', 'Person Charlie', 'person', 0.88, 3.1, 8.5, 2.1, [
83
+ { frame: 200, bbox: { x: 70, y: 55, w: 5, h: 12 } },
84
+ { frame: 600, bbox: { x: 68, y: 54, w: 5, h: 12 } },
85
+ { frame: 1000, bbox: { x: 65, y: 52, w: 5, h: 12 } },
86
+ { frame: 1400, bbox: { x: 62, y: 50, w: 5, h: 12 } },
87
+ ]),
88
+ createTrack('P-004', 'Person Delta', 'person', 0.86, 12.4, 22.3, 1.2, [
89
+ { frame: 50, bbox: { x: 15, y: 60, w: 3, h: 8 } },
90
+ { frame: 450, bbox: { x: 25, y: 55, w: 3.5, h: 9 } },
91
+ { frame: 850, bbox: { x: 40, y: 48, w: 4, h: 10 } },
92
+ { frame: 1250, bbox: { x: 55, y: 42, w: 4, h: 10 } },
93
+ { frame: 1650, bbox: { x: 70, y: 38, w: 3.5, h: 9 } },
94
+ { frame: 1847, bbox: { x: 80, y: 35, w: 3, h: 8 } },
95
+ ]),
96
+ createTrack('P-005', 'Person Echo', 'person', 0.92, 1.8, 6.2, 2.5, [
97
+ { frame: 300, bbox: { x: 45, y: 70, w: 5.5, h: 13 } },
98
+ { frame: 700, bbox: { x: 44, y: 69, w: 5.5, h: 13 } },
99
+ { frame: 1100, bbox: { x: 43, y: 68, w: 5.5, h: 13 } },
100
+ { frame: 1500, bbox: { x: 42, y: 67, w: 5.5, h: 13 } },
101
+ ]),
102
+ createTrack('P-006', 'Person Foxtrot', 'person', 0.79, 22.1, 30.5, 0.9, [
103
+ { frame: 0, bbox: { x: 80, y: 25, w: 3, h: 7 } },
104
+ { frame: 350, bbox: { x: 65, y: 30, w: 3.5, h: 8 } },
105
+ { frame: 700, bbox: { x: 45, y: 38, w: 4, h: 10 } },
106
+ { frame: 1050, bbox: { x: 28, y: 44, w: 4, h: 10 } },
107
+ { frame: 1400, bbox: { x: 12, y: 50, w: 3.5, h: 8 } },
108
+ ]),
109
+ createTrack('P-007', 'Person Golf', 'person', 0.83, 6.5, 14.8, 1.6, [
110
+ { frame: 150, bbox: { x: 30, y: 20, w: 4, h: 10 } },
111
+ { frame: 550, bbox: { x: 35, y: 22, w: 4, h: 10 } },
112
+ { frame: 950, bbox: { x: 38, y: 25, w: 4, h: 10 } },
113
+ { frame: 1350, bbox: { x: 40, y: 28, w: 4, h: 10 } },
114
+ { frame: 1750, bbox: { x: 42, y: 30, w: 4, h: 10 } },
115
+ ]),
116
+ createTrack('P-008', 'Person Hotel', 'person', 0.77, 15.3, 25.0, 1.1, [
117
+ { frame: 250, bbox: { x: 88, y: 40, w: 3, h: 8 } },
118
+ { frame: 650, bbox: { x: 75, y: 42, w: 3.5, h: 9 } },
119
+ { frame: 1050, bbox: { x: 58, y: 45, w: 4, h: 10 } },
120
+ { frame: 1450, bbox: { x: 40, y: 48, w: 4, h: 10 } },
121
+ ]),
122
+
123
+ // 5 vehicles
124
+ createTrack('V-001', 'Vehicle Alpha', 'vehicle', 0.96, 45.0, 35.2, 4.8, [
125
+ { frame: 0, bbox: { x: 5, y: 50, w: 10, h: 8 } },
126
+ { frame: 400, bbox: { x: 25, y: 48, w: 10, h: 8 } },
127
+ { frame: 800, bbox: { x: 50, y: 46, w: 11, h: 9 } },
128
+ { frame: 1200, bbox: { x: 72, y: 44, w: 10, h: 8 } },
129
+ { frame: 1600, bbox: { x: 90, y: 42, w: 9, h: 7 } },
130
+ ]),
131
+ createTrack('V-002', 'Vehicle Bravo', 'vehicle', 0.93, 62.3, 42.1, 5.2, [
132
+ { frame: 200, bbox: { x: 90, y: 55, w: 9, h: 7 } },
133
+ { frame: 550, bbox: { x: 70, y: 53, w: 10, h: 8 } },
134
+ { frame: 900, bbox: { x: 48, y: 50, w: 11, h: 9 } },
135
+ { frame: 1250, bbox: { x: 25, y: 48, w: 10, h: 8 } },
136
+ { frame: 1600, bbox: { x: 5, y: 46, w: 9, h: 7 } },
137
+ ]),
138
+ createTrack('V-003', 'Vehicle Charlie', 'vehicle', 0.89, 38.7, 28.9, 3.9, [
139
+ { frame: 100, bbox: { x: 40, y: 62, w: 9, h: 7 } },
140
+ { frame: 500, bbox: { x: 50, y: 60, w: 9.5, h: 7.5 } },
141
+ { frame: 900, bbox: { x: 60, y: 58, w: 10, h: 8 } },
142
+ { frame: 1300, bbox: { x: 68, y: 56, w: 10, h: 8 } },
143
+ { frame: 1700, bbox: { x: 75, y: 54, w: 9, h: 7 } },
144
+ ]),
145
+ createTrack('V-004', 'Vehicle Delta', 'vehicle', 0.85, 78.5, 55.0, 3.2, [
146
+ { frame: 0, bbox: { x: 92, y: 35, w: 7, h: 6 } },
147
+ { frame: 300, bbox: { x: 68, y: 38, w: 8, h: 7 } },
148
+ { frame: 600, bbox: { x: 42, y: 42, w: 10, h: 8 } },
149
+ { frame: 900, bbox: { x: 18, y: 45, w: 10, h: 8 } },
150
+ ]),
151
+ createTrack('V-005', 'Vehicle Echo', 'vehicle', 0.91, 31.2, 20.7, 4.5, [
152
+ { frame: 500, bbox: { x: 10, y: 68, w: 10, h: 8 } },
153
+ { frame: 900, bbox: { x: 30, y: 65, w: 10.5, h: 8.5 } },
154
+ { frame: 1300, bbox: { x: 52, y: 62, w: 11, h: 9 } },
155
+ { frame: 1700, bbox: { x: 72, y: 60, w: 10, h: 8 } },
156
+ ]),
157
+
158
+ // 2 drones
159
+ createTrack('D-001', 'Drone Alpha', 'drone', 0.97, 42.0, 85.0, 0.6, [
160
+ { frame: 400, bbox: { x: 60, y: 8, w: 2.5, h: 2 } },
161
+ { frame: 700, bbox: { x: 50, y: 12, w: 3, h: 2.5 } },
162
+ { frame: 1000, bbox: { x: 38, y: 10, w: 3, h: 2.5 } },
163
+ { frame: 1300, bbox: { x: 28, y: 6, w: 2.5, h: 2 } },
164
+ { frame: 1600, bbox: { x: 18, y: 8, w: 2, h: 1.8 } },
165
+ { frame: 1847, bbox: { x: 10, y: 5, w: 2, h: 1.5 } },
166
+ ]),
167
+ createTrack('D-002', 'Drone Bravo', 'drone', 0.93, 55.8, 120.0, 0.4, [
168
+ { frame: 800, bbox: { x: 20, y: 5, w: 2, h: 1.5 } },
169
+ { frame: 1050, bbox: { x: 35, y: 8, w: 2.5, h: 2 } },
170
+ { frame: 1300, bbox: { x: 52, y: 6, w: 2.5, h: 2 } },
171
+ { frame: 1550, bbox: { x: 68, y: 10, w: 3, h: 2.5 } },
172
+ { frame: 1800, bbox: { x: 82, y: 7, w: 2.5, h: 2 } },
173
+ ]),
174
+
175
+ // 5 stationary
176
+ createTrack('S-001', 'Stationary Alpha', 'stationary', 0.95, 0.0, 10.2, 3.8, [
177
+ { frame: 0, bbox: { x: 75, y: 72, w: 8, h: 6 } },
178
+ { frame: 600, bbox: { x: 75, y: 72, w: 8, h: 6 } },
179
+ { frame: 1200, bbox: { x: 75, y: 72, w: 8, h: 6 } },
180
+ { frame: 1847, bbox: { x: 75, y: 72, w: 8, h: 6 } },
181
+ ]),
182
+ createTrack('S-002', 'Stationary Bravo', 'stationary', 0.90, 0.2, 15.8, 5.1, [
183
+ { frame: 0, bbox: { x: 22, y: 78, w: 10, h: 7 } },
184
+ { frame: 600, bbox: { x: 22, y: 78, w: 10, h: 7 } },
185
+ { frame: 1200, bbox: { x: 22, y: 78, w: 10, h: 7 } },
186
+ { frame: 1847, bbox: { x: 22, y: 78, w: 10, h: 7 } },
187
+ ]),
188
+ createTrack('S-003', 'Stationary Charlie', 'stationary', 0.87, 0.1, 8.3, 2.4, [
189
+ { frame: 0, bbox: { x: 50, y: 82, w: 6, h: 5 } },
190
+ { frame: 900, bbox: { x: 50, y: 82, w: 6, h: 5 } },
191
+ { frame: 1847, bbox: { x: 50, y: 82, w: 6, h: 5 } },
192
+ ]),
193
+ createTrack('S-004', 'Stationary Delta', 'stationary', 0.82, 0.5, 22.1, 6.2, [
194
+ { frame: 0, bbox: { x: 88, y: 65, w: 11, h: 8 } },
195
+ { frame: 500, bbox: { x: 88, y: 65, w: 11, h: 8 } },
196
+ { frame: 1000, bbox: { x: 88, y: 65.5, w: 11, h: 8 } },
197
+ { frame: 1500, bbox: { x: 88, y: 65, w: 11, h: 8 } },
198
+ { frame: 1847, bbox: { x: 88, y: 65, w: 11, h: 8 } },
199
+ ]),
200
+ createTrack('S-005', 'Stationary Echo', 'stationary', 0.78, 0.3, 5.6, 1.9, [
201
+ { frame: 200, bbox: { x: 35, y: 58, w: 5, h: 4 } },
202
+ { frame: 700, bbox: { x: 35, y: 58, w: 5, h: 4 } },
203
+ { frame: 1200, bbox: { x: 35, y: 58, w: 5, h: 4 } },
204
+ { frame: 1700, bbox: { x: 35, y: 58, w: 5, h: 4 } },
205
+ ]),
206
+ ];
207
+
208
+ // ── 18 Mock Timeline Events ─────────────────────────────────────
209
+
210
+ const MOCK_EVENTS = [
211
+ { frame: 0, time: '00:00:00', type: 'system', label: 'Mission Start', description: 'ISR feed initialized. All sensors nominal.', priority: 'low' },
212
+ { frame: 85, time: '00:00:03', type: 'detection', label: 'First Detection', description: 'Person Alpha acquired at sector 2.', priority: 'medium' },
213
+ { frame: 210, time: '00:00:07', type: 'detection', label: 'Vehicle Spotted', description: 'Vehicle Alpha entering FOV from west.', priority: 'medium' },
214
+ { frame: 400, time: '00:00:13', type: 'alert', label: 'Drone Detected', description: 'Drone Alpha detected at high altitude. Tracking.', priority: 'high' },
215
+ { frame: 520, time: '00:00:17', type: 'tracking', label: 'Track Merge', description: 'Tracks P-003 and P-005 proximity alert.', priority: 'low' },
216
+ { frame: 650, time: '00:00:22', type: 'detection', label: 'New Vehicle', description: 'Vehicle Delta β€” high speed approach detected.', priority: 'medium' },
217
+ { frame: 780, time: '00:00:26', type: 'alert', label: 'Speed Warning', description: 'Vehicle Delta exceeding 75 kph in restricted zone.', priority: 'high' },
218
+ { frame: 800, time: '00:00:27', type: 'alert', label: 'Second Drone', description: 'Drone Bravo detected. Multiple aerial contacts.', priority: 'high' },
219
+ { frame: 920, time: '00:00:31', type: 'tracking', label: 'Track Lost', description: 'Vehicle Delta exited FOV at sector 1.', priority: 'medium' },
220
+ { frame: 1050, time: '00:00:35', type: 'detection', label: 'Crowd Formation', description: '5 persons converging at grid reference 4-C.', priority: 'medium' },
221
+ { frame: 1180, time: '00:00:39', type: 'system', label: 'Depth Map Update', description: 'Depth estimation recalibrated. Accuracy +12%.', priority: 'low' },
222
+ { frame: 1300, time: '00:00:43', type: 'alert', label: 'Drone Maneuver', description: 'Drone Alpha executing erratic flight pattern.', priority: 'high' },
223
+ { frame: 1380, time: '00:00:46', type: 'tracking', label: 'Re-ID Confirmed', description: 'Person Hotel re-identified after occlusion.', priority: 'low' },
224
+ { frame: 1450, time: '00:00:48', type: 'detection', label: 'Vehicle Convoy', description: 'Vehicles Bravo and Echo moving in formation.', priority: 'medium' },
225
+ { frame: 1550, time: '00:00:52', type: 'alert', label: 'Perimeter Breach', description: 'Drone Bravo approaching restricted airspace.', priority: 'high' },
226
+ { frame: 1650, time: '00:00:55', type: 'tracking', label: 'Person Foxtrot Exit', description: 'Person Foxtrot exited FOV. Track archived.', priority: 'low' },
227
+ { frame: 1750, time: '00:00:58', type: 'system', label: 'Frame Drop Warning', description: 'Processing latency spike: 45ms β†’ 120ms.', priority: 'medium' },
228
+ { frame: 1847, time: '00:01:02', type: 'system', label: 'Mission Complete', description: 'ISR feed terminated. 20 tracks catalogued.', priority: 'low' },
229
+ ];
230
+
231
+ // ── Mock Detection Density (100 values, 0-1) ────────────────────
232
+
233
+ const MOCK_DENSITY = [];
234
+ const DENSITY_TYPES = [];
235
+ (function generateDensity() {
236
+ const segments = 100;
237
+ for (let i = 0; i < segments; i++) {
238
+ const t = i / segments;
239
+ // Base density with peaks at key event areas
240
+ let d = 0.15 + Math.random() * 0.15;
241
+ // Peak near frame 400 (drone entry)
242
+ d += 0.35 * Math.exp(-Math.pow((t - 0.22) / 0.05, 2));
243
+ // Peak near frame 800 (second drone + speed warning)
244
+ d += 0.45 * Math.exp(-Math.pow((t - 0.43) / 0.06, 2));
245
+ // Peak near frame 1050 (crowd)
246
+ d += 0.30 * Math.exp(-Math.pow((t - 0.57) / 0.04, 2));
247
+ // Peak near frame 1300-1550 (drone maneuver + perimeter breach)
248
+ d += 0.50 * Math.exp(-Math.pow((t - 0.75) / 0.08, 2));
249
+ // Gentle ramp at end
250
+ d += 0.10 * Math.exp(-Math.pow((t - 0.95) / 0.06, 2));
251
+
252
+ MOCK_DENSITY.push(Math.min(1.0, d));
253
+
254
+ // Dominant type per segment
255
+ if (t > 0.20 && t < 0.28) DENSITY_TYPES.push('drone');
256
+ else if (t > 0.40 && t < 0.48) DENSITY_TYPES.push('drone');
257
+ else if (t > 0.70 && t < 0.82) DENSITY_TYPES.push('drone');
258
+ else if (t > 0.30 && t < 0.38) DENSITY_TYPES.push('vehicle');
259
+ else if (t > 0.55 && t < 0.62) DENSITY_TYPES.push('person');
260
+ else DENSITY_TYPES.push(Math.random() > 0.6 ? 'vehicle' : 'person');
261
+ }
262
+ })();
263
+
264
+ // ── Mock Placeholder Data for Depth / Mask / PointCloud ─────────
265
+
266
+ const MOCK_MASKS = {};
267
+ MOCK_TRACKS.forEach(t => {
268
+ // Each mask is a placeholder canvas-like descriptor
269
+ MOCK_MASKS[t.id] = {
270
+ trackId: t.id,
271
+ type: 'binary-mask',
272
+ width: 640,
273
+ height: 480,
274
+ data: null, // would be Uint8Array in real impl
275
+ };
276
+ });
277
+
278
+ const MOCK_DEPTH = {
279
+ type: 'depth-map',
280
+ width: 640,
281
+ height: 480,
282
+ min: 0.5,
283
+ max: 150.0,
284
+ data: null, // would be Float32Array in real impl
285
+ };
286
+
287
+ const MOCK_POINTCLOUD = {
288
+ type: 'point-cloud',
289
+ numPoints: 12000,
290
+ bounds: { minX: -10, maxX: 10, minY: -5, maxY: 5, minZ: 0, maxZ: 50 },
291
+ positions: null, // would be Float32Array(numPoints * 3) in real impl
292
+ colors: null, // would be Float32Array(numPoints * 3) in real impl
293
+ };
294
+
295
+ // ── Mock AI Responses ───────────────────────────────────────────
296
+
297
+ const MOCK_AI_RESPONSES_FALLBACK = {
298
+ 'threat assessment': {
299
+ 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.',
300
+ action: null,
301
+ },
302
+ 'show all drones': {
303
+ 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.',
304
+ action: 'filter-drones',
305
+ },
306
+ 'generate report': {
307
+ text: 'Generating mission report... Report includes 20 tracked objects across 1847 frames (61.6 seconds). 5 high-priority alerts logged. Report ready for download.',
308
+ action: 'show-report',
309
+ },
310
+ 'summarize mission': {
311
+ 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.',
312
+ action: null,
313
+ },
314
+ 'count vehicles': {
315
+ 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.',
316
+ action: null,
317
+ },
318
+ };
319
+
320
+ // ── Boot Log ────────────────────────────────────────────────────
321
+
322
+ console.log('[ISR Command Center] Mock data layer initialized.');
323
+ console.log(` Tracks: ${MOCK_TRACKS.length}`);
324
+ console.log(` Events: ${MOCK_EVENTS.length}`);
325
+ console.log(` Density segments: ${MOCK_DENSITY.length}`);
326
+ console.log(` AI commands: ${Object.keys(MOCK_AI_RESPONSES_FALLBACK).length}`);
327
+
328
+ // ── Export to namespace ─────────────────────────────────────────
329
+
330
+ window.ISR.STATE = STATE;
331
+ window.ISR.TYPE_COLORS = TYPE_COLORS;
332
+ window.ISR.getColorForType = getColorForType;
333
+ window.ISR.createTrack = createTrack;
334
+ window.ISR.MOCK_TRACKS = MOCK_TRACKS;
335
+ window.ISR.MOCK_EVENTS = MOCK_EVENTS;
336
+ window.ISR.MOCK_DENSITY = MOCK_DENSITY;
337
+ window.ISR.DENSITY_TYPES = DENSITY_TYPES;
338
+ window.ISR.MOCK_MASKS = MOCK_MASKS;
339
+ window.ISR.MOCK_DEPTH = MOCK_DEPTH;
340
+ window.ISR.MOCK_POINTCLOUD = MOCK_POINTCLOUD;
341
+ window.ISR.MOCK_AI_RESPONSES_FALLBACK = MOCK_AI_RESPONSES_FALLBACK;
demo/js/timeline.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 6: Timeline β€” Waveform, Events, Playhead
5
+ * ================================================================ */
6
+
7
+ function renderWaveform(canvas, densityData) {
8
+ const rect = canvas.parentElement.getBoundingClientRect();
9
+ const dpr = window.devicePixelRatio || 1;
10
+ canvas.width = rect.width * dpr;
11
+ canvas.height = rect.height * dpr;
12
+ canvas.style.width = rect.width + 'px';
13
+ canvas.style.height = rect.height + 'px';
14
+
15
+ const ctx = canvas.getContext('2d');
16
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
17
+
18
+ const w = rect.width;
19
+ const h = rect.height;
20
+ const barW = w / densityData.length;
21
+
22
+ ctx.clearRect(0, 0, w, h);
23
+
24
+ for (let i = 0; i < densityData.length; i++) {
25
+ const val = densityData[i];
26
+ const barH = val * h * 0.85;
27
+ const x = i * barW;
28
+ const y = h - barH;
29
+ const color = ISR.getColorForType(ISR.DENSITY_TYPES[i]);
30
+
31
+ ctx.fillStyle = color + '88';
32
+ ctx.fillRect(x, y, Math.max(barW - 1, 1), barH);
33
+ }
34
+ }
35
+
36
+ function renderEventMarkers(container, events) {
37
+ container.innerHTML = '';
38
+ const EVENT_TYPE_COLORS = {
39
+ system: 'var(--text-tertiary)',
40
+ detection: 'var(--accent)',
41
+ tracking: 'var(--success)',
42
+ alert: 'var(--danger)',
43
+ };
44
+
45
+ for (const evt of events) {
46
+ const pct = (evt.frame / ISR.STATE.totalFrames) * 100;
47
+ const marker = document.createElement('div');
48
+ marker.className = 'event-marker';
49
+ marker.style.left = pct + '%';
50
+ marker.style.background = EVENT_TYPE_COLORS[evt.type] || 'var(--text-tertiary)';
51
+ if (evt.priority === 'high') {
52
+ marker.style.boxShadow = `0 0 4px ${EVENT_TYPE_COLORS[evt.type]}`;
53
+ }
54
+ marker.title = `${evt.time} β€” ${evt.label}`;
55
+ marker.addEventListener('click', () => {
56
+ ISR.STATE.playheadFrame = evt.frame;
57
+ updatePlayheadPosition();
58
+ ISR.updateFrameCounter();
59
+ ISR.updateTimeDisplay();
60
+ });
61
+ container.appendChild(marker);
62
+ }
63
+ }
64
+
65
+ function updatePlayheadPosition() {
66
+ const playhead = document.getElementById('playhead');
67
+ if (!playhead) return;
68
+ const pct = (ISR.STATE.playheadFrame / ISR.STATE.totalFrames) * 100;
69
+ playhead.style.left = pct + '%';
70
+ }
71
+
72
+ function initPlayheadDrag() {
73
+ const wrap = document.querySelector('.timeline-waveform-wrap');
74
+ if (!wrap) return;
75
+ let dragging = false;
76
+
77
+ function setFrameFromX(clientX) {
78
+ const rect = wrap.getBoundingClientRect();
79
+ let pct = (clientX - rect.left) / rect.width;
80
+ pct = Math.max(0, Math.min(1, pct));
81
+ ISR.STATE.playheadFrame = Math.round(pct * ISR.STATE.totalFrames);
82
+ updatePlayheadPosition();
83
+ ISR.updateFrameCounter();
84
+ ISR.updateTimeDisplay();
85
+
86
+ // Seek video when jobId exists
87
+ if (ISR.STATE.jobId) {
88
+ const processedVideo = document.getElementById('processedVideo');
89
+ if (processedVideo) {
90
+ processedVideo.currentTime = ISR.STATE.playheadFrame / ISR.STATE.fps;
91
+ }
92
+ }
93
+ }
94
+
95
+ wrap.addEventListener('mousedown', (e) => {
96
+ dragging = true;
97
+ setFrameFromX(e.clientX);
98
+ });
99
+
100
+ document.addEventListener('mousemove', (e) => {
101
+ if (dragging) setFrameFromX(e.clientX);
102
+ });
103
+
104
+ document.addEventListener('mouseup', () => {
105
+ dragging = false;
106
+ });
107
+ }
108
+
109
+ function renderTimelineLegend() {
110
+ const legend = document.getElementById('timelineLegend');
111
+ if (!legend) return;
112
+ const counts = { system: 0, detection: 0, tracking: 0, alert: 0 };
113
+ const colors = {
114
+ system: 'var(--text-tertiary)',
115
+ detection: 'var(--accent)',
116
+ tracking: 'var(--success)',
117
+ alert: 'var(--danger)',
118
+ };
119
+ ISR.MOCK_EVENTS.forEach(e => { if (counts[e.type] !== undefined) counts[e.type]++; });
120
+
121
+ legend.innerHTML = Object.entries(counts).map(([type, count]) =>
122
+ `<span class="timeline-legend-item"><span class="dot" style="background: ${colors[type]};"></span> ${type} (${count})</span>`
123
+ ).join('');
124
+ }
125
+
126
+ function toggleEventLog() {
127
+ ISR.STATE.eventLogOpen = !ISR.STATE.eventLogOpen;
128
+ const log = document.getElementById('eventLog');
129
+ const toggle = document.getElementById('eventLogToggle');
130
+ if (ISR.STATE.eventLogOpen) {
131
+ log.classList.remove('hidden');
132
+ toggle.innerHTML = 'EVENT LOG &#9650;';
133
+ // Use real events if available, otherwise mock
134
+ if (ISR.STATE.jobId && ISR.STATE._derivedEvents) {
135
+ ISR.renderEventLogFromData(ISR.STATE._derivedEvents);
136
+ } else {
137
+ renderEventLog();
138
+ }
139
+ } else {
140
+ log.classList.add('hidden');
141
+ toggle.innerHTML = 'EVENT LOG &#9660;';
142
+ }
143
+ }
144
+
145
+ function renderEventLog() {
146
+ const log = document.getElementById('eventLog');
147
+ if (!log) return;
148
+
149
+ const EVENT_TYPE_COLORS = {
150
+ system: { bg: 'rgba(255,255,255,0.05)', color: 'var(--text-tertiary)' },
151
+ detection: { bg: 'rgba(59,130,246,0.12)', color: 'var(--accent-light)' },
152
+ tracking: { bg: 'rgba(34,197,94,0.12)', color: 'var(--success)' },
153
+ alert: { bg: 'rgba(239,68,68,0.12)', color: 'var(--danger)' },
154
+ };
155
+
156
+ log.innerHTML = ISR.MOCK_EVENTS.map(evt => {
157
+ const tc = EVENT_TYPE_COLORS[evt.type] || EVENT_TYPE_COLORS.system;
158
+ return `
159
+ <div class="event-log-entry" data-frame="${evt.frame}">
160
+ <span class="event-log-time">${evt.time}</span>
161
+ <span class="event-log-type" style="background: ${tc.bg}; color: ${tc.color};">${evt.type}</span>
162
+ <span class="event-log-label">${evt.label}</span>
163
+ <span class="event-log-desc">${evt.description}</span>
164
+ </div>`;
165
+ }).join('');
166
+
167
+ log.querySelectorAll('.event-log-entry').forEach(entry => {
168
+ entry.addEventListener('click', () => {
169
+ ISR.STATE.playheadFrame = parseInt(entry.dataset.frame, 10);
170
+ updatePlayheadPosition();
171
+ ISR.updateFrameCounter();
172
+ ISR.updateTimeDisplay();
173
+ });
174
+ });
175
+ }
176
+
177
+ /* ================================================================
178
+ * TASK 12: Event Marker Tooltips
179
+ * ================================================================ */
180
+
181
+ function wireEventMarkerTooltips() {
182
+ document.querySelectorAll('.event-marker').forEach((marker, idx) => {
183
+ const evt = ISR.MOCK_EVENTS[idx];
184
+ if (!evt) return;
185
+
186
+ marker.addEventListener('mouseenter', () => {
187
+ // Remove old tooltips
188
+ document.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove());
189
+
190
+ const tooltip = document.createElement('div');
191
+ tooltip.className = 'event-marker-tooltip';
192
+ tooltip.textContent = `${evt.time} β€” ${evt.label}: ${evt.description}`;
193
+ marker.style.position = 'absolute'; // ensure positioning context
194
+ marker.appendChild(tooltip);
195
+ });
196
+
197
+ marker.addEventListener('mouseleave', () => {
198
+ marker.querySelectorAll('.event-marker-tooltip').forEach(t => t.remove());
199
+ });
200
+ });
201
+ }
202
+
203
+ /* ================================================================
204
+ * TASK 12: Keyboard Shortcuts Hint
205
+ * ================================================================ */
206
+
207
+ function showShortcutsHint() {
208
+ // Remove existing hint if any
209
+ const existing = document.getElementById('shortcutsHint');
210
+ if (existing) existing.remove();
211
+
212
+ const commandBar = document.getElementById('commandBar');
213
+ const hint = document.createElement('div');
214
+ hint.id = 'shortcutsHint';
215
+ hint.innerHTML = `
216
+ <span class="shortcut-item"><kbd>Space</kbd> Play/Pause</span>
217
+ <span class="shortcut-item"><kbd>&larr;</kbd><kbd>&rarr;</kbd> Seek 5s</span>
218
+ <span class="shortcut-item"><kbd>,</kbd><kbd>.</kbd> Frame step</span>
219
+ <span class="shortcut-item"><kbd>Esc</kbd> Back/Close</span>
220
+ `;
221
+ commandBar.style.position = 'relative';
222
+ commandBar.appendChild(hint);
223
+
224
+ // Remove after animation completes (3s)
225
+ setTimeout(() => {
226
+ if (hint.parentElement) hint.remove();
227
+ }, 3100);
228
+ }
229
+
230
+ // ── Export to namespace ─────────────────────────────────────────
231
+
232
+ Object.assign(window.ISR, {
233
+ renderWaveform,
234
+ renderEventMarkers,
235
+ updatePlayheadPosition,
236
+ initPlayheadDrag,
237
+ renderTimelineLegend,
238
+ toggleEventLog,
239
+ renderEventLog,
240
+ wireEventMarkerTooltips,
241
+ showShortcutsHint,
242
+ });
demo/js/ui.js ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.ISR = window.ISR || {};
2
+
3
+ /* ================================================================
4
+ * TASK 8/9: Play/Pause + Keyboard Controls
5
+ * ================================================================ */
6
+
7
+ function togglePlayPause() {
8
+ if (ISR.STATE.current === 'ready') return;
9
+ if (ISR.STATE.current === 'processing') return;
10
+ ISR.STATE.isPlaying = !ISR.STATE.isPlaying;
11
+ ISR.lastFrameTime = 0;
12
+ const btn = document.getElementById('playPauseBtn');
13
+ btn.innerHTML = ISR.STATE.isPlaying ? '&#9646;&#9646;' : '&#9654;';
14
+
15
+ // When jobId exists, play/pause the <video> element
16
+ if (ISR.STATE.jobId) {
17
+ const processedVideo = document.getElementById('processedVideo');
18
+ if (processedVideo) {
19
+ if (ISR.STATE.isPlaying) {
20
+ // Check video is ready
21
+ if (processedVideo.readyState < 2) {
22
+ console.warn('[ISR] Video not ready (readyState:', processedVideo.readyState, ')');
23
+ ISR.showToast('Video still loading, please wait...', 3000);
24
+ ISR.STATE.isPlaying = false;
25
+ btn.innerHTML = '&#9654;';
26
+ return;
27
+ }
28
+ processedVideo.playbackRate = ISR.playbackSpeed;
29
+ if (processedVideo.currentTime >= processedVideo.duration - 0.1) {
30
+ processedVideo.currentTime = 0;
31
+ }
32
+ processedVideo.play().catch(err => {
33
+ console.error('[ISR] Video play failed:', err);
34
+ ISR.showToast('Video playback failed: ' + err.message, 5000);
35
+ ISR.STATE.isPlaying = false;
36
+ btn.innerHTML = '&#9654;';
37
+ });
38
+ } else {
39
+ processedVideo.pause();
40
+ }
41
+ } else {
42
+ console.error('[ISR] processedVideo element not found');
43
+ ISR.STATE.isPlaying = false;
44
+ btn.innerHTML = '&#9654;';
45
+ }
46
+ } else {
47
+ if (ISR.STATE.isPlaying && ISR.STATE.playheadFrame >= ISR.STATE.totalFrames) {
48
+ ISR.STATE.playheadFrame = 0;
49
+ }
50
+ }
51
+
52
+ // Ensure we're in a playable state
53
+ if (ISR.STATE.isPlaying && ISR.STATE.current === 'analysis') {
54
+ ISR.STATE.current = 'playing';
55
+ document.body.setAttribute('data-state', 'analysis'); // keep analysis CSS
56
+ }
57
+ if (!ISR.STATE.isPlaying && ISR.STATE.current === 'playing') {
58
+ ISR.STATE.current = 'analysis';
59
+ document.body.setAttribute('data-state', 'analysis');
60
+ }
61
+ }
62
+
63
+ function updateTimeDisplay() {
64
+ const totalSeconds = ISR.STATE.playheadFrame / ISR.STATE.fps;
65
+ const min = Math.floor(totalSeconds / 60);
66
+ const sec = Math.floor(totalSeconds % 60);
67
+ const el = document.getElementById('timeStart');
68
+ if (el) el.textContent = String(min).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
69
+ }
70
+
71
+ function seekFrames(delta) {
72
+ if (ISR.STATE.current === 'ready' || ISR.STATE.current === 'processing') return;
73
+ ISR.STATE.playheadFrame = Math.max(0, Math.min(ISR.STATE.totalFrames, ISR.STATE.playheadFrame + delta));
74
+ ISR.updatePlayheadPosition();
75
+ ISR.updateFrameCounter();
76
+ updateTimeDisplay();
77
+
78
+ // Seek video when jobId exists
79
+ if (ISR.STATE.jobId) {
80
+ const processedVideo = document.getElementById('processedVideo');
81
+ if (processedVideo) {
82
+ processedVideo.currentTime = ISR.STATE.playheadFrame / ISR.STATE.fps;
83
+ }
84
+ }
85
+ }
86
+
87
+ function handleKeyboardControls(e) {
88
+ // Don't capture when typing in command bar or other inputs
89
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
90
+
91
+ // Escape priority chain
92
+ if (e.code === 'Escape') {
93
+ e.preventDefault();
94
+ // 1. Expanded quadrant -> collapse
95
+ if (ISR.STATE.expandedQuadrant) {
96
+ const panel = document.getElementById('inspectPanel');
97
+ if (panel) {
98
+ panel.querySelectorAll('.quadrant.expanded').forEach(eq => eq.classList.remove('expanded'));
99
+ panel.querySelectorAll('.quadrant').forEach(oq => oq.style.display = '');
100
+ }
101
+ ISR.STATE.expandedQuadrant = null;
102
+ return;
103
+ }
104
+ // 2. Event log open -> close
105
+ if (ISR.STATE.eventLogOpen) {
106
+ ISR.toggleEventLog();
107
+ return;
108
+ }
109
+ // 3. In inspect state -> return to analysis
110
+ if (ISR.STATE.current === 'inspect') {
111
+ ISR.exitInspectState();
112
+ return;
113
+ }
114
+ return;
115
+ }
116
+
117
+ if (e.code === 'Space') {
118
+ e.preventDefault();
119
+ togglePlayPause();
120
+ } else if (e.code === 'ArrowRight') {
121
+ e.preventDefault();
122
+ seekFrames(ISR.STATE.fps * 5); // 5 seconds forward
123
+ } else if (e.code === 'ArrowLeft') {
124
+ e.preventDefault();
125
+ seekFrames(-ISR.STATE.fps * 5); // 5 seconds backward
126
+ } else if (e.code === 'Period') {
127
+ e.preventDefault();
128
+ seekFrames(1); // next frame
129
+ } else if (e.code === 'Comma') {
130
+ e.preventDefault();
131
+ seekFrames(-1); // previous frame
132
+ }
133
+ }
134
+
135
+ /* ================================================================
136
+ * TASK 9: Canvas Hit-Testing + Hover Sync
137
+ * ================================================================ */
138
+
139
+ function hitTestBoxes(clientX, clientY) {
140
+ const container = document.getElementById('videoFeed');
141
+ const rect = container.getBoundingClientRect();
142
+ const relX = clientX - rect.left;
143
+ const relY = clientY - rect.top;
144
+ const w = rect.width;
145
+ const h = rect.height;
146
+
147
+ const tracks = ISR.getTracksAtFrame(ISR.STATE.playheadFrame);
148
+
149
+ // Check in reverse order (top-most drawn last)
150
+ for (let i = tracks.length - 1; i >= 0; i--) {
151
+ const t = tracks[i];
152
+ const bx = (t.bbox.x / 100) * w;
153
+ const by = (t.bbox.y / 100) * h;
154
+ const bw = (t.bbox.w / 100) * w;
155
+ const bh = (t.bbox.h / 100) * h;
156
+
157
+ if (relX >= bx && relX <= bx + bw && relY >= by && relY <= by + bh) {
158
+ return t;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function handleCanvasMouseMove(e) {
165
+ if (ISR.STATE.current === 'ready' || ISR.STATE.current === 'processing') return;
166
+
167
+ const hit = hitTestBoxes(e.clientX, e.clientY);
168
+ const hitId = hit ? hit.id : null;
169
+
170
+ // Skip DOM work if highlight hasn't changed
171
+ if (hitId === ISR._prevHighlightHit) return;
172
+ ISR._prevHighlightHit = hitId;
173
+
174
+ const overlay = document.getElementById('overlayCanvas');
175
+ overlay.style.cursor = hit ? 'pointer' : 'default';
176
+ ISR._updateTrackHighlight(hitId);
177
+ }
178
+
179
+ function handleCanvasClick(e) {
180
+ if (ISR.STATE.current === 'ready' || ISR.STATE.current === 'processing') return;
181
+
182
+ const hit = hitTestBoxes(e.clientX, e.clientY);
183
+ if (hit) {
184
+ ISR.enterInspectState(hit.id);
185
+ }
186
+ }
187
+
188
+ /* ================================================================
189
+ * TASK 5: Drawer β€” Tabs + Track List
190
+ * ================================================================ */
191
+
192
+ function switchDrawerTab(tabName) {
193
+ ISR.STATE.drawerTab = tabName;
194
+ const tabs = document.querySelectorAll('.drawer-tab');
195
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
196
+
197
+ // Hide all panels first
198
+ document.getElementById('configPanel').classList.add('hidden');
199
+ document.getElementById('tracksPanel').classList.add('hidden');
200
+ document.getElementById('inspectPanel').classList.add('hidden');
201
+ document.getElementById('explainPanel').classList.add('hidden');
202
+
203
+ if (tabName === 'tracks' || tabName === 'config') {
204
+ // Config panel only shows in ready state
205
+ if (ISR.STATE.current === 'ready') {
206
+ document.getElementById('configPanel').classList.remove('hidden');
207
+ }
208
+ document.getElementById('tracksPanel').classList.remove('hidden');
209
+ } else if (tabName === 'inspect') {
210
+ document.getElementById('inspectPanel').classList.remove('hidden');
211
+ } else if (tabName === 'explain') {
212
+ document.getElementById('explainPanel').classList.remove('hidden');
213
+ }
214
+ }
215
+
216
+ /* ================================================================
217
+ * TASK 7: Command Bar + Toast System
218
+ * ================================================================ */
219
+
220
+ function handleCommand(text) {
221
+ if (!text.trim()) return;
222
+
223
+ // Check for client-side action commands first
224
+ const localMatch = ISR.matchAICommand(text);
225
+ if (localMatch && localMatch.action) {
226
+ showToast(localMatch.text, 6000);
227
+ ISR.executeAction(localMatch.action);
228
+ return;
229
+ }
230
+
231
+ // Get selected track context from real tracks if available
232
+ let selectedTrack = null;
233
+ if (ISR.STATE.selectedTrackId && ISR.STATE._realTracks) {
234
+ selectedTrack = ISR.STATE._realTracks.find(t =>
235
+ t.track_id === ISR.STATE.selectedTrackId || t.track_id === String(ISR.STATE.selectedTrackId)
236
+ );
237
+ }
238
+
239
+ // Call askAI β€” handles fallback internally
240
+ ISR.askAI(text, selectedTrack).then(response => {
241
+ showToast(response.response || response.text || 'No response.');
242
+ if (response.action) {
243
+ ISR.executeAction(response.action);
244
+ }
245
+ });
246
+ }
247
+
248
+ function showToast(text, duration) {
249
+ if (duration === undefined) duration = 8000;
250
+ const container = document.getElementById('toastContainer');
251
+ const toast = document.createElement('div');
252
+ toast.className = 'toast';
253
+
254
+ const msg = document.createElement('span');
255
+ msg.style.flex = '1';
256
+ msg.textContent = text;
257
+
258
+ const close = document.createElement('button');
259
+ close.className = 'toast-close';
260
+ close.innerHTML = '&times;';
261
+ close.addEventListener('click', () => dismissToast(toast));
262
+
263
+ toast.appendChild(msg);
264
+ toast.appendChild(close);
265
+ container.appendChild(toast);
266
+
267
+ setTimeout(() => dismissToast(toast), duration);
268
+ }
269
+
270
+ function dismissToast(toast) {
271
+ if (!toast || !toast.parentElement) return;
272
+ toast.classList.add('toast-out');
273
+ toast.addEventListener('animationend', () => {
274
+ if (toast.parentElement) toast.parentElement.removeChild(toast);
275
+ });
276
+ }
277
+
278
+ // ── Export to namespace ─────────────────────────────────────────
279
+
280
+ Object.assign(window.ISR, {
281
+ togglePlayPause,
282
+ updateTimeDisplay,
283
+ seekFrames,
284
+ handleKeyboardControls,
285
+ hitTestBoxes,
286
+ handleCanvasMouseMove,
287
+ handleCanvasClick,
288
+ switchDrawerTab,
289
+ handleCommand,
290
+ showToast,
291
+ dismissToast,
292
+ });
demo/styles/animations.css ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Keyframes ─────────────────────────────────────────────────── */
2
+
3
+ @keyframes pulse-dot {
4
+ 0%, 100% { box-shadow: 0 0 4px currentColor, 0 0 8px currentColor; opacity: 1; }
5
+ 50% { box-shadow: 0 0 8px currentColor, 0 0 18px currentColor; opacity: 0.7; }
6
+ }
7
+
8
+ @keyframes processing-pulse {
9
+ 0%, 100% { box-shadow: 0 0 4px currentColor, 0 0 8px currentColor; opacity: 1; }
10
+ 50% { box-shadow: 0 0 12px currentColor, 0 0 24px currentColor; opacity: 0.5; }
11
+ }
12
+
13
+ @keyframes drawer-fade-in {
14
+ from { opacity: 0; transform: translateY(10px); }
15
+ to { opacity: 1; transform: translateY(0); }
16
+ }
17
+
18
+ @keyframes track-card-enter {
19
+ from { opacity: 0; transform: translateY(8px); }
20
+ to { opacity: 1; transform: translateY(0); }
21
+ }
22
+
23
+ @keyframes toast-in {
24
+ from { opacity: 0; transform: translateY(20px); }
25
+ to { opacity: 1; transform: translateY(0); }
26
+ }
27
+
28
+ @keyframes toast-out {
29
+ from { opacity: 1; transform: translateY(0); }
30
+ to { opacity: 0; transform: translateY(12px); }
31
+ }
32
+
33
+ @keyframes box-dash-march {
34
+ to { stroke-dashoffset: -20; }
35
+ }
36
+
37
+ @keyframes box-pulse-glow {
38
+ 0%, 100% { opacity: 0.6; }
39
+ 50% { opacity: 1; }
40
+ }
41
+
42
+ @keyframes fps-flicker {
43
+ 0% { color: var(--text-secondary); }
44
+ 50% { color: var(--accent-light); }
45
+ 100% { color: var(--text-secondary); }
46
+ }
47
+
48
+ @keyframes indeterminate-pulse {
49
+ 0%, 100% { stroke-dashoffset: 213.628; opacity: 0.4; }
50
+ 50% { stroke-dashoffset: 53.407; opacity: 1; }
51
+ }
52
+
53
+ @keyframes shortcuts-fade {
54
+ 0% { opacity: 0; transform: translateX(-50%) translateY(4px); }
55
+ 10% { opacity: 1; transform: translateX(-50%) translateY(0); }
56
+ 80% { opacity: 1; transform: translateX(-50%) translateY(0); }
57
+ 100% { opacity: 0; transform: translateX(-50%) translateY(-4px); }
58
+ }
59
+
60
+ @keyframes exNodeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
61
+
62
+ /* ── Toast ────────────────────────────────────────────────────── */
63
+
64
+ #toastContainer {
65
+ position: fixed;
66
+ bottom: 80px;
67
+ right: 20px;
68
+ z-index: 100;
69
+ display: flex;
70
+ flex-direction: column-reverse;
71
+ gap: 8px;
72
+ pointer-events: none;
73
+ }
74
+
75
+ .toast {
76
+ background: rgba(2,6,23,0.92);
77
+ backdrop-filter: blur(12px);
78
+ -webkit-backdrop-filter: blur(12px);
79
+ border: 1px solid var(--panel-border);
80
+ border-radius: 8px;
81
+ padding: 10px 14px;
82
+ max-width: 400px;
83
+ font-size: 11px;
84
+ color: var(--text-primary);
85
+ line-height: 1.5;
86
+ pointer-events: auto;
87
+ display: flex;
88
+ align-items: flex-start;
89
+ gap: 8px;
90
+ animation: toast-in 0.3s ease forwards;
91
+ }
92
+
93
+ .toast-close {
94
+ background: none;
95
+ border: none;
96
+ color: var(--text-tertiary);
97
+ font-size: 14px;
98
+ cursor: pointer;
99
+ padding: 0;
100
+ line-height: 1;
101
+ flex-shrink: 0;
102
+ }
103
+
104
+ .toast-close:hover {
105
+ color: var(--text-primary);
106
+ }
107
+
108
+ .toast.toast-out {
109
+ animation: toast-out 0.25s ease forwards;
110
+ }
111
+
112
+ /* ── Polish: Drawer Content Transitions ───────────────────────── */
113
+
114
+ .drawer-section {
115
+ animation: drawer-fade-in 0.3s ease both;
116
+ }
117
+
118
+ /* ── Polish: Progress Overlay Fade ────────────────────────────── */
119
+
120
+ #progressOverlay {
121
+ transition: opacity 0.5s ease;
122
+ }
123
+
124
+ /* ── Polish: Start Button Fade Out ────────────────────────────── */
125
+
126
+ #startBtn.fading-out {
127
+ opacity: 0;
128
+ transform: translate(-50%, -50%) scale(0.95);
129
+ transition: opacity 0.3s ease, transform 0.3s ease;
130
+ pointer-events: none;
131
+ }
132
+
133
+ /* ── Polish: Track Card Hover ─────────────────────────────────── */
134
+
135
+ .track-card {
136
+ transition: background 0.15s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
137
+ }
138
+
139
+ .track-card:hover {
140
+ background: rgba(255,255,255,0.05);
141
+ border-color: rgba(255,255,255,0.08);
142
+ transform: scale(1.01);
143
+ }
144
+
145
+ .track-card.highlighted {
146
+ border-color: var(--accent-light) !important;
147
+ background: rgba(59,130,246,0.12) !important;
148
+ box-shadow: 0 0 8px rgba(59,130,246,0.15);
149
+ transform: scale(1.01);
150
+ }
151
+
152
+ /* Type-colored glow on hover */
153
+ .track-card:hover .track-dot {
154
+ transition: box-shadow 0.2s ease;
155
+ }
156
+
157
+ /* ── Polish: Quadrant Hover ─────────��─────────────────────────── */
158
+
159
+ .quadrant {
160
+ transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
161
+ }
162
+
163
+ .quadrant:hover {
164
+ border-color: rgba(255,255,255,0.15);
165
+ transform: scale(1.02);
166
+ box-shadow: 0 0 12px rgba(59,130,246,0.1);
167
+ }
168
+
169
+ .quadrant.expanded:hover {
170
+ transform: none;
171
+ }
172
+
173
+ /* ── Polish: Event Marker Hover Tooltip ────────────────────────── */
174
+
175
+ .event-marker {
176
+ transition: opacity 0.15s, transform 0.15s;
177
+ z-index: 2;
178
+ }
179
+
180
+ .event-marker:hover {
181
+ opacity: 1;
182
+ transform: translate(-50%, -50%) scale(1.5);
183
+ z-index: 3;
184
+ }
185
+
186
+ .event-marker-tooltip {
187
+ position: absolute;
188
+ bottom: calc(100% + 8px);
189
+ left: 50%;
190
+ transform: translateX(-50%);
191
+ background: rgba(2,6,23,0.95);
192
+ border: 1px solid var(--panel-border);
193
+ border-radius: 4px;
194
+ padding: 4px 8px;
195
+ font-size: 9px;
196
+ color: var(--text-primary);
197
+ white-space: nowrap;
198
+ pointer-events: none;
199
+ z-index: 20;
200
+ backdrop-filter: blur(8px);
201
+ -webkit-backdrop-filter: blur(8px);
202
+ }
203
+
204
+ /* ── Polish: Command Bar Focus ────────────────────────────────── */
205
+
206
+ #commandBar {
207
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
208
+ }
209
+
210
+ #commandBar:focus-within {
211
+ border-color: rgba(59,130,246,0.4);
212
+ box-shadow: inset 0 0 12px rgba(59,130,246,0.06);
213
+ }
214
+
215
+ /* ── Polish: Tab Button Underline Slide ────────────────────────── */
216
+
217
+ .drawer-tab {
218
+ position: relative;
219
+ border-bottom: 2px solid transparent;
220
+ transition: color 0.2s ease, border-color 0.3s ease;
221
+ }
222
+
223
+ .drawer-tab::after {
224
+ content: '';
225
+ position: absolute;
226
+ bottom: -2px;
227
+ left: 50%;
228
+ right: 50%;
229
+ height: 2px;
230
+ background: var(--accent);
231
+ transition: left 0.25s ease, right 0.25s ease;
232
+ }
233
+
234
+ .drawer-tab.active::after {
235
+ left: 0;
236
+ right: 0;
237
+ }
238
+
239
+ .drawer-tab.active {
240
+ border-bottom-color: transparent;
241
+ }
242
+
243
+ /* ── Polish: Button Lift on Hover ─────────────────────────────── */
244
+
245
+ .vc-btn:hover {
246
+ background: rgba(255,255,255,0.12);
247
+ transform: translateY(-1px);
248
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
249
+ }
250
+
251
+ .inspect-back-btn:hover {
252
+ transform: translateY(-1px);
253
+ }
254
+
255
+ .config-toggle-btn {
256
+ transition: all 0.15s ease;
257
+ }
258
+
259
+ .config-toggle-btn:hover {
260
+ transform: translateY(-1px);
261
+ }
262
+
263
+ /* ── Polish: Detection Box Animations ─────────────────────────── */
264
+
265
+ /* (keyframes defined above) */
266
+
267
+ /* ── Polish: Top Bar Processing Progress Bar ──────────────────── */
268
+
269
+ #topBar {
270
+ position: relative;
271
+ overflow: hidden;
272
+ }
273
+
274
+ #topBarProgress {
275
+ position: absolute;
276
+ bottom: 0;
277
+ left: 0;
278
+ height: 2px;
279
+ width: 0%;
280
+ background: linear-gradient(90deg, var(--accent), var(--accent-light));
281
+ transition: width 0.3s ease;
282
+ z-index: 10;
283
+ opacity: 0;
284
+ }
285
+
286
+ /* FPS flicker */
287
+ .fps-updating {
288
+ animation: fps-flicker 0.3s ease;
289
+ }
290
+
291
+ /* Indeterminate progress */
292
+ .progress-ring__fg.indeterminate {
293
+ animation: indeterminate-pulse 2s ease-in-out infinite;
294
+ }
295
+
296
+ /* ── Polish: Keyboard Shortcuts Hint ──────────────────────────── */
297
+
298
+ #shortcutsHint {
299
+ position: absolute;
300
+ bottom: calc(100% + 8px);
301
+ left: 50%;
302
+ transform: translateX(-50%);
303
+ background: rgba(2,6,23,0.95);
304
+ backdrop-filter: blur(20px);
305
+ -webkit-backdrop-filter: blur(20px);
306
+ border: 1px solid var(--panel-border);
307
+ border-radius: 8px;
308
+ padding: 10px 14px;
309
+ z-index: 50;
310
+ display: flex;
311
+ gap: 16px;
312
+ font-size: 9px;
313
+ color: var(--text-secondary);
314
+ white-space: nowrap;
315
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
316
+ opacity: 0;
317
+ animation: shortcuts-fade 3s ease forwards;
318
+ pointer-events: none;
319
+ }
320
+
321
+ #shortcutsHint .shortcut-item {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 4px;
325
+ }
326
+
327
+ #shortcutsHint kbd {
328
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
329
+ font-size: 8px;
330
+ background: rgba(255,255,255,0.06);
331
+ border: 1px solid var(--panel-border);
332
+ border-radius: 3px;
333
+ padding: 1px 4px;
334
+ color: var(--text-primary);
335
+ }
336
+
337
+ /* ── Polish: Glass Effect on Panels ───────────────────────────── */
338
+
339
+ #eventLog {
340
+ backdrop-filter: blur(20px);
341
+ -webkit-backdrop-filter: blur(20px);
342
+ box-shadow: 0 4px 24px rgba(0,0,0,0.3);
343
+ }
344
+
345
+ .toast {
346
+ backdrop-filter: blur(20px);
347
+ -webkit-backdrop-filter: blur(20px);
348
+ box-shadow: 0 4px 24px rgba(0,0,0,0.3);
349
+ }
350
+
351
+ #drawer {
352
+ box-shadow: 0 4px 24px rgba(0,0,0,0.15);
353
+ }
354
+
355
+ #timeline {
356
+ box-shadow: 0 2px 16px rgba(0,0,0,0.15);
357
+ }
358
+
359
+ /* ── Polish: z-index Stacking ─────────────────────────────────── */
360
+
361
+ #toastContainer { z-index: 100; }
362
+ #eventLog { z-index: 50; }
363
+ #progressOverlay { z-index: 5; }
364
+ #shortcutsHint { z-index: 60; }
365
+
366
+ /* ── Polish: Smooth Scroll ────────────────────────────────────── */
367
+
368
+ .drawer-section {
369
+ scroll-behavior: smooth;
370
+ }
371
+
372
+ #eventLog {
373
+ scroll-behavior: smooth;
374
+ }
demo/styles/components.css ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Panel Base ────────────────────────────────────────────────── */
2
+
3
+ .panel {
4
+ background: var(--panel-bg);
5
+ border: 1px solid var(--panel-border);
6
+ border-radius: 10px;
7
+ transition: border-color 0.2s ease, background 0.2s ease;
8
+ }
9
+
10
+ .panel:hover {
11
+ border-color: rgba(255, 255, 255, 0.1);
12
+ }
13
+
14
+ /* ── Typography ────────────────────────────────────────────────── */
15
+
16
+ .label {
17
+ font-size: 8px;
18
+ text-transform: uppercase;
19
+ letter-spacing: 2px;
20
+ color: var(--text-tertiary);
21
+ font-weight: 500;
22
+ }
23
+
24
+ .value {
25
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
26
+ font-size: 13px;
27
+ color: var(--text-primary);
28
+ }
29
+
30
+ .heading {
31
+ font-size: 13px;
32
+ font-weight: 600;
33
+ color: var(--text-primary);
34
+ letter-spacing: 0.3px;
35
+ }
36
+
37
+ /* ── Indicators ────────────────────────────────────────────────── */
38
+
39
+ .dot {
40
+ width: 6px;
41
+ height: 6px;
42
+ border-radius: 50%;
43
+ display: inline-block;
44
+ flex-shrink: 0;
45
+ }
46
+
47
+ .dot--lg {
48
+ width: 8px;
49
+ height: 8px;
50
+ }
51
+
52
+ .dot--glow {
53
+ box-shadow: 0 0 6px currentColor, 0 0 12px currentColor;
54
+ }
55
+
56
+ .pill {
57
+ font-size: 8px;
58
+ font-weight: 600;
59
+ text-transform: uppercase;
60
+ letter-spacing: 1px;
61
+ padding: 2px 6px;
62
+ border-radius: 4px;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ gap: 4px;
66
+ line-height: 1;
67
+ }
68
+
69
+ /* ── Utility ───────────────────────────────────────────────────── */
70
+
71
+ .hidden {
72
+ display: none !important;
73
+ }
74
+
75
+ /* ── Transitions ───────────────────────────────────────────────── */
76
+
77
+ a, button, input, select, textarea {
78
+ transition: all 0.2s ease;
79
+ }
80
+
81
+ /* ── Buttons ───────────────────────────────────────────────────── */
82
+
83
+ .vc-btn {
84
+ background: rgba(255,255,255,0.06);
85
+ border: 1px solid var(--panel-border);
86
+ color: var(--text-primary);
87
+ width: 28px;
88
+ height: 28px;
89
+ border-radius: 6px;
90
+ cursor: pointer;
91
+ font-size: 12px;
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ }
96
+
97
+ .vc-select {
98
+ background: rgba(255,255,255,0.06);
99
+ border: 1px solid var(--panel-border);
100
+ color: var(--text-primary);
101
+ font-size: 10px;
102
+ padding: 4px 6px;
103
+ border-radius: 6px;
104
+ cursor: pointer;
105
+ font-family: inherit;
106
+ }
107
+
108
+ .vc-select option {
109
+ background: #0a0f1a;
110
+ }
111
+
112
+ .config-toggle-btn {
113
+ flex: 1;
114
+ background: rgba(255,255,255,0.03);
115
+ border: 1px solid var(--panel-border);
116
+ color: var(--text-secondary);
117
+ font-family: inherit;
118
+ font-size: 9px;
119
+ font-weight: 600;
120
+ letter-spacing: 1px;
121
+ padding: 6px 0;
122
+ border-radius: 6px;
123
+ cursor: pointer;
124
+ }
125
+
126
+ .config-toggle-btn.active {
127
+ background: rgba(59,130,246,0.12);
128
+ border-color: var(--accent);
129
+ color: var(--accent-light);
130
+ }
131
+
132
+ .config-toggle-btn:hover:not(.active) {
133
+ background: rgba(255,255,255,0.06);
134
+ }
135
+
136
+ .inspect-back-btn {
137
+ background: none;
138
+ border: 1px solid var(--panel-border);
139
+ color: var(--text-secondary);
140
+ font-family: inherit;
141
+ font-size: 9px;
142
+ font-weight: 600;
143
+ letter-spacing: 1px;
144
+ padding: 4px 8px;
145
+ border-radius: 4px;
146
+ cursor: pointer;
147
+ }
148
+
149
+ .inspect-back-btn:hover {
150
+ background: rgba(255,255,255,0.06);
151
+ color: var(--text-primary);
152
+ }
153
+
154
+ /* ── Command Bar Internals ────────────────────────────────────── */
155
+
156
+ .cmd-icon {
157
+ color: var(--text-tertiary);
158
+ font-size: 16px;
159
+ flex-shrink: 0;
160
+ }
161
+
162
+ .cmd-input {
163
+ flex: 1;
164
+ background: none;
165
+ border: none;
166
+ color: var(--text-primary);
167
+ font-family: inherit;
168
+ font-size: 12px;
169
+ outline: none;
170
+ }
171
+
172
+ .cmd-input::placeholder {
173
+ color: var(--text-tertiary);
174
+ }
175
+
176
+ .cmd-badge {
177
+ font-size: 9px;
178
+ font-weight: 600;
179
+ color: var(--text-tertiary);
180
+ background: rgba(255,255,255,0.04);
181
+ border: 1px solid var(--panel-border);
182
+ padding: 2px 6px;
183
+ border-radius: 4px;
184
+ flex-shrink: 0;
185
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
186
+ }
187
+
188
+ /* Config Panel */
189
+
190
+ #configPanel {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 12px;
194
+ }
195
+
196
+ .config-group {
197
+ display: flex;
198
+ flex-direction: column;
199
+ gap: 5px;
200
+ }
201
+
202
+ .config-toggle {
203
+ display: flex;
204
+ gap: 4px;
205
+ }
206
+
207
+ .config-select, .config-input {
208
+ background: rgba(255,255,255,0.03);
209
+ border: 1px solid var(--panel-border);
210
+ color: var(--text-primary);
211
+ font-family: inherit;
212
+ font-size: 11px;
213
+ padding: 7px 10px;
214
+ border-radius: 6px;
215
+ outline: none;
216
+ }
217
+
218
+ .config-select option {
219
+ background: #0a0f1a;
220
+ }
221
+
222
+ .config-select:focus, .config-input:focus {
223
+ border-color: var(--accent);
224
+ }
225
+
226
+ .config-row {
227
+ flex-direction: row;
228
+ align-items: center;
229
+ }
230
+
231
+ .config-checkbox-label {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 6px;
235
+ font-size: 11px;
236
+ color: var(--text-secondary);
237
+ cursor: pointer;
238
+ }
239
+
240
+ .config-checkbox-label input[type="checkbox"] {
241
+ accent-color: var(--accent);
242
+ }
243
+
244
+ /* ── File Upload ─────────────────────────────────────────────── */
245
+
246
+ .file-upload-label {
247
+ display: flex; align-items: center; gap: 8px;
248
+ padding: 8px 12px; border-radius: 6px; cursor: pointer;
249
+ background: rgba(255,255,255,0.03); border: 1px dashed var(--panel-border);
250
+ color: var(--text-secondary); font-size: 11px; transition: all 0.2s;
251
+ }
252
+ .file-upload-label:hover { border-color: var(--accent); color: var(--accent-light); }
253
+ .file-upload-label.has-file { border-style: solid; border-color: var(--success); color: var(--success); }
254
+
255
+ .config-toggle-group {
256
+ display: flex;
257
+ gap: 4px;
258
+ }
259
+
260
+ .config-select {
261
+ width: 100%; background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
262
+ border-radius: 6px; color: var(--text-primary); padding: 6px 10px; font-size: 11px;
263
+ font-family: inherit;
264
+ }
265
+ .config-select:focus { border-color: var(--accent); outline: none; }
266
+ .config-select option { background: #0a0f1a; color: var(--text-primary); }
267
+
268
+ .config-checkbox {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 6px;
272
+ font-size: 11px;
273
+ color: var(--text-secondary);
274
+ cursor: pointer;
275
+ }
276
+ .config-checkbox input[type="checkbox"] {
277
+ accent-color: var(--accent);
278
+ }
279
+
280
+ /* Track Cards */
281
+
282
+ .track-card {
283
+ display: flex;
284
+ align-items: center;
285
+ gap: 8px;
286
+ padding: 8px 10px;
287
+ border-radius: 6px;
288
+ background: rgba(255,255,255,0.02);
289
+ border: 1px solid rgba(255,255,255,0.04);
290
+ margin-bottom: 6px;
291
+ cursor: pointer;
292
+ }
293
+
294
+ .track-card.selected {
295
+ border-color: var(--accent);
296
+ background: rgba(59,130,246,0.06);
297
+ }
298
+
299
+ .track-dot {
300
+ width: 8px;
301
+ height: 8px;
302
+ border-radius: 50%;
303
+ flex-shrink: 0;
304
+ }
305
+
306
+ .track-info {
307
+ flex: 1;
308
+ min-width: 0;
309
+ }
310
+
311
+ .track-label {
312
+ font-size: 11px;
313
+ font-weight: 500;
314
+ color: var(--text-primary);
315
+ white-space: nowrap;
316
+ overflow: hidden;
317
+ text-overflow: ellipsis;
318
+ }
319
+
320
+ .track-meta {
321
+ display: flex;
322
+ gap: 8px;
323
+ font-size: 9px;
324
+ color: var(--text-tertiary);
325
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
326
+ margin-top: 2px;
327
+ }
328
+
329
+ .track-conf {
330
+ font-size: 10px;
331
+ font-weight: 600;
332
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
333
+ color: var(--text-secondary);
334
+ flex-shrink: 0;
335
+ }
336
+
337
+ /* ── Track Card: GPT Assessment Variants ────────────────────────── */
338
+
339
+ .track-card.not-relevant { opacity: 0.4; }
340
+ .track-card.active { border-color: var(--accent); background: rgba(59,130,246,0.08); }
341
+ .track-card .reason { color: var(--text-secondary); font-size: 10px; margin-top: 4px; line-height: 1.4; }
342
+ .track-card .features-table { display: none; }
343
+ .track-card.active .features-table {
344
+ display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px;
345
+ margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--panel-border);
346
+ }
347
+ .features-table .feat-key { color: var(--text-tertiary); font-size: 8px; text-transform: uppercase; letter-spacing: 1px; }
348
+ .features-table .feat-val { color: var(--text-secondary); font-size: 10px; font-family: monospace; }
349
+ .status-badge {
350
+ font-size: 8px; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px;
351
+ font-weight: 600; text-transform: uppercase;
352
+ }
353
+ .status-badge.match { background: rgba(52,211,153,0.15); color: #34d399; }
354
+ .status-badge.no-match { background: rgba(248,113,113,0.12); color: #f87171; }
355
+ .status-badge.relevant { background: rgba(59,130,246,0.12); color: #60a5fa; }
356
+ .status-badge.not-relevant { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.4); }
357
+ .status-badge.pending { background: rgba(251,191,36,0.1); color: #fbbf24; }
demo/styles/explain.css ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Explainability Graph ─────────────────────────────────────── */
2
+ #explainPanel {
3
+ overflow-y: auto;
4
+ padding: 0;
5
+ position: relative;
6
+ }
7
+
8
+ /* ── Loading / Error ─────────────────────────────────── */
9
+ .explain-loading {
10
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
11
+ gap: 14px; padding: 48px 16px; color: var(--text-secondary); font-size: 11px;
12
+ }
13
+ .explain-spinner {
14
+ width: 28px; height: 28px;
15
+ border: 2px solid transparent;
16
+ border-top-color: #a78bfa; border-right-color: #7c3aed;
17
+ border-radius: 50%;
18
+ animation: exSpin 0.7s linear infinite;
19
+ }
20
+ @keyframes exSpin { to { transform: rotate(360deg); } }
21
+ .explain-loading span { animation: exPulse 2.5s ease-in-out infinite; letter-spacing: 0.5px; }
22
+ @keyframes exPulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
23
+ .explain-error { padding: 24px 16px; color: var(--danger); font-size: 11px; text-align: center; }
24
+
25
+ /* ── Tree Container ──────────────────────────────────── */
26
+ .ex-tree {
27
+ padding: 16px 12px;
28
+ position: relative;
29
+ }
30
+
31
+ /* ── Root Node ────────────────────────────────────────── */
32
+ .ex-node-root {
33
+ position: relative;
34
+ display: flex; align-items: center; gap: 12px;
35
+ padding: 14px 16px;
36
+ background: linear-gradient(135deg, rgba(124,58,237,0.18), rgba(59,130,246,0.08));
37
+ border: 1px solid rgba(124,58,237,0.4);
38
+ border-radius: 10px;
39
+ animation: exNodeIn 0.5s ease-out;
40
+ overflow: hidden;
41
+ }
42
+ .ex-node-root::before {
43
+ content: ''; position: absolute; inset: 0;
44
+ background: radial-gradient(ellipse at 20% 30%, rgba(124,58,237,0.15) 0%, transparent 65%);
45
+ pointer-events: none;
46
+ }
47
+ .ex-conf-ring { width: 42px; height: 42px; flex-shrink: 0; }
48
+ .ex-conf-ring .ring-bg { fill: none; stroke: rgba(255,255,255,0.06); stroke-width: 3; }
49
+ .ex-conf-ring .ring-fill {
50
+ fill: none; stroke: #a78bfa; stroke-width: 3; stroke-linecap: round;
51
+ transform: rotate(-90deg); transform-origin: center;
52
+ animation: exRingDraw 1s ease-out forwards;
53
+ }
54
+ .ex-conf-ring .ring-text {
55
+ fill: #e9d5ff; font-size: 10px; font-weight: 700;
56
+ text-anchor: middle; dominant-baseline: central;
57
+ }
58
+ @keyframes exRingDraw { from { stroke-dashoffset: 113; } }
59
+ .ex-root-info { flex: 1; min-width: 0; }
60
+ .ex-root-label {
61
+ font-size: 13px; font-weight: 700; letter-spacing: 1.5px;
62
+ color: #e9d5ff; text-transform: uppercase;
63
+ }
64
+ .ex-root-summary {
65
+ font-size: 9.5px; color: var(--text-secondary); line-height: 1.5; margin-top: 3px;
66
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
67
+ }
68
+ .ex-root-badge {
69
+ display: inline-block; margin-top: 5px; padding: 2px 8px;
70
+ border-radius: 4px; font-size: 8px; font-weight: 700; letter-spacing: 0.8px;
71
+ }
72
+ .ex-root-badge.match { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
73
+ .ex-root-badge.no-match { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
74
+
75
+ /* ── Trunk Line (root β†’ categories) ──────────────────── */
76
+ .ex-trunk {
77
+ width: 2px; height: 16px;
78
+ margin: 0 auto;
79
+ background: linear-gradient(180deg, rgba(124,58,237,0.6), rgba(124,58,237,0.2));
80
+ }
81
+
82
+ /* ── Category Node ───────────────────────────────────── */
83
+ .ex-node-cat {
84
+ position: relative;
85
+ margin-top: 2px;
86
+ animation: exNodeIn 0.4s ease-out both;
87
+ }
88
+ .ex-cat-head {
89
+ display: flex; align-items: center; gap: 8px;
90
+ padding: 8px 12px;
91
+ border-radius: 8px;
92
+ cursor: pointer;
93
+ border: 1px solid rgba(255,255,255,0.05);
94
+ background: rgba(255,255,255,0.02);
95
+ transition: all 0.25s;
96
+ user-select: none;
97
+ position: relative;
98
+ }
99
+ .ex-cat-head:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
100
+ .ex-node-cat.open > .ex-cat-head { border-color: var(--cat-color, rgba(255,255,255,0.15)); }
101
+
102
+ /* Branch line from trunk */
103
+ .ex-cat-branch {
104
+ position: absolute; left: -14px; top: 50%;
105
+ width: 14px; height: 2px;
106
+ transform: translateY(-1px);
107
+ }
108
+ .ex-cat-dot {
109
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
110
+ box-shadow: 0 0 8px var(--cat-color);
111
+ background: var(--cat-color);
112
+ transition: transform 0.2s;
113
+ }
114
+ .ex-node-cat.open > .ex-cat-head .ex-cat-dot { transform: scale(1.2); }
115
+ .ex-cat-name {
116
+ flex: 1; font-size: 11px; font-weight: 600;
117
+ letter-spacing: 0.5px; text-transform: uppercase;
118
+ color: var(--text-primary);
119
+ }
120
+ .ex-cat-chevron {
121
+ width: 14px; height: 14px; flex-shrink: 0;
122
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
123
+ color: var(--text-tertiary);
124
+ }
125
+ .ex-node-cat.open > .ex-cat-head .ex-cat-chevron { transform: rotate(90deg); color: var(--cat-color); }
126
+ .ex-cat-count {
127
+ font-size: 9px; color: var(--text-tertiary); font-weight: 500;
128
+ padding: 1px 6px; border-radius: 3px;
129
+ background: rgba(255,255,255,0.04);
130
+ }
131
+
132
+ /* Feature container with branch lines */
133
+ .ex-features-wrap {
134
+ max-height: 0; overflow: hidden;
135
+ transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
136
+ margin-left: 16px;
137
+ border-left: 2px solid rgba(255,255,255,0.06);
138
+ position: relative;
139
+ }
140
+ .ex-node-cat.open > .ex-features-wrap {
141
+ max-height: 800px;
142
+ border-left-color: var(--cat-color, rgba(255,255,255,0.12));
143
+ }
144
+ .ex-features-inner { padding: 6px 0 8px 0; }
145
+
146
+ /* ── Feature Node ────────────────────────────────────── */
147
+ .ex-node-feat {
148
+ position: relative;
149
+ margin: 3px 0; margin-left: 12px;
150
+ padding: 7px 10px;
151
+ border-radius: 6px;
152
+ border: 1px solid rgba(255,255,255,0.04);
153
+ background: rgba(255,255,255,0.015);
154
+ transition: all 0.25s;
155
+ animation: exFeatIn 0.3s ease-out both;
156
+ }
157
+ .ex-node-feat:hover {
158
+ background: rgba(255,255,255,0.04);
159
+ border-color: rgba(255,255,255,0.1);
160
+ transform: translateX(2px);
161
+ }
162
+ @keyframes exFeatIn { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: translateX(0); } }
163
+
164
+ /* Branch line from category trunk to feature */
165
+ .ex-feat-branch {
166
+ position: absolute; left: -13px; top: 16px;
167
+ width: 12px; height: 2px;
168
+ }
169
+ .ex-feat-name {
170
+ font-size: 10.5px; font-weight: 600; color: var(--text-primary); line-height: 1.3;
171
+ }
172
+ .ex-feat-reasoning {
173
+ font-size: 9px; color: var(--text-secondary); line-height: 1.5; margin-top: 3px;
174
+ }
175
+ .ex-feat-validators { display: flex; gap: 6px; margin-top: 5px; flex-wrap: wrap; }
176
+ .ex-val-chip {
177
+ display: inline-flex; align-items: center; gap: 3px;
178
+ font-size: 8px; font-weight: 600; letter-spacing: 0.3px;
179
+ padding: 2px 6px; border-radius: 3px;
180
+ }
181
+ .ex-val-chip.agree { background: rgba(34,197,94,0.1); color: #4ade80; }
182
+ .ex-val-chip.disagree { background: rgba(239,68,68,0.1); color: #f87171; }
183
+
184
+ /* ── LLM Tags ────────────────────────────────────────── */
185
+ .ex-llm-strip { display: flex; gap: 5px; margin: 10px 0 6px; }
186
+ .ex-llm-tag {
187
+ font-size: 8px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
188
+ padding: 2px 7px; border-radius: 3px;
189
+ background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
190
+ color: var(--text-tertiary);
191
+ }
192
+ .ex-llm-tag.active { border-color: rgba(124,58,237,0.4); color: #c4b5fd; background: rgba(124,58,237,0.08); }
193
+
194
+ /* ── Consensus Bar ───────────────────────────────────── */
195
+ .ex-consensus {
196
+ margin-top: 12px; padding: 10px 12px;
197
+ background: rgba(255,255,255,0.02); border-radius: 8px;
198
+ animation: exNodeIn 0.5s ease-out 0.4s both;
199
+ }
200
+ .ex-cons-bar-bg {
201
+ height: 4px; border-radius: 2px;
202
+ background: rgba(255,255,255,0.06); overflow: hidden;
203
+ }
204
+ .ex-cons-bar-fill {
205
+ height: 100%; border-radius: 2px;
206
+ background: linear-gradient(90deg, #7c3aed, #a78bfa);
207
+ box-shadow: 0 0 8px rgba(124,58,237,0.5);
208
+ transition: width 1s cubic-bezier(0.4,0,0.2,1);
209
+ }
210
+ .ex-cons-labels {
211
+ display: flex; justify-content: space-between;
212
+ margin-top: 6px; font-size: 9px; color: var(--text-tertiary);
213
+ }
214
+ .ex-cons-labels .agreed { color: #a78bfa; }
215
+
216
+ /* ── Expand/Collapse All ─────────────────────────────── */
217
+ .ex-controls {
218
+ display: flex; justify-content: flex-end; gap: 6px;
219
+ margin-bottom: 8px;
220
+ }
221
+ .ex-ctrl-btn {
222
+ font-size: 8px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
223
+ padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.08);
224
+ background: rgba(255,255,255,0.03); color: var(--text-secondary);
225
+ cursor: pointer; transition: all 0.2s;
226
+ }
227
+ .ex-ctrl-btn:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.15); color: var(--text-primary); }
228
+ .ex-ctrl-btn.active { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.4); color: #c4b5fd; }
229
+
230
+ /* ─��� Graph Node View ─────────────────────────────────── */
231
+ .ex-graph-wrap {
232
+ position: relative;
233
+ overflow: auto;
234
+ border-radius: 8px;
235
+ background: radial-gradient(ellipse at 50% 30%, rgba(124,58,237,0.06) 0%, transparent 70%);
236
+ border: 1px solid rgba(255,255,255,0.04);
237
+ min-height: 260px;
238
+ }
239
+ .ex-graph-svg {
240
+ display: block;
241
+ font-family: 'Inter', -apple-system, sans-serif;
242
+ }
243
+ .ex-graph-svg text { pointer-events: none; user-select: none; }
244
+ .ex-graph-svg .gn-edge {
245
+ fill: none; stroke-width: 2; opacity: 0.45;
246
+ animation: exEdgeDraw 0.8s ease-out both;
247
+ }
248
+ @keyframes exEdgeDraw {
249
+ from { stroke-dashoffset: 200; }
250
+ to { stroke-dashoffset: 0; }
251
+ }
252
+ .ex-graph-svg .gn-node { cursor: default; }
253
+ .ex-graph-svg .gn-node:hover rect,
254
+ .ex-graph-svg .gn-node:hover circle { filter: brightness(1.3); }
255
+
256
+ /* Graph tooltip */
257
+ .ex-graph-tip {
258
+ position: absolute; z-index: 100;
259
+ background: rgba(15, 23, 42, 0.95);
260
+ border: 1px solid rgba(124,58,237,0.3);
261
+ border-radius: 6px; padding: 8px 10px;
262
+ max-width: 220px; font-size: 9.5px;
263
+ color: var(--text-primary); line-height: 1.5;
264
+ pointer-events: none;
265
+ box-shadow: 0 4px 16px rgba(0,0,0,0.6);
266
+ animation: exTipIn 0.15s ease-out;
267
+ }
268
+ @keyframes exTipIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
269
+ .ex-graph-tip strong { color: #e9d5ff; font-size: 10px; }
270
+ .ex-graph-tip .gt-section { margin-top: 4px; padding-top: 3px; border-top: 1px solid rgba(255,255,255,0.08); }
271
+ .ex-graph-tip .gt-agree { color: #4ade80; }
272
+ .ex-graph-tip .gt-disagree { color: #f87171; }
273
+ .ex-graph-tip .gt-label { font-weight: 600; color: var(--text-secondary); }
demo/styles/inspect.css ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Inspect panel styling */
2
+ .inspect-header {
3
+ display: flex;
4
+ align-items: center;
5
+ gap: 8px;
6
+ margin-bottom: 12px;
7
+ padding-bottom: 10px;
8
+ border-bottom: 1px solid var(--panel-border);
9
+ }
10
+
11
+ .inspect-row {
12
+ display: flex;
13
+ justify-content: space-between;
14
+ align-items: center;
15
+ padding: 6px 0;
16
+ border-bottom: 1px solid rgba(255,255,255,0.03);
17
+ }
18
+
19
+ .inspect-label {
20
+ font-size: 8px;
21
+ text-transform: uppercase;
22
+ letter-spacing: 1.5px;
23
+ color: var(--text-tertiary);
24
+ }
25
+
26
+ /* ── Inspect Quad Grid ────────────────────────────────────────── */
27
+
28
+ #inspectPanel {
29
+ display: flex;
30
+ flex-direction: column;
31
+ }
32
+
33
+ #inspectPanel.hidden {
34
+ display: none !important;
35
+ }
36
+
37
+ #inspectHeader {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 8px;
41
+ margin-bottom: 8px;
42
+ padding-bottom: 8px;
43
+ border-bottom: 1px solid var(--panel-border);
44
+ flex-shrink: 0;
45
+ }
46
+
47
+ #inspectBack {
48
+ background: none;
49
+ border: 1px solid var(--panel-border);
50
+ color: var(--text-secondary);
51
+ font-family: inherit;
52
+ font-size: 9px;
53
+ font-weight: 600;
54
+ letter-spacing: 1px;
55
+ padding: 4px 8px;
56
+ border-radius: 4px;
57
+ cursor: pointer;
58
+ flex-shrink: 0;
59
+ }
60
+
61
+ #inspectBack:hover {
62
+ background: rgba(255,255,255,0.06);
63
+ color: var(--text-primary);
64
+ }
65
+
66
+ #inspectLabel {
67
+ font-size: 11px;
68
+ font-weight: 600;
69
+ letter-spacing: 1.5px;
70
+ color: var(--text-primary);
71
+ flex: 1;
72
+ min-width: 0;
73
+ overflow: hidden;
74
+ text-overflow: ellipsis;
75
+ white-space: nowrap;
76
+ }
77
+
78
+ #inspectConf {
79
+ font-size: 11px;
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ #quadGrid {
84
+ display: grid;
85
+ grid-template: 1fr 1fr / 1fr 1fr;
86
+ gap: 6px;
87
+ flex: 1;
88
+ min-height: 0;
89
+ }
90
+
91
+ .quadrant {
92
+ position: relative;
93
+ border-radius: 8px;
94
+ overflow: hidden;
95
+ background: var(--panel-bg);
96
+ border: 1px solid var(--panel-border);
97
+ cursor: pointer;
98
+ }
99
+
100
+ .quad-label {
101
+ position: absolute;
102
+ top: 6px;
103
+ left: 8px;
104
+ font-size: 9px;
105
+ font-weight: 600;
106
+ text-transform: uppercase;
107
+ letter-spacing: 1.5px;
108
+ z-index: 2;
109
+ pointer-events: none;
110
+ }
111
+
112
+ .quad-canvas {
113
+ width: 100%;
114
+ height: 100%;
115
+ display: block;
116
+ }
117
+
118
+ .quad-metric {
119
+ position: absolute;
120
+ bottom: 6px;
121
+ left: 8px;
122
+ font-size: 9px;
123
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
124
+ color: var(--text-secondary);
125
+ z-index: 2;
126
+ pointer-events: none;
127
+ }
128
+
129
+ .quadrant.expanded {
130
+ grid-column: 1 / -1;
131
+ grid-row: 1 / -1;
132
+ z-index: 5;
133
+ }
134
+
135
+ #threeContainer {
136
+ width: 100%;
137
+ height: 100%;
138
+ }
139
+
140
+ #threeContainer canvas {
141
+ width: 100% !important;
142
+ height: 100% !important;
143
+ }
144
+
145
+ #inspectMetrics {
146
+ flex-shrink: 0;
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ gap: 6px;
150
+ padding-top: 8px;
151
+ border-top: 1px solid var(--panel-border);
152
+ margin-top: 8px;
153
+ }
154
+
155
+ .inspect-metric-item {
156
+ flex: 1 1 45%;
157
+ min-width: 0;
158
+ }
159
+
160
+ .inspect-metric-item .label {
161
+ font-size: 7px;
162
+ }
163
+
164
+ .inspect-metric-item .value {
165
+ font-size: 11px;
166
+ }
167
+
168
+ /* ── Metrics Mode ─────────────────────────────────────────────── */
169
+
170
+ .metric-hero {
171
+ text-align: center;
172
+ padding: 16px 0 12px;
173
+ border-bottom: 1px solid var(--panel-border);
174
+ margin-bottom: 12px;
175
+ }
176
+
177
+ .metric-big-number {
178
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
179
+ font-size: 48px;
180
+ font-weight: 300;
181
+ color: var(--accent-light);
182
+ line-height: 1;
183
+ }
184
+
185
+ .metric-hero .label {
186
+ margin-top: 6px;
187
+ }
188
+
189
+ .metric-breakdown {
190
+ display: flex;
191
+ flex-direction: column;
192
+ gap: 8px;
193
+ margin-bottom: 14px;
194
+ }
195
+
196
+ .metric-bar-row {
197
+ display: flex;
198
+ align-items: center;
199
+ gap: 8px;
200
+ }
201
+
202
+ .metric-bar-label {
203
+ font-size: 9px;
204
+ font-weight: 600;
205
+ letter-spacing: 1px;
206
+ text-transform: uppercase;
207
+ color: var(--text-secondary);
208
+ min-width: 72px;
209
+ text-align: right;
210
+ }
211
+
212
+ .metric-bar-track {
213
+ flex: 1;
214
+ height: 4px;
215
+ background: rgba(255,255,255,0.04);
216
+ border-radius: 2px;
217
+ overflow: hidden;
218
+ }
219
+
220
+ .metric-bar-fill {
221
+ height: 100%;
222
+ border-radius: 2px;
223
+ transition: width 0.6s ease;
224
+ }
225
+
226
+ .metric-bar-count {
227
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
228
+ font-size: 10px;
229
+ color: var(--text-primary);
230
+ min-width: 16px;
231
+ }
232
+
233
+ .metric-stats {
234
+ display: flex;
235
+ flex-direction: column;
236
+ gap: 0;
237
+ }
238
+
239
+ .metric-stat-row {
240
+ display: flex;
241
+ justify-content: space-between;
242
+ align-items: center;
243
+ padding: 6px 0;
244
+ border-bottom: 1px solid rgba(255,255,255,0.03);
245
+ }
246
+
247
+ .metric-stat-row:last-child {
248
+ border-bottom: none;
249
+ }
250
+
251
+ .metric-stat-label {
252
+ font-size: 9px;
253
+ text-transform: uppercase;
254
+ letter-spacing: 1.2px;
255
+ color: var(--text-tertiary);
256
+ }
257
+
258
+ .metric-stat-value {
259
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
260
+ font-size: 12px;
261
+ color: var(--text-primary);
262
+ font-weight: 500;
263
+ }
264
+
265
+ .inspect-value {
266
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
267
+ font-size: 12px;
268
+ color: var(--text-primary);
269
+ }
270
+
271
+ /* Mission report styling */
272
+ .mission-report {
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 14px;
276
+ }
277
+
278
+ .report-header {
279
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
280
+ font-size: 11px;
281
+ font-weight: 700;
282
+ letter-spacing: 1.5px;
283
+ color: var(--accent-light);
284
+ padding-bottom: 8px;
285
+ border-bottom: 1px solid var(--panel-border);
286
+ }
287
+
288
+ .report-section {
289
+ background: rgba(255,255,255,0.02);
290
+ border: 1px solid rgba(255,255,255,0.04);
291
+ border-radius: 6px;
292
+ padding: 10px 12px;
293
+ }
294
+
295
+ .report-section-title {
296
+ font-size: 8px;
297
+ text-transform: uppercase;
298
+ letter-spacing: 2px;
299
+ color: var(--accent-light);
300
+ margin-bottom: 8px;
301
+ font-weight: 600;
302
+ }
303
+
304
+ .report-stat {
305
+ display: flex;
306
+ justify-content: space-between;
307
+ padding: 4px 0;
308
+ font-size: 11px;
309
+ }
310
+
311
+ .report-stat-label {
312
+ color: var(--text-tertiary);
313
+ }
314
+
315
+ .report-stat-value {
316
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
317
+ color: var(--text-primary);
318
+ font-weight: 500;
319
+ }
320
+
321
+ .report-threat {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 8px;
325
+ padding: 6px 8px;
326
+ border-radius: 4px;
327
+ background: rgba(239,68,68,0.06);
328
+ border: 1px solid rgba(239,68,68,0.12);
329
+ margin-bottom: 6px;
330
+ }
331
+
332
+ .report-threat-time {
333
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
334
+ font-size: 9px;
335
+ color: var(--text-tertiary);
336
+ flex-shrink: 0;
337
+ }
338
+
339
+ .report-threat-label {
340
+ font-size: 10px;
341
+ color: var(--danger);
342
+ font-weight: 500;
343
+ }
344
+
345
+ .report-rec {
346
+ font-size: 10px;
347
+ color: var(--text-secondary);
348
+ padding: 4px 0 4px 12px;
349
+ border-left: 2px solid var(--accent);
350
+ margin-bottom: 6px;
351
+ line-height: 1.5;
352
+ }
353
+
354
+ /* Metrics panel styling */
355
+ .metrics-grid {
356
+ display: grid;
357
+ grid-template-columns: 1fr 1fr;
358
+ gap: 8px;
359
+ margin-bottom: 12px;
360
+ }
361
+
362
+ .metric-card {
363
+ background: rgba(255,255,255,0.02);
364
+ border: 1px solid rgba(255,255,255,0.04);
365
+ border-radius: 6px;
366
+ padding: 10px;
367
+ text-align: center;
368
+ }
369
+
370
+ .metric-value {
371
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
372
+ font-size: 18px;
373
+ font-weight: 700;
374
+ color: var(--text-primary);
375
+ }
376
+
377
+ .metric-label {
378
+ font-size: 8px;
379
+ text-transform: uppercase;
380
+ letter-spacing: 1.5px;
381
+ color: var(--text-tertiary);
382
+ margin-top: 4px;
383
+ }
demo/styles/layout.css ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Layout: Top Bar ───────────────────────────────────────────── */
2
+
3
+ #topBar {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ padding: 0 20px;
8
+ border-bottom: 1px solid var(--panel-border);
9
+ background: rgba(2, 6, 23, 0.8);
10
+ backdrop-filter: blur(12px);
11
+ -webkit-backdrop-filter: blur(12px);
12
+ }
13
+
14
+ /* ── Layout: Main Area ─────────────────────────────────────────── */
15
+
16
+ #mainArea {
17
+ display: flex;
18
+ gap: 12px;
19
+ padding: 12px;
20
+ min-height: 0;
21
+ overflow: hidden;
22
+ }
23
+
24
+ #videoFeed {
25
+ flex: 7;
26
+ min-width: 0;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ position: relative;
31
+ overflow: hidden;
32
+ }
33
+
34
+ #drawer {
35
+ flex: 3;
36
+ min-width: 0;
37
+ display: flex;
38
+ flex-direction: column;
39
+ align-items: stretch;
40
+ overflow: hidden;
41
+ }
42
+
43
+ /* ── Layout: Timeline ──────────────────────────────────────────── */
44
+
45
+ #timeline {
46
+ margin: 0 12px;
47
+ padding: 10px 16px;
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: stretch;
51
+ gap: 6px;
52
+ min-height: 48px;
53
+ position: relative;
54
+ }
55
+
56
+ /* ── Layout: Command Bar ───────────────────────────────────────── */
57
+
58
+ #commandBar {
59
+ margin: 8px 12px 12px;
60
+ padding: 10px 16px;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: flex-start;
64
+ gap: 10px;
65
+ background: transparent;
66
+ border: 1px solid var(--panel-border);
67
+ border-radius: 10px;
68
+ min-height: 44px;
69
+ }
70
+
71
+ /* ── Top Bar Internals ────────────────────────────────────────── */
72
+
73
+ .topbar-left, .topbar-right {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 10px;
77
+ }
78
+
79
+ .topbar-sep {
80
+ color: var(--text-tertiary);
81
+ font-weight: 300;
82
+ font-size: 16px;
83
+ margin: 0 2px;
84
+ }
85
+
86
+ .topbar-mission {
87
+ color: var(--success);
88
+ letter-spacing: 2.5px;
89
+ }
90
+
91
+ .topbar-pulse {
92
+ animation: pulse-dot 2s ease-in-out infinite;
93
+ }
94
+
95
+ .topbar-indicator {
96
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
97
+ font-size: 10px;
98
+ color: var(--text-secondary);
99
+ letter-spacing: 0.5px;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: 5px;
103
+ }
104
+
105
+ .topbar-clock {
106
+ min-width: 72px;
107
+ text-align: right;
108
+ }
109
+
110
+ /* ── Placeholder Labels ────────────────────────────────────────── */
111
+
112
+ .placeholder-label {
113
+ color: var(--text-tertiary);
114
+ font-size: 11px;
115
+ letter-spacing: 1px;
116
+ text-transform: uppercase;
117
+ user-select: none;
118
+ }
119
+
120
+ /* ── Drawer ───────────────────────────────────────────────────── */
121
+
122
+ .drawer-tabs {
123
+ display: flex;
124
+ border-bottom: 1px solid var(--panel-border);
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ .drawer-tab {
129
+ flex: 1;
130
+ background: none;
131
+ border: none;
132
+ color: var(--text-tertiary);
133
+ font-family: inherit;
134
+ font-size: 9px;
135
+ font-weight: 600;
136
+ letter-spacing: 1.5px;
137
+ text-transform: uppercase;
138
+ padding: 10px 0;
139
+ cursor: pointer;
140
+ }
141
+
142
+ .drawer-tab:hover {
143
+ color: var(--text-secondary);
144
+ }
145
+
146
+ .drawer-tab.active {
147
+ color: var(--accent-light);
148
+ }
149
+
150
+ .drawer-section {
151
+ flex: 1;
152
+ overflow-y: auto;
153
+ padding: 10px 12px;
154
+ }
155
+
156
+ /* ── Min Width Guard ───────────────────────────────────────────── */
157
+
158
+ @media (max-width: 1280px) {
159
+ body {
160
+ min-width: 1280px;
161
+ }
162
+ }
demo/styles/state.css ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── State-Driven Visibility ─────────────────────────────────── */
2
+
3
+ /* Start button: visible only in ready */
4
+ #startBtn { display: none; }
5
+ body[data-state="ready"] #startBtn { display: block; }
6
+
7
+ /* Progress overlay: visible only in processing */
8
+ #progressOverlay { display: none !important; }
9
+ body[data-state="processing"] #progressOverlay {
10
+ display: flex !important;
11
+ background: rgba(0,0,0,0.6);
12
+ }
13
+
14
+ /* Drawer tabs: hidden in ready (config panel shown instead) */
15
+ body[data-state="ready"] .drawer-tabs { display: none; }
16
+
17
+ /* Processing: only TRACKS tab available */
18
+ body[data-state="processing"] .drawer-tab[data-tab="inspect"],
19
+ body[data-state="processing"] .drawer-tab[data-tab="explain"] {
20
+ opacity: 0.3;
21
+ pointer-events: none;
22
+ }
23
+
24
+ /* Analysis: TRACKS + METRICS available, INSPECT disabled */
25
+ body[data-state="analysis"] .drawer-tab[data-tab="inspect"] {
26
+ opacity: 0.3;
27
+ pointer-events: none;
28
+ }
29
+
30
+ /* Inspect state: all tabs enabled, INSPECT forced active */
31
+ body[data-state="inspect"] .drawer-tab[data-tab="inspect"] {
32
+ opacity: 1;
33
+ pointer-events: auto;
34
+ }
35
+
36
+ /* Video controls: hidden in ready */
37
+ body[data-state="ready"] #videoControls { display: none; }
38
+ body[data-state="ready"] #videoBadges { display: none; }
39
+ body[data-state="ready"] #frameCounter { display: none; }
40
+
41
+ /* Processing pulse for top bar dot */
42
+ .topbar-dot-processing {
43
+ animation: processing-pulse 0.8s ease-in-out infinite !important;
44
+ }
45
+
46
+ /* Processing progress overlay fade */
47
+ body[data-state="processing"] #progressOverlay {
48
+ opacity: 1;
49
+ }
50
+
51
+ /* Top Bar Processing Progress Bar */
52
+ body[data-state="processing"] #topBarProgress {
53
+ opacity: 1;
54
+ }
55
+
56
+ /* New Analysis button visibility */
57
+ body[data-state="analysis"] #newAnalysisBtn { display: inline-flex; }
58
+ body[data-state="inspect"] #newAnalysisBtn { display: inline-flex; }
59
+ body[data-state="ready"] #newAnalysisBtn { display: none; }
60
+ body[data-state="processing"] #newAnalysisBtn { display: none; }
61
+
62
+ /* ── State-Driven MJPEG/Video/Cancel Visibility ──────────────── */
63
+
64
+ body[data-state="processing"] #mjpegStream { display: block; }
65
+ body[data-state="processing"] #cancelBtn { display: block; }
66
+ body[data-state="processing"] #videoCanvas { opacity: 0; }
67
+ body[data-state="ready"] #mjpegStream, body[data-state="analysis"] #mjpegStream, body[data-state="inspect"] #mjpegStream { display: none; }
68
+ body[data-state="ready"] #processedVideo, body[data-state="processing"] #processedVideo { display: none; }
69
+ body[data-state="ready"] #cancelBtn, body[data-state="analysis"] #cancelBtn, body[data-state="inspect"] #cancelBtn { display: none; }
demo/styles/timeline.css ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Timeline Internals ───────────────────────────────────────── */
2
+
3
+ .timeline-bar {
4
+ display: flex;
5
+ align-items: center;
6
+ gap: 10px;
7
+ width: 100%;
8
+ }
9
+
10
+ .timeline-time {
11
+ font-size: 9px;
12
+ color: var(--text-tertiary);
13
+ flex-shrink: 0;
14
+ min-width: 32px;
15
+ }
16
+
17
+ .timeline-waveform-wrap {
18
+ flex: 1;
19
+ height: 32px;
20
+ position: relative;
21
+ cursor: pointer;
22
+ }
23
+
24
+ #waveformCanvas {
25
+ width: 100%;
26
+ height: 100%;
27
+ border-radius: 4px;
28
+ }
29
+
30
+ #playhead {
31
+ position: absolute;
32
+ top: 0;
33
+ bottom: 0;
34
+ width: 2px;
35
+ background: #fff;
36
+ left: 0;
37
+ z-index: 2;
38
+ pointer-events: auto;
39
+ cursor: ew-resize;
40
+ }
41
+
42
+ .playhead-handle {
43
+ width: 8px;
44
+ height: 8px;
45
+ border-radius: 50%;
46
+ background: #fff;
47
+ position: absolute;
48
+ top: -4px;
49
+ left: -3px;
50
+ box-shadow: 0 0 4px rgba(255,255,255,0.4);
51
+ }
52
+
53
+ #eventMarkers {
54
+ position: absolute;
55
+ top: 0;
56
+ left: 0;
57
+ right: 0;
58
+ bottom: 0;
59
+ pointer-events: none;
60
+ z-index: 1;
61
+ }
62
+
63
+ .event-marker {
64
+ position: absolute;
65
+ width: 6px;
66
+ height: 6px;
67
+ border-radius: 50%;
68
+ top: 50%;
69
+ transform: translate(-50%, -50%);
70
+ pointer-events: auto;
71
+ cursor: pointer;
72
+ opacity: 0.8;
73
+ }
74
+
75
+ .timeline-legend {
76
+ display: flex;
77
+ gap: 14px;
78
+ font-size: 9px;
79
+ color: var(--text-tertiary);
80
+ padding: 0 42px;
81
+ }
82
+
83
+ .timeline-legend-item {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 4px;
87
+ }
88
+
89
+ .timeline-log-toggle {
90
+ background: none;
91
+ border: none;
92
+ color: var(--text-tertiary);
93
+ font-family: inherit;
94
+ font-size: 8px;
95
+ font-weight: 600;
96
+ letter-spacing: 1.5px;
97
+ text-transform: uppercase;
98
+ cursor: pointer;
99
+ padding: 2px 0;
100
+ align-self: flex-start;
101
+ margin-left: 42px;
102
+ }
103
+
104
+ .timeline-log-toggle:hover {
105
+ color: var(--text-secondary);
106
+ }
107
+
108
+ #eventLog {
109
+ position: absolute;
110
+ bottom: 100%;
111
+ left: 0;
112
+ right: 0;
113
+ max-height: 200px;
114
+ overflow-y: auto;
115
+ background: rgba(2,6,23,0.92);
116
+ backdrop-filter: blur(12px);
117
+ -webkit-backdrop-filter: blur(12px);
118
+ border: 1px solid var(--panel-border);
119
+ border-radius: 8px 8px 0 0;
120
+ padding: 8px;
121
+ z-index: 10;
122
+ }
123
+
124
+ .event-log-entry {
125
+ display: flex;
126
+ align-items: flex-start;
127
+ gap: 8px;
128
+ padding: 5px 6px;
129
+ border-radius: 4px;
130
+ font-size: 10px;
131
+ transition: background 0.15s;
132
+ cursor: pointer;
133
+ }
134
+
135
+ .event-log-entry:hover {
136
+ background: rgba(255,255,255,0.04);
137
+ }
138
+
139
+ .event-log-time {
140
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
141
+ font-size: 9px;
142
+ color: var(--text-tertiary);
143
+ flex-shrink: 0;
144
+ min-width: 52px;
145
+ }
146
+
147
+ .event-log-type {
148
+ font-size: 8px;
149
+ font-weight: 600;
150
+ letter-spacing: 0.8px;
151
+ text-transform: uppercase;
152
+ padding: 1px 5px;
153
+ border-radius: 3px;
154
+ flex-shrink: 0;
155
+ }
156
+
157
+ .event-log-label {
158
+ color: var(--text-primary);
159
+ font-weight: 500;
160
+ flex-shrink: 0;
161
+ }
162
+
163
+ .event-log-desc {
164
+ color: var(--text-tertiary);
165
+ flex: 1;
166
+ min-width: 0;
167
+ }
demo/styles/variables.css ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── CSS Design System ─────────────────────────────────────────── */
2
+
3
+ :root {
4
+ --base: #020617;
5
+ --accent: #3b82f6;
6
+ --accent-light: #60a5fa;
7
+ --danger: #ef4444;
8
+ --warning: #f59e0b;
9
+ --success: #22c55e;
10
+ --text-primary: rgba(255, 255, 255, 0.92);
11
+ --text-secondary: rgba(255, 255, 255, 0.58);
12
+ --text-tertiary: rgba(255, 255, 255, 0.35);
13
+ --panel-bg: rgba(255, 255, 255, 0.02);
14
+ --panel-border: rgba(255, 255, 255, 0.06);
15
+ }
16
+
17
+ /* ── Reset ──────────────────────────────────────────────────────── */
18
+
19
+ *, *::before, *::after {
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ body {
24
+ margin: 0;
25
+ padding: 0;
26
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
27
+ font-size: 13px;
28
+ color: var(--text-primary);
29
+ background: var(--base);
30
+ overflow: hidden;
31
+ width: 100vw;
32
+ height: 100vh;
33
+ display: grid;
34
+ grid-template-rows: 44px 1fr auto auto;
35
+ grid-template-columns: 1fr;
36
+ position: relative;
37
+ }
38
+
39
+ /* ── Ambient Background ────────────────────────────────────────── */
40
+
41
+ body::before {
42
+ content: '';
43
+ position: fixed;
44
+ top: 0;
45
+ left: 0;
46
+ right: 0;
47
+ bottom: 0;
48
+ background:
49
+ radial-gradient(ellipse 80% 50% at 20% 40%, rgba(59, 130, 246, 0.04) 0%, transparent 70%),
50
+ radial-gradient(ellipse 60% 60% at 80% 60%, rgba(96, 165, 250, 0.03) 0%, transparent 70%),
51
+ radial-gradient(ellipse 90% 40% at 50% 90%, rgba(59, 130, 246, 0.02) 0%, transparent 60%);
52
+ pointer-events: none;
53
+ z-index: 0;
54
+ }
55
+
56
+ body > * {
57
+ position: relative;
58
+ z-index: 1;
59
+ }
60
+
61
+ /* ── Scrollbar ─────────────────────────────────────────────────── */
62
+
63
+ ::-webkit-scrollbar {
64
+ width: 4px;
65
+ height: 4px;
66
+ }
67
+
68
+ ::-webkit-scrollbar-track {
69
+ background: rgba(255, 255, 255, 0.02);
70
+ border-radius: 2px;
71
+ }
72
+
73
+ ::-webkit-scrollbar-thumb {
74
+ background: var(--accent);
75
+ border-radius: 2px;
76
+ opacity: 0.5;
77
+ }
78
+
79
+ ::-webkit-scrollbar-thumb:hover {
80
+ background: var(--accent-light);
81
+ }
82
+
83
+ /* ── Polish: Global Text Rendering ────────────────────────────── */
84
+
85
+ * {
86
+ -webkit-font-smoothing: antialiased;
87
+ -moz-osx-font-smoothing: grayscale;
88
+ }
89
+
90
+ ::selection {
91
+ background: rgba(59,130,246,0.3);
92
+ }
demo/styles/video.css ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Video Feed ───────────────────────────────────────────────── */
2
+
3
+ #videoFeed {
4
+ position: relative;
5
+ background: #0a0f1a;
6
+ }
7
+
8
+ #videoCanvas, #overlayCanvas {
9
+ position: absolute;
10
+ top: 0;
11
+ left: 0;
12
+ width: 100%;
13
+ height: 100%;
14
+ }
15
+
16
+ #overlayCanvas {
17
+ pointer-events: auto;
18
+ }
19
+
20
+ #videoBadges {
21
+ position: absolute;
22
+ top: 10px;
23
+ left: 10px;
24
+ display: flex;
25
+ gap: 6px;
26
+ z-index: 2;
27
+ }
28
+
29
+ #frameCounter {
30
+ position: absolute;
31
+ bottom: 10px;
32
+ left: 10px;
33
+ font-size: 10px;
34
+ color: var(--text-secondary);
35
+ z-index: 2;
36
+ background: rgba(0,0,0,0.45);
37
+ padding: 2px 6px;
38
+ border-radius: 4px;
39
+ }
40
+
41
+ #videoControls {
42
+ position: absolute;
43
+ bottom: 10px;
44
+ right: 10px;
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 6px;
48
+ z-index: 4;
49
+ }
50
+
51
+ /* Progress Overlay */
52
+
53
+ #progressOverlay {
54
+ position: absolute;
55
+ inset: 0;
56
+ background: rgba(2,6,23,0.75);
57
+ display: flex;
58
+ flex-direction: column;
59
+ align-items: center;
60
+ justify-content: center;
61
+ z-index: 5;
62
+ gap: 8px;
63
+ }
64
+
65
+ .progress-ring circle {
66
+ fill: none;
67
+ stroke-width: 3;
68
+ }
69
+
70
+ .progress-ring__bg {
71
+ stroke: rgba(255,255,255,0.06);
72
+ }
73
+
74
+ .progress-ring__fg {
75
+ stroke: var(--accent);
76
+ stroke-linecap: round;
77
+ stroke-dasharray: 213.628;
78
+ stroke-dashoffset: 213.628;
79
+ transform: rotate(-90deg);
80
+ transform-origin: center;
81
+ transition: stroke-dashoffset 0.3s ease;
82
+ }
83
+
84
+ .progress-pct {
85
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
86
+ font-size: 14px;
87
+ font-weight: 600;
88
+ color: var(--text-primary);
89
+ }
90
+
91
+ /* Start Button */
92
+
93
+ #startBtn {
94
+ position: absolute;
95
+ top: 50%;
96
+ left: 50%;
97
+ transform: translate(-50%, -50%);
98
+ z-index: 3;
99
+ padding: 14px 36px;
100
+ font-family: inherit;
101
+ font-size: 13px;
102
+ font-weight: 600;
103
+ letter-spacing: 1.5px;
104
+ text-transform: uppercase;
105
+ color: #fff;
106
+ background: linear-gradient(135deg, var(--accent), #2563eb);
107
+ border: none;
108
+ border-radius: 10px;
109
+ cursor: pointer;
110
+ box-shadow: 0 0 20px rgba(59,130,246,0.3), 0 4px 12px rgba(0,0,0,0.3);
111
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
112
+ }
113
+
114
+ #startBtn:hover {
115
+ transform: translate(-50%, -50%) scale(1.04);
116
+ box-shadow: 0 0 30px rgba(59,130,246,0.45), 0 6px 16px rgba(0,0,0,0.35);
117
+ }
118
+
119
+ #startBtn:active {
120
+ transform: translate(-50%, -50%) scale(0.97);
121
+ }
122
+
123
+ #startBtn:disabled { opacity: 0.3; pointer-events: none; }
124
+
125
+ /* ── MJPEG Stream + Processed Video ──────────────────────────── */
126
+
127
+ #mjpegStream {
128
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
129
+ object-fit: contain; z-index: 1; border-radius: 10px;
130
+ }
131
+ #processedVideo {
132
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
133
+ object-fit: contain; z-index: 1; border-radius: 10px; background: #000;
134
+ }
135
+ .cancel-btn {
136
+ position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
137
+ z-index: 15; background: rgba(239,68,68,0.2); border: 1px solid rgba(239,68,68,0.4);
138
+ color: #ef4444; padding: 6px 20px; border-radius: 6px; font-family: inherit;
139
+ font-size: 10px; letter-spacing: 1px; text-transform: uppercase; cursor: pointer;
140
+ }
141
+ .cancel-btn:hover { background: rgba(239,68,68,0.3); }
142
+
143
+ #newAnalysisBtn {
144
+ position: absolute; top: 10px; right: 10px;
145
+ z-index: 15; background: rgba(59,130,246,0.15); border: 1px solid rgba(59,130,246,0.35);
146
+ color: var(--accent-light); padding: 5px 12px; border-radius: 6px; font-family: inherit;
147
+ font-size: 9px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; cursor: pointer;
148
+ display: none;
149
+ }
150
+ #newAnalysisBtn:hover { background: rgba(59,130,246,0.25); }
151
+
152
+ /* Mission-colored SVG overlay boxes */
153
+ #detectionOverlay rect { cursor: pointer; transition: opacity 0.15s; }
154
+ #detectionOverlay rect:hover { opacity: 1 !important; }