Spaces:
Runtime error
Runtime error
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 filesSplit 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 +0 -0
- demo/js/analysis.js +275 -0
- demo/js/api.js +185 -0
- demo/js/cache.js +74 -0
- demo/js/explain.js +447 -0
- demo/js/helpers.js +241 -0
- demo/js/init.js +235 -0
- demo/js/inspect.js +767 -0
- demo/js/real-backend.js +1337 -0
- demo/js/render.js +280 -0
- demo/js/state-machine.js +215 -0
- demo/js/state.js +341 -0
- demo/js/timeline.js +242 -0
- demo/js/ui.js +292 -0
- demo/styles/animations.css +374 -0
- demo/styles/components.css +357 -0
- demo/styles/explain.css +273 -0
- demo/styles/inspect.css +383 -0
- demo/styles/layout.css +162 -0
- demo/styles/state.css +69 -0
- demo/styles/timeline.css +167 -0
- demo/styles/variables.css +92 -0
- demo/styles/video.css +154 -0
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 = '▶';
|
| 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 = '▶';
|
| 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">← 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 = '▶';
|
| 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 = '▶';
|
| 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 = '▶';
|
| 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 ▲';
|
| 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 ▼';
|
| 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>←</kbd><kbd>→</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 ? '▮▮' : '▶';
|
| 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 = '▶';
|
| 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 = '▶';
|
| 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 = '▶';
|
| 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 = '×';
|
| 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; }
|