Spaces:
Runtime error
Runtime error
| window.ISR = window.ISR || {}; | |
| /* ================================================================ | |
| * TASK 3: Top Bar β Clock | |
| * ================================================================ */ | |
| function updateClock() { | |
| const el = document.getElementById('liveClock'); | |
| if (!el) return; | |
| const now = new Date(); | |
| const h = String(now.getUTCHours()).padStart(2, '0'); | |
| const m = String(now.getUTCMinutes()).padStart(2, '0'); | |
| const s = String(now.getUTCSeconds()).padStart(2, '0'); | |
| el.textContent = `${h}:${m}:${s}Z`; | |
| } | |
| /* ================================================================ | |
| * TASK 4: Video Feed β Rendering | |
| * ================================================================ */ | |
| let videoCanvas, videoCtx, overlayCanvas, overlayCtx; | |
| let scanLineY = 0; | |
| let animFrame = 0; | |
| let playbackSpeed = 1; | |
| let lastFrameTime = 0; | |
| function initCanvases() { | |
| videoCanvas = document.getElementById('videoCanvas'); | |
| overlayCanvas = document.getElementById('overlayCanvas'); | |
| videoCtx = videoCanvas.getContext('2d'); | |
| overlayCtx = overlayCanvas.getContext('2d'); | |
| resizeCanvases(); | |
| } | |
| function resizeCanvases() { | |
| const container = document.getElementById('videoFeed'); | |
| const rect = container.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| [videoCanvas, overlayCanvas].forEach(c => { | |
| c.width = rect.width * dpr; | |
| c.height = rect.height * dpr; | |
| c.style.width = rect.width + 'px'; | |
| c.style.height = rect.height + 'px'; | |
| c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); | |
| }); | |
| } | |
| function renderVideoBackground(ctx, w, h, frame) { | |
| ctx.fillStyle = '#0a0f1a'; | |
| ctx.fillRect(0, 0, w, h); | |
| // Grid lines | |
| ctx.strokeStyle = 'rgba(255,255,255,0.02)'; | |
| ctx.lineWidth = 0.5; | |
| for (let x = 0; x < w; x += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, h); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < h; y += 40) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(w, y); | |
| ctx.stroke(); | |
| } | |
| // Scan line β slow horizontal sweep | |
| scanLineY = (scanLineY + 0.5) % h; | |
| const scanGrad = ctx.createLinearGradient(0, scanLineY - 30, 0, scanLineY + 30); | |
| scanGrad.addColorStop(0, 'rgba(255,255,255,0)'); | |
| scanGrad.addColorStop(0.5, 'rgba(255,255,255,0.015)'); | |
| scanGrad.addColorStop(1, 'rgba(255,255,255,0)'); | |
| ctx.fillStyle = scanGrad; | |
| ctx.fillRect(0, scanLineY - 30, w, 60); | |
| // Vignette effect β darker at edges | |
| const vignetteGrad = ctx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.25, w * 0.5, h * 0.5, Math.max(w, h) * 0.75); | |
| vignetteGrad.addColorStop(0, 'rgba(0,0,0,0)'); | |
| vignetteGrad.addColorStop(1, 'rgba(0,0,0,0.35)'); | |
| ctx.fillStyle = vignetteGrad; | |
| ctx.fillRect(0, 0, w, h); | |
| // Corner crosshair markers β military tactical HUD | |
| const crossLen = 18; | |
| const crossOff = 12; | |
| ctx.strokeStyle = 'rgba(255,255,255,0.08)'; | |
| ctx.lineWidth = 1; | |
| // Top-left | |
| ctx.beginPath(); | |
| ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff + crossLen, crossOff); | |
| ctx.moveTo(crossOff, crossOff); ctx.lineTo(crossOff, crossOff + crossLen); | |
| ctx.stroke(); | |
| // Top-right | |
| ctx.beginPath(); | |
| ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff - crossLen, crossOff); | |
| ctx.moveTo(w - crossOff, crossOff); ctx.lineTo(w - crossOff, crossOff + crossLen); | |
| ctx.stroke(); | |
| // Bottom-left | |
| ctx.beginPath(); | |
| ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff + crossLen, h - crossOff); | |
| ctx.moveTo(crossOff, h - crossOff); ctx.lineTo(crossOff, h - crossOff - crossLen); | |
| ctx.stroke(); | |
| // Bottom-right | |
| ctx.beginPath(); | |
| ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff - crossLen, h - crossOff); | |
| ctx.moveTo(w - crossOff, h - crossOff); ctx.lineTo(w - crossOff, h - crossOff - crossLen); | |
| ctx.stroke(); | |
| } | |
| // Track which boxes have been seen, for fade-in animation | |
| const boxFirstSeen = {}; | |
| let boxAnimTime = 0; | |
| function renderDetections(ctx, w, h, frame) { | |
| const STATE = ISR.STATE; | |
| ctx.clearRect(0, 0, w, h); | |
| const tracks = ISR.getTracksAtFrame(frame); | |
| boxAnimTime = performance.now(); | |
| for (const t of tracks) { | |
| const bx = (t.bbox.x / 100) * w; | |
| const by = (t.bbox.y / 100) * h; | |
| const bw = (t.bbox.w / 100) * w; | |
| const bh = (t.bbox.h / 100) * h; | |
| // Fade-in: track when boxes first appear | |
| if (!boxFirstSeen[t.id]) { | |
| boxFirstSeen[t.id] = boxAnimTime; | |
| } | |
| const age = boxAnimTime - boxFirstSeen[t.id]; | |
| const fadeAlpha = Math.min(1, age / 300); // 300ms fade-in | |
| const isSelected = (STATE.selectedTrackId === t.id); | |
| const isHighlighted = (ISR.highlightedTrackId === t.id) || isSelected; | |
| const lineWidth = isHighlighted ? 2.5 : 1.5; | |
| ctx.globalAlpha = fadeAlpha; | |
| // Pulsing glow for highlighted boxes | |
| if (isHighlighted) { | |
| const pulse = 0.6 + 0.4 * Math.sin(boxAnimTime / 300); | |
| ctx.shadowColor = t.color; | |
| ctx.shadowBlur = 8 + 8 * pulse; | |
| } | |
| // Selected box: animated dashed border | |
| if (isSelected) { | |
| ctx.setLineDash([6, 4]); | |
| ctx.lineDashOffset = -(boxAnimTime / 50); // marching ants | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 2; | |
| ISR.roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.lineDashOffset = 0; | |
| } | |
| // Rounded rect border | |
| ctx.strokeStyle = isHighlighted ? '#fff' : t.color; | |
| ctx.lineWidth = lineWidth; | |
| ISR.roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.stroke(); | |
| ctx.shadowColor = 'transparent'; | |
| ctx.shadowBlur = 0; | |
| // Faint fill | |
| ctx.fillStyle = t.color + (isHighlighted ? '1A' : '0D'); | |
| ISR.roundRect(ctx, bx, by, bw, bh, 3); | |
| ctx.fill(); | |
| // Label badge above | |
| const labelText = `${t.label.split(' ').pop()} ${t.confidence.toFixed(2)}`; | |
| ctx.font = '500 9px Inter, sans-serif'; | |
| const tm = ctx.measureText(labelText); | |
| const lw = tm.width + 8; | |
| const lh = 14; | |
| const lx = bx; | |
| const ly = by - lh - 3; | |
| ctx.fillStyle = t.color + 'CC'; | |
| ISR.roundRect(ctx, lx, ly, lw, lh, 2); | |
| ctx.fill(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(labelText, lx + 4, ly + lh / 2); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function updateFrameCounter() { | |
| const STATE = ISR.STATE; | |
| const el = document.getElementById('frameCounter'); | |
| if (el) el.textContent = `FRM ${STATE.playheadFrame} / ${STATE.totalFrames}`; | |
| } | |
| function mainRenderLoop(timestamp) { | |
| const STATE = ISR.STATE; | |
| if (!videoCanvas) { requestAnimationFrame(mainRenderLoop); return; } | |
| const container = document.getElementById('videoFeed'); | |
| const rect = container.getBoundingClientRect(); | |
| const w = rect.width; | |
| const h = rect.height; | |
| renderVideoBackground(videoCtx, w, h, STATE.playheadFrame); | |
| const svgOverlay = document.getElementById('detectionOverlay'); | |
| if (STATE.current !== 'ready') { | |
| // Use real detection rendering when we have a jobId in analysis/inspect/playing | |
| if (STATE.jobId && (STATE.current === 'analysis' || STATE.current === 'playing' || STATE.current === 'inspect')) { | |
| // Real detection rendering is driven by video timeupdate, NOT the render loop | |
| // Only update SVG pointer events here | |
| if (svgOverlay) svgOverlay.style.pointerEvents = 'all'; | |
| } else { | |
| renderDetections(overlayCtx, w, h, STATE.playheadFrame); | |
| // Hide SVG overlay in mock mode | |
| if (svgOverlay) { | |
| while (svgOverlay.firstChild) svgOverlay.removeChild(svgOverlay.firstChild); | |
| svgOverlay.style.pointerEvents = 'none'; | |
| } | |
| } | |
| } else { | |
| overlayCtx.clearRect(0, 0, w, h); | |
| // Clear and disable SVG overlay in ready state | |
| if (svgOverlay) { | |
| while (svgOverlay.firstChild) svgOverlay.removeChild(svgOverlay.firstChild); | |
| svgOverlay.style.pointerEvents = 'none'; | |
| } | |
| } | |
| // Advance playhead if playing (mock playback only β video element handles real playback) | |
| if (STATE.isPlaying && !STATE.jobId && (STATE.current === 'playing' || STATE.current === 'analysis')) { | |
| if (!lastFrameTime) lastFrameTime = timestamp; | |
| const elapsed = timestamp - lastFrameTime; | |
| const framesPerMs = (STATE.fps * playbackSpeed) / 1000; | |
| const framesToAdvance = Math.floor(elapsed * framesPerMs); | |
| if (framesToAdvance > 0) { | |
| STATE.playheadFrame = Math.min(STATE.playheadFrame + framesToAdvance, STATE.totalFrames); | |
| lastFrameTime = timestamp; | |
| updateFrameCounter(); | |
| ISR.updatePlayheadPosition(); | |
| ISR.updateTimeDisplay(); | |
| if (STATE.playheadFrame >= STATE.totalFrames) { | |
| STATE.isPlaying = false; | |
| STATE.playheadFrame = STATE.totalFrames; | |
| document.getElementById('playPauseBtn').innerHTML = '▶'; | |
| } | |
| } | |
| } | |
| requestAnimationFrame(mainRenderLoop); | |
| } | |
| // ββ Export to namespace βββββββββββββββββββββββββββββββββββββββββ | |
| Object.assign(window.ISR, { | |
| updateClock, | |
| initCanvases, | |
| resizeCanvases, | |
| renderVideoBackground, | |
| renderDetections, | |
| updateFrameCounter, | |
| mainRenderLoop, | |
| boxFirstSeen, | |
| get videoCanvas() { return videoCanvas; }, | |
| get videoCtx() { return videoCtx; }, | |
| get overlayCanvas() { return overlayCanvas; }, | |
| get overlayCtx() { return overlayCtx; }, | |
| get playbackSpeed() { return playbackSpeed; }, | |
| set playbackSpeed(v) { playbackSpeed = v; }, | |
| get lastFrameTime() { return lastFrameTime; }, | |
| set lastFrameTime(v) { lastFrameTime = v; }, | |
| }); | |