perception2 / frontend /js /ui /radar.js
Zhen Ye
Show first frame radar when processed image appears
85ec659
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 });
};