APP.ui.radar = {}; // Military color palette APP.ui.radar.colors = { background: "#0a0d0f", gridPrimary: "rgba(0, 255, 136, 0.3)", gridSecondary: "rgba(0, 255, 136, 0.12)", gridTertiary: "rgba(0, 255, 136, 0.06)", sweepLine: "rgba(0, 255, 136, 0.9)", sweepGlow: "rgba(0, 255, 136, 0.4)", sweepTrail: "rgba(0, 255, 136, 0.15)", text: "rgba(0, 255, 136, 0.9)", textDim: "rgba(0, 255, 136, 0.5)", ownship: "#00ff88", hostile: "#ff3344", neutral: "#ffaa00", friendly: "#00aaff", selected: "#ffffff", dataBox: "rgba(0, 20, 10, 0.92)", dataBorder: "rgba(0, 255, 136, 0.6)" }; APP.ui.radar.render = function (canvasId, trackSource, options = {}) { const isStatic = options.static || false; const { state } = APP.core; const { clamp, now, $ } = APP.core.utils; const canvas = $(`#${canvasId}`); const colors = APP.ui.radar.colors; if (!canvas) return; const ctx = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); const dpr = devicePixelRatio || 1; // Resize if needed const targetW = Math.max(1, Math.floor(rect.width * dpr)); const targetH = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== targetW || canvas.height !== targetH) { canvas.width = targetW; canvas.height = targetH; } const w = canvas.width, h = canvas.height; const cx = w * 0.5, cy = h * 0.5; const R = Math.min(w, h) * 0.44; const maxRangeM = 1500; ctx.clearRect(0, 0, w, h); // --- Control Knobs --- const histSlider = document.getElementById("radarHistoryLen"); const futSlider = document.getElementById("radarFutureLen"); if (histSlider && document.getElementById("radarHistoryVal")) { document.getElementById("radarHistoryVal").textContent = histSlider.value; } if (futSlider && document.getElementById("radarFutureVal")) { document.getElementById("radarFutureVal").textContent = futSlider.value; } const maxHist = histSlider ? parseInt(histSlider.value, 10) : 30; const maxFut = futSlider ? parseInt(futSlider.value, 10) : 30; // =========================================== // 1. BACKGROUND - Dark tactical display // =========================================== ctx.fillStyle = colors.background; ctx.fillRect(0, 0, w, h); // Subtle noise/static effect ctx.globalAlpha = 0.03; for (let i = 0; i < 100; i++) { const nx = Math.random() * w; const ny = Math.random() * h; ctx.fillStyle = "#00ff88"; ctx.fillRect(nx, ny, 1, 1); } ctx.globalAlpha = 1; // Scanline effect ctx.strokeStyle = "rgba(0, 255, 136, 0.02)"; ctx.lineWidth = 1; for (let y = 0; y < h; y += 3) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // =========================================== // 2. OUTER BEZEL / FRAME // =========================================== // Outer border ring ctx.strokeStyle = colors.gridPrimary; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, cy, R + 8, 0, Math.PI * 2); ctx.stroke(); // Inner border ring ctx.strokeStyle = colors.gridSecondary; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, R + 3, 0, Math.PI * 2); ctx.stroke(); // Corner brackets const bracketSize = 15; const bracketOffset = R + 20; ctx.strokeStyle = colors.gridPrimary; ctx.lineWidth = 2; // Top-left ctx.beginPath(); ctx.moveTo(cx - bracketOffset, cy - bracketOffset + bracketSize); ctx.lineTo(cx - bracketOffset, cy - bracketOffset); ctx.lineTo(cx - bracketOffset + bracketSize, cy - bracketOffset); ctx.stroke(); // Top-right ctx.beginPath(); ctx.moveTo(cx + bracketOffset - bracketSize, cy - bracketOffset); ctx.lineTo(cx + bracketOffset, cy - bracketOffset); ctx.lineTo(cx + bracketOffset, cy - bracketOffset + bracketSize); ctx.stroke(); // Bottom-left ctx.beginPath(); ctx.moveTo(cx - bracketOffset, cy + bracketOffset - bracketSize); ctx.lineTo(cx - bracketOffset, cy + bracketOffset); ctx.lineTo(cx - bracketOffset + bracketSize, cy + bracketOffset); ctx.stroke(); // Bottom-right ctx.beginPath(); ctx.moveTo(cx + bracketOffset - bracketSize, cy + bracketOffset); ctx.lineTo(cx + bracketOffset, cy + bracketOffset); ctx.lineTo(cx + bracketOffset, cy + bracketOffset - bracketSize); ctx.stroke(); // =========================================== // 3. RANGE RINGS with labels // =========================================== const rangeRings = [ { frac: 0.25, label: "375m" }, { frac: 0.5, label: "750m" }, { frac: 0.75, label: "1125m" }, { frac: 1.0, label: "1500m" } ]; rangeRings.forEach((ring, i) => { const ringR = R * ring.frac; // Ring line ctx.strokeStyle = i === 3 ? colors.gridPrimary : colors.gridSecondary; ctx.lineWidth = i === 3 ? 1.5 : 1; ctx.beginPath(); ctx.arc(cx, cy, ringR, 0, Math.PI * 2); ctx.stroke(); // Tick marks on outer ring if (i === 3) { for (let deg = 0; deg < 360; deg += 5) { const rad = (deg - 90) * Math.PI / 180; const tickLen = deg % 30 === 0 ? 8 : (deg % 10 === 0 ? 5 : 2); const x1 = cx + Math.cos(rad) * ringR; const y1 = cy + Math.sin(rad) * ringR; const x2 = cx + Math.cos(rad) * (ringR + tickLen); const y2 = cy + Math.sin(rad) * (ringR + tickLen); ctx.strokeStyle = deg % 30 === 0 ? colors.gridPrimary : colors.gridTertiary; ctx.lineWidth = deg % 30 === 0 ? 1.5 : 0.5; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } } // Range labels (on right side) ctx.font = "bold 9px 'Courier New', monospace"; ctx.fillStyle = colors.textDim; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(ring.label, cx + ringR + 4, cy); }); // =========================================== // 4. COMPASS ROSE / BEARING LINES // =========================================== // Cardinal directions with labels const cardinals = [ { deg: 0, label: "N", primary: true }, { deg: 45, label: "NE", primary: false }, { deg: 90, label: "E", primary: true }, { deg: 135, label: "SE", primary: false }, { deg: 180, label: "S", primary: true }, { deg: 225, label: "SW", primary: false }, { deg: 270, label: "W", primary: true }, { deg: 315, label: "NW", primary: false } ]; cardinals.forEach(dir => { const rad = (dir.deg - 90) * Math.PI / 180; const x1 = cx + Math.cos(rad) * 12; const y1 = cy + Math.sin(rad) * 12; const x2 = cx + Math.cos(rad) * R; const y2 = cy + Math.sin(rad) * R; // Spoke line ctx.strokeStyle = dir.primary ? colors.gridSecondary : colors.gridTertiary; ctx.lineWidth = dir.primary ? 1 : 0.5; ctx.setLineDash(dir.primary ? [] : [2, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.setLineDash([]); // Cardinal label const labelR = R + 18; const lx = cx + Math.cos(rad) * labelR; const ly = cy + Math.sin(rad) * labelR; ctx.font = dir.primary ? "bold 11px 'Courier New', monospace" : "9px 'Courier New', monospace"; ctx.fillStyle = dir.primary ? colors.text : colors.textDim; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(dir.label, lx, ly); }); // =========================================== // 5. SWEEP ANIMATION (skip for static mode) // =========================================== if (!isStatic) { const t = now() / 2000; // Slower sweep const sweepAng = (t * (Math.PI * 2)) % (Math.PI * 2); // Sweep trail (gradient arc) const trailLength = Math.PI * 0.4; const trailGrad = ctx.createConicGradient(sweepAng - trailLength + Math.PI / 2, cx, cy); trailGrad.addColorStop(0, "transparent"); trailGrad.addColorStop(0.7, "rgba(0, 255, 136, 0.0)"); trailGrad.addColorStop(1, "rgba(0, 255, 136, 0.12)"); ctx.fillStyle = trailGrad; ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill(); // Sweep line with glow ctx.shadowBlur = 15; ctx.shadowColor = colors.sweepGlow; ctx.strokeStyle = colors.sweepLine; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(sweepAng) * R, cy + Math.sin(sweepAng) * R); ctx.stroke(); ctx.shadowBlur = 0; } // =========================================== // 6. OWNSHIP (Center) // =========================================== // Ownship symbol - aircraft shape ctx.fillStyle = colors.ownship; ctx.shadowBlur = 8; ctx.shadowColor = colors.ownship; ctx.beginPath(); ctx.moveTo(cx, cy - 8); // Nose ctx.lineTo(cx + 5, cy + 4); // Right wing ctx.lineTo(cx + 2, cy + 2); ctx.lineTo(cx + 2, cy + 8); // Right tail ctx.lineTo(cx, cy + 5); ctx.lineTo(cx - 2, cy + 8); // Left tail ctx.lineTo(cx - 2, cy + 2); ctx.lineTo(cx - 5, cy + 4); // Left wing ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0; // Ownship pulse ring (skip for static mode) if (!isStatic) { const pulsePhase = (now() / 1000) % 1; const pulseR = 10 + pulsePhase * 15; ctx.strokeStyle = `rgba(0, 255, 136, ${0.5 - pulsePhase * 0.5})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, pulseR, 0, Math.PI * 2); ctx.stroke(); } else { // Static ring for static mode ctx.strokeStyle = `rgba(0, 255, 136, 0.4)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, 12, 0, Math.PI * 2); ctx.stroke(); } // =========================================== // 7. RENDER TRACKS / TARGETS // =========================================== const source = trackSource || state.detections; const fovRad = (60 * Math.PI) / 180; if (source && source.length > 0) { source.forEach((det, idx) => { // Calculate range let rPx; let dist = 3000; if (det.depth_valid && det.depth_rel != null) { rPx = (det.depth_rel * 0.9 + 0.1) * R; dist = det.depth_est_m || 3000; } else { if (det.gpt_distance_m) { dist = det.gpt_distance_m; } else if (det.baseRange_m) { dist = det.baseRange_m; } rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R; } // Calculate bearing from bbox const bx = det.bbox.x + det.bbox.w * 0.5; let tx = 0; if (bx <= 2.0) { tx = bx - 0.5; } else { const fw = state.frame.w || 1280; tx = (bx / fw) - 0.5; } const angle = (-Math.PI / 2) + (tx * fovRad); // Target position const px = cx + Math.cos(angle) * rPx; const py = cy + Math.sin(angle) * rPx; const isSelected = (state.selectedId === det.id) || (state.tracker.selectedTrackId === det.id); // Determine threat color let threatColor = colors.hostile; // Default hostile (red) const label = (det.label || "").toLowerCase(); if (label.includes('person')) threatColor = colors.neutral; if (label.includes('friendly')) threatColor = colors.friendly; if (isSelected) threatColor = colors.selected; // Target glow for all targets ctx.shadowBlur = isSelected ? 15 : 8; ctx.shadowColor = threatColor; // =========================================== // TARGET SYMBOL - Military bracket style // =========================================== ctx.save(); ctx.translate(px, py); // Rotation based on heading let rotation = -Math.PI / 2; if (det.angle_deg !== undefined) { rotation = det.angle_deg * (Math.PI / 180); } ctx.rotate(rotation); const size = isSelected ? 16 : 12; // Draw target - larger triangle shape ctx.strokeStyle = threatColor; ctx.fillStyle = threatColor; ctx.lineWidth = isSelected ? 2.5 : 2; // Triangle pointing in direction of travel ctx.beginPath(); ctx.moveTo(size, 0); // Front tip ctx.lineTo(-size * 0.6, -size * 0.5); // Top left ctx.lineTo(-size * 0.4, 0); // Back indent ctx.lineTo(-size * 0.6, size * 0.5); // Bottom left ctx.closePath(); ctx.fill(); // Outline for better visibility ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(0, 0, 0, 0.5)"; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); ctx.shadowBlur = 0; // =========================================== // TARGET ID LABEL (always show) // =========================================== ctx.font = "bold 9px 'Courier New', monospace"; ctx.fillStyle = threatColor; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(det.id, px + 12, py - 2); // =========================================== // SELECTED TARGET - Full data display // =========================================== if (isSelected) { // Targeting brackets around selected target const bracketS = 18; ctx.strokeStyle = colors.selected; ctx.lineWidth = 1.5; // Animated bracket expansion (static for static mode) const bracketPulse = isStatic ? 0 : Math.sin(now() / 200) * 2; const bOff = bracketS + bracketPulse; // Top-left bracket ctx.beginPath(); ctx.moveTo(px - bOff, py - bOff + 6); ctx.lineTo(px - bOff, py - bOff); ctx.lineTo(px - bOff + 6, py - bOff); ctx.stroke(); // Top-right bracket ctx.beginPath(); ctx.moveTo(px + bOff - 6, py - bOff); ctx.lineTo(px + bOff, py - bOff); ctx.lineTo(px + bOff, py - bOff + 6); ctx.stroke(); // Bottom-left bracket ctx.beginPath(); ctx.moveTo(px - bOff, py + bOff - 6); ctx.lineTo(px - bOff, py + bOff); ctx.lineTo(px - bOff + 6, py + bOff); ctx.stroke(); // Bottom-right bracket ctx.beginPath(); ctx.moveTo(px + bOff - 6, py + bOff); ctx.lineTo(px + bOff, py + bOff); ctx.lineTo(px + bOff, py + bOff - 6); ctx.stroke(); // Data callout box const boxX = px + 25; const boxY = py - 50; const boxW = 95; const boxH = det.speed_kph ? 52 : 32; // Line from target to box ctx.strokeStyle = colors.dataBorder; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(px + 15, py - 10); ctx.lineTo(boxX, boxY + boxH / 2); ctx.stroke(); ctx.setLineDash([]); // Box background ctx.fillStyle = colors.dataBox; ctx.fillRect(boxX, boxY, boxW, boxH); // Box border ctx.strokeStyle = colors.dataBorder; ctx.lineWidth = 1; ctx.strokeRect(boxX, boxY, boxW, boxH); // Corner accents ctx.strokeStyle = colors.text; ctx.lineWidth = 2; const cornerLen = 5; // Top-left ctx.beginPath(); ctx.moveTo(boxX, boxY + cornerLen); ctx.lineTo(boxX, boxY); ctx.lineTo(boxX + cornerLen, boxY); ctx.stroke(); // Top-right ctx.beginPath(); ctx.moveTo(boxX + boxW - cornerLen, boxY); ctx.lineTo(boxX + boxW, boxY); ctx.lineTo(boxX + boxW, boxY + cornerLen); ctx.stroke(); // Data text ctx.font = "bold 10px 'Courier New', monospace"; ctx.fillStyle = colors.text; ctx.textAlign = "left"; // Range ctx.fillText(`RNG: ${Math.round(dist)}m`, boxX + 6, boxY + 14); // Bearing const bearingDeg = Math.round((angle + Math.PI / 2) * 180 / Math.PI); ctx.fillText(`BRG: ${bearingDeg.toString().padStart(3, '0')}°`, boxX + 6, boxY + 28); // Speed (if available) if (det.speed_kph) { ctx.fillStyle = colors.neutral; ctx.fillText(`SPD: ${det.speed_kph.toFixed(0)} kph`, boxX + 6, boxY + 42); } // Trail rendering for selected target if (det.history && det.history.length > 0 && det.gpt_distance_m) { const currH = det.bbox.h; const currDist = det.gpt_distance_m; ctx.save(); const available = det.history.length; const startIdx = Math.max(0, available - maxHist); const subset = det.history.slice(startIdx); let points = []; subset.forEach((hBox) => { let hH, hX; if (hBox[0] <= 2.0 && hBox[2] <= 2.0) { hH = hBox[3] - hBox[1]; hX = (hBox[0] + hBox[2]) / 2; } else { const fw = state.frame.w || 1280; const fh = state.frame.h || 720; hH = (hBox[3] - hBox[1]) / fh; hX = ((hBox[0] + hBox[2]) / 2) / fw; } if (hH <= 0.001) return; let distHist = currDist * (det.bbox.h / hH); const rPxHist = (clamp(distHist, 0, maxRangeM) / maxRangeM) * R; const txHist = hX - 0.5; const angleHist = (-Math.PI / 2) + (txHist * fovRad); const pxHist = cx + Math.cos(angleHist) * rPxHist; const pyHist = cy + Math.sin(angleHist) * rPxHist; points.push({ x: pxHist, y: pyHist }); }); points.push({ x: px, y: py }); ctx.lineWidth = 1.5; for (let i = 0; i < points.length - 1; i++) { const p1 = points[i]; const p2 = points[i + 1]; const age = points.length - 1 - i; const alpha = Math.max(0, 1.0 - (age / (maxHist + 1))); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.strokeStyle = threatColor; ctx.globalAlpha = alpha * 0.6; ctx.stroke(); } ctx.globalAlpha = 1; ctx.restore(); } // Predicted path if (det.predicted_path && maxFut > 0) { ctx.save(); const futSubset = det.predicted_path.slice(0, maxFut); if (futSubset.length > 0) { const currDist = det.gpt_distance_m || (det.depth_est_m || 2000); const fw = state.frame.w || 1280; const fh = state.frame.h || 720; let predPoints = [{ x: px, y: py }]; futSubset.forEach((pt) => { const pX = pt[0] <= 2.0 ? pt[0] : (pt[0] / fw); const pY = pt[0] <= 2.0 ? pt[1] : (pt[1] / fh); const txP = pX - 0.5; const angP = (-Math.PI / 2) + (txP * fovRad); const cY = (det.bbox.y <= 2.0) ? (det.bbox.y + det.bbox.h / 2) : ((det.bbox.y + det.bbox.h / 2) / fh); let distP = currDist * (cY / Math.max(0.01, pY)); const rPxP = (clamp(distP, 0, maxRangeM) / maxRangeM) * R; const pxP = cx + Math.cos(angP) * rPxP; const pyP = cy + Math.sin(angP) * rPxP; predPoints.push({ x: pxP, y: pyP }); }); ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); for (let i = 0; i < predPoints.length - 1; i++) { const p1 = predPoints[i]; const p2 = predPoints[i + 1]; const alpha = Math.max(0, 1.0 - (i / maxFut)); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.strokeStyle = threatColor; ctx.globalAlpha = alpha * 0.8; ctx.stroke(); } ctx.setLineDash([]); ctx.globalAlpha = 1; } ctx.restore(); } } }); } // =========================================== // 8. STATUS OVERLAY - Top corners // =========================================== ctx.font = "bold 9px 'Courier New', monospace"; ctx.fillStyle = colors.textDim; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Top left - Mode ctx.fillText(isStatic ? "SNAPSHOT" : "TGT ACQUISITION", 8, 8); // Track count const trackCount = source ? source.length : 0; ctx.fillStyle = trackCount > 0 ? colors.text : colors.textDim; ctx.fillText(`TRACKS: ${trackCount}`, 8, 22); // Top right - Range setting ctx.textAlign = "right"; ctx.fillStyle = colors.textDim; ctx.fillText(`MAX RNG: ${maxRangeM}m`, w - 8, 8); // Time const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false }); ctx.fillText(timeStr, w - 8, 22); // Bottom center - FOV indicator ctx.textAlign = "center"; ctx.fillStyle = colors.textDim; ctx.fillText("FOV: 60°", cx, h - 12); }; // Aliases for compatibility APP.ui.radar.renderFrameRadar = function () { const { state } = APP.core; // Only show tracks after first frame is processed if (!state.firstFrameReady) { APP.ui.radar.render("frameRadar", [], { static: true }); return; } // In demo mode, use demo data for first frame (time=0) to match video radar initial state let trackSource = state.detections; if (APP.core.demo.active && APP.core.demo.data) { const demoTracks = APP.core.demo.getFrameData(0); // Get frame 0 data if (demoTracks && demoTracks.length > 0) { trackSource = demoTracks; } } // First frame radar is static - no sweep animation APP.ui.radar.render("frameRadar", trackSource, { static: true }); }; APP.ui.radar.renderLiveRadar = function () { const { state } = APP.core; // Only show tracks after Engage has been clicked (tracker running) if (!state.tracker.running) { APP.ui.radar.render("radarCanvas", [], { static: false }); return; } // Live radar has sweep animation APP.ui.radar.render("radarCanvas", state.tracker.tracks, { static: false }); };