Spaces:
Sleeping
Sleeping
| 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 }); | |
| }; | |