ISR / demo /js /render.js
Zhen Ye
refactor(demo): split monolithic HTML into component files
0d1be5e
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 = '&#9654;';
}
}
}
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; },
});