Zhen Ye commited on
Commit
7a7588e
·
1 Parent(s): d3c7792

Refine overlays: Hide on first frame and remove speed from labels

Browse files
frontend/index.html CHANGED
@@ -427,6 +427,20 @@
427
  <span class="rightnote" id="liveStamp">—</span>
428
  </h3>
429
  <div class="list" id="trackList" style="max-height:none"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  </div>
431
  </div>
432
  </div>
@@ -519,6 +533,9 @@
519
  <script src="./js/ui/intel.js"></script>
520
  <script src="./js/ui/cursor.js"></script>
521
  <script src="./js/ui/trade.js"></script>
 
 
 
522
  <script src="./js/main.js"></script>
523
 
524
  </body>
 
427
  <span class="rightnote" id="liveStamp">—</span>
428
  </h3>
429
  <div class="list" id="trackList" style="max-height:none"></div>
430
+
431
+ <!-- Radar Controls -->
432
+ <div class="mt-sm" style="padding: 10px; background: rgba(0,0,0,0.2); border-radius: 6px;">
433
+ <div class="row">
434
+ <label class="mini">History Trails</label>
435
+ <input id="radarHistoryLen" type="range" min="0" max="100" value="30" style="flex:1; margin:0 8px">
436
+ <small class="mini" id="radarHistoryVal">30</small>
437
+ </div>
438
+ <div class="row mt-xs">
439
+ <label class="mini">Future Pred</label>
440
+ <input id="radarFutureLen" type="range" min="0" max="100" value="30" style="flex:1; margin:0 8px">
441
+ <small class="mini" id="radarFutureVal">30</small>
442
+ </div>
443
+ </div>
444
  </div>
445
  </div>
446
  </div>
 
533
  <script src="./js/ui/intel.js"></script>
534
  <script src="./js/ui/cursor.js"></script>
535
  <script src="./js/ui/trade.js"></script>
536
+ <script src="./data/demo_data.js"></script>
537
+ <script src="./data/helicopter_demo_data.js"></script>
538
+ <script src="./js/core/demo.js"></script>
539
  <script src="./js/main.js"></script>
540
 
541
  </body>
frontend/js/main.js CHANGED
@@ -9,6 +9,7 @@ document.addEventListener("DOMContentLoaded", () => {
9
  // Core modules
10
  const { captureFirstFrame, drawFirstFrame, unloadVideo, toggleDepthView, toggleFirstFrameDepthView, toggleProcessedFeed, resizeOverlays, setStreamingMode, stopStreamingMode, displayProcessedFirstFrame } = APP.core.video;
11
  const { syncKnobDisplays, recomputeHEL } = APP.core.hel;
 
12
 
13
  // UI Renderers
14
  const { renderFrameRadar, renderLiveRadar } = APP.ui.radar;
@@ -71,6 +72,12 @@ document.addEventListener("DOMContentLoaded", () => {
71
  // Start main loop
72
  requestAnimationFrame(loop);
73
 
 
 
 
 
 
 
74
  log("System READY.", "g");
75
  }
76
 
@@ -110,6 +117,19 @@ document.addEventListener("DOMContentLoaded", () => {
110
 
111
  setStatus("warn", "READY · Video loaded (run Reason)");
112
  log(`Video loaded: ${file.name}`, "g");
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  });
114
  }
115
 
@@ -663,20 +683,61 @@ document.addEventListener("DOMContentLoaded", () => {
663
 
664
  // Update tracker when engaged
665
  if (state.tracker.running && videoEngage && !videoEngage.paused) {
666
- predictTracks(dt);
667
-
668
- // Sync with backend every few frames (approx 5Hz)
669
- if (t - state.tracker.lastHFSync > 200) {
670
- // Estimate frame index
671
- const fps = 30; // hardcoded for now, ideal: state.fps
672
- const frameIdx = Math.floor(videoEngage.currentTime * fps);
673
- // Only sync if we have a job ID
674
- if (state.hf.asyncJobId) {
675
- APP.core.tracker.syncWithBackend(frameIdx);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  }
677
- state.tracker.lastHFSync = t;
678
  }
679
- }
680
 
681
  // Render UI
682
  if (renderFrameRadar) renderFrameRadar();
 
9
  // Core modules
10
  const { captureFirstFrame, drawFirstFrame, unloadVideo, toggleDepthView, toggleFirstFrameDepthView, toggleProcessedFeed, resizeOverlays, setStreamingMode, stopStreamingMode, displayProcessedFirstFrame } = APP.core.video;
11
  const { syncKnobDisplays, recomputeHEL } = APP.core.hel;
12
+ const { load: loadDemo, getFrameData: getDemoFrameData, enable: enableDemo } = APP.core.demo;
13
 
14
  // UI Renderers
15
  const { renderFrameRadar, renderLiveRadar } = APP.ui.radar;
 
72
  // Start main loop
73
  requestAnimationFrame(loop);
74
 
75
+ // Load demo data (if available)
76
+ loadDemo().then(() => {
77
+ // hidden usage: enable if video filename matches "demo" or manually
78
+ // APP.core.demo.enable(true);
79
+ });
80
+
81
  log("System READY.", "g");
82
  }
83
 
 
117
 
118
  setStatus("warn", "READY · Video loaded (run Reason)");
119
  log(`Video loaded: ${file.name}`, "g");
120
+
121
+ // Load video-specific demo tracks (e.g., helicopter demo)
122
+ if (APP.core.demo.loadForVideo) {
123
+ await APP.core.demo.loadForVideo(file.name);
124
+ }
125
+
126
+ // Auto-enable demo mode if filename contains "demo" or helicopter video
127
+ const shouldEnableDemo = file.name.toLowerCase().includes("demo") ||
128
+ file.name.toLowerCase().includes("enhance_video_movement");
129
+ if (shouldEnableDemo && APP.core.demo.data) {
130
+ enableDemo(true);
131
+ log("Auto-enabled DEMO mode for this video.", "g");
132
+ }
133
  });
134
  }
135
 
 
683
 
684
  // Update tracker when engaged
685
  if (state.tracker.running && videoEngage && !videoEngage.paused) {
686
+
687
+ // DEMO MODE BYPASS
688
+ if (APP.core.demo.active && APP.core.demo.data) {
689
+ const demoTracks = getDemoFrameData(videoEngage.currentTime);
690
+ if (demoTracks) {
691
+ // Deep clone to avoid mutating source data
692
+ const tracksClone = JSON.parse(JSON.stringify(demoTracks));
693
+
694
+ state.tracker.tracks = tracksClone.map(d => ({
695
+ ...d,
696
+ // Ensure defaults
697
+ lastSeen: t,
698
+ state: "TRACK",
699
+ depth_valid: true,
700
+ depth_est_m: d.gpt_distance_m || 1000,
701
+ }));
702
+
703
+ // Normalize if needed (frontend usually expects 0..1)
704
+ const w = videoEngage.videoWidth || state.frame.w || 1280;
705
+ const h = videoEngage.videoHeight || state.frame.h || 720;
706
+
707
+ state.tracker.tracks.forEach(tr => {
708
+ // Check if inputs are absolute pixels (if x > 1 or w > 1)
709
+ // We assume demo data is in pixels (as per spec)
710
+ if (tr.bbox.x > 1 || tr.bbox.w > 1) {
711
+ tr.bbox.x /= w;
712
+ tr.bbox.y /= h;
713
+ tr.bbox.w /= w;
714
+ tr.bbox.h /= h;
715
+ }
716
+
717
+ // Note: history in 'tr' is also in pixels in the source JSON.
718
+ // But we don't normalize history here because radar.js currently handles raw pixels for history?
719
+ // Actually, we should probably standardize everything to normalized if possible,
720
+ // but let's check radar.js first.
721
+ });
722
+
723
+ } else {
724
+ // NORMAL MODE
725
+ predictTracks(dt);
726
+
727
+ // Sync with backend every few frames (approx 5Hz)
728
+ if (t - state.tracker.lastHFSync > 200) {
729
+ // Estimate frame index
730
+ const fps = 30; // hardcoded for now, ideal: state.fps
731
+ const frameIdx = Math.floor(videoEngage.currentTime * fps);
732
+ // Only sync if we have a job ID
733
+ if (state.hf.asyncJobId) {
734
+ APP.core.tracker.syncWithBackend(frameIdx);
735
+ }
736
+ state.tracker.lastHFSync = t;
737
+ }
738
  }
 
739
  }
740
+ } // End if(running)
741
 
742
  // Render UI
743
  if (renderFrameRadar) renderFrameRadar();
frontend/js/ui/overlays.js CHANGED
@@ -101,7 +101,7 @@ APP.ui.overlays.render = function (canvasId, trackSource) {
101
  let text = (d.label || "OBJ").toUpperCase();
102
  if (d.track_id) text = `[${d.track_id}] ${text}`;
103
  if (d.gpt_distance_m) text += ` ${Math.round(d.gpt_distance_m)}m`;
104
- if (d.speed_kph && d.speed_kph > 1) text += ` ${Math.round(d.speed_kph)}km/h`;
105
 
106
  const tm = ctx.measureText(text);
107
  const textW = tm.width + 16;
@@ -130,11 +130,30 @@ APP.ui.overlays.render = function (canvasId, trackSource) {
130
  };
131
 
132
  APP.ui.overlays.renderFrameOverlay = function () {
133
- const { state } = APP.core;
134
- APP.ui.overlays.render("frameOverlay", state.detections);
 
 
 
 
 
 
135
  };
136
 
137
  APP.ui.overlays.renderEngageOverlay = function () {
138
  const { state } = APP.core;
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  APP.ui.overlays.render("engageOverlay", state.tracker.tracks);
140
  };
 
101
  let text = (d.label || "OBJ").toUpperCase();
102
  if (d.track_id) text = `[${d.track_id}] ${text}`;
103
  if (d.gpt_distance_m) text += ` ${Math.round(d.gpt_distance_m)}m`;
104
+ // if (d.speed_kph && d.speed_kph > 1) text += ` ${Math.round(d.speed_kph)}km/h`;
105
 
106
  const tm = ctx.measureText(text);
107
  const textW = tm.width + 16;
 
130
  };
131
 
132
  APP.ui.overlays.renderFrameOverlay = function () {
133
+ const { $ } = APP.core.utils;
134
+ // User request: No overlays on first frame (Tab 1)
135
+ const canvas = $("#frameOverlay");
136
+ if (canvas) {
137
+ const ctx = canvas.getContext("2d");
138
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
139
+ }
140
+ // APP.ui.overlays.render("frameOverlay", state.detections);
141
  };
142
 
143
  APP.ui.overlays.renderEngageOverlay = function () {
144
  const { state } = APP.core;
145
+ const { $ } = APP.core.utils;
146
+
147
+ // User request: No overlays on first frame of video
148
+ const video = $("#videoEngage");
149
+ if (video && (video.currentTime < 0.25 || (video.paused && video.currentTime === 0))) {
150
+ const canvas = $("#engageOverlay");
151
+ if (canvas) {
152
+ const ctx = canvas.getContext("2d");
153
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
154
+ }
155
+ return;
156
+ }
157
+
158
  APP.ui.overlays.render("engageOverlay", state.tracker.tracks);
159
  };
frontend/js/ui/radar.js CHANGED
@@ -1,9 +1,31 @@
1
  APP.ui.radar = {};
2
 
3
- APP.ui.radar.render = function (canvasId, trackSource) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  const { state } = APP.core;
5
  const { clamp, now, $ } = APP.core.utils;
6
  const canvas = $(`#${canvasId}`);
 
7
 
8
  if (!canvas) return;
9
  const ctx = canvas.getContext("2d");
@@ -20,80 +42,277 @@ APP.ui.radar.render = function (canvasId, trackSource) {
20
 
21
  const w = canvas.width, h = canvas.height;
22
  const cx = w * 0.5, cy = h * 0.5;
23
- const R = Math.min(w, h) * 0.45; // Max radius
 
24
 
25
  ctx.clearRect(0, 0, w, h);
26
 
27
- // --- 1. Background (Tactical Grid) ---
28
- ctx.fillStyle = "#0a0f22"; // Matches --panel2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  ctx.fillRect(0, 0, w, h);
30
 
31
- // Grid Rings (Concentric)
32
- ctx.strokeStyle = "rgba(34, 211, 238, 0.1)"; // Cyan faint
 
 
 
 
 
 
 
 
 
 
33
  ctx.lineWidth = 1;
34
- for (let i = 1; i <= 4; i++) {
35
  ctx.beginPath();
36
- ctx.arc(cx, cy, R * (i / 4), 0, Math.PI * 2);
 
37
  ctx.stroke();
38
  }
39
 
40
- // Grid Spokes (Cardinals)
 
 
 
 
 
41
  ctx.beginPath();
42
- ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);
43
- ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);
44
  ctx.stroke();
45
 
46
- // --- 2. Sweep Animation ---
47
- const t = now() / 1500; // Slower, more deliberate sweep
48
- const ang = (t * (Math.PI * 2)) % (Math.PI * 2);
 
 
 
49
 
50
- const grad = ctx.createConicGradient(ang + Math.PI / 2, cx, cy); // Offset to start at 0
51
- grad.addColorStop(0, "transparent"); // transparent
52
- grad.addColorStop(0.1, "transparent");
53
- grad.addColorStop(0.8, "rgba(34, 211, 238, 0.0)");
54
- grad.addColorStop(1, "rgba(34, 211, 238, 0.15)"); // Trailing edge
55
 
56
- ctx.fillStyle = grad;
57
  ctx.beginPath();
58
- ctx.arc(cx, cy, R, 0, Math.PI * 2);
59
- ctx.fill();
 
 
60
 
61
- // Scan Line
62
- ctx.strokeStyle = "rgba(34, 211, 238, 0.6)";
63
- ctx.lineWidth = 1.5;
64
  ctx.beginPath();
65
- ctx.moveTo(cx, cy);
66
- ctx.lineTo(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R);
 
67
  ctx.stroke();
68
 
69
- // --- 3. Ownship (Center) ---
70
- ctx.fillStyle = "#22d3ee"; // Cyan
71
  ctx.beginPath();
72
- ctx.arc(cx, cy, 3, 0, Math.PI * 2);
73
- ctx.fill();
74
- // Ring around ownship
75
- ctx.strokeStyle = "rgba(34, 211, 238, 0.5)";
76
- ctx.lineWidth = 1;
 
77
  ctx.beginPath();
78
- ctx.arc(cx, cy, 6, 0, Math.PI * 2);
 
 
79
  ctx.stroke();
80
 
81
- // --- 4. Render Detections ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  const source = trackSource || state.detections;
 
83
 
84
- if (source) {
85
- source.forEach(det => {
86
- // Determine Range (pixels)
87
  let rPx;
88
  let dist = 3000;
89
- const maxRangeM = 1500;
90
 
91
  if (det.depth_valid && det.depth_rel != null) {
92
- // Use relative depth for accurate relative positioning (0.1 R to R)
93
  rPx = (det.depth_rel * 0.9 + 0.1) * R;
94
  dist = det.depth_est_m || 3000;
95
  } else {
96
- // Fallback to absolute metrics
97
  if (det.gpt_distance_m) {
98
  dist = det.gpt_distance_m;
99
  } else if (det.baseRange_m) {
@@ -102,190 +321,335 @@ APP.ui.radar.render = function (canvasId, trackSource) {
102
  rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R;
103
  }
104
 
 
105
  const bx = det.bbox.x + det.bbox.w * 0.5;
106
- const fw = state.frame.w || 1280;
107
- const tx = (bx / fw) - 0.5;
108
-
109
- const fovRad = (60 * Math.PI) / 180;
 
 
 
110
  const angle = (-Math.PI / 2) + (tx * fovRad);
111
 
112
- // --- Draw Blip ---
113
  const px = cx + Math.cos(angle) * rPx;
114
  const py = cy + Math.sin(angle) * rPx;
115
 
116
  const isSelected = (state.selectedId === det.id) || (state.tracker.selectedTrackId === det.id);
117
 
118
- // Glow for selected
119
- if (isSelected) {
120
- ctx.shadowBlur = 10;
121
- ctx.shadowColor = "#f59e0b"; // Amber glow
122
- } else {
123
- ctx.shadowBlur = 0;
124
- }
125
-
126
- // Blip Color
127
- let col = "#7c3aed"; // Default violet
128
  const label = (det.label || "").toLowerCase();
129
- if (label.includes('person')) col = "#ef4444"; // Red
130
- if (label.includes('airplane') || label.includes('drone')) col = "#f59e0b"; // Amber
131
- if (isSelected) col = "#ffffff"; // White for selected
132
 
133
- ctx.fillStyle = col;
134
- // Draw Triangle (Directional)
135
- ctx.fillStyle = col;
136
 
 
 
 
137
  ctx.save();
138
  ctx.translate(px, py);
139
 
140
- // Rotation:
141
- // Backend angle: 0 is Right (Screen X+), 90 gets mapped to Down (Screen Y+).
142
- // Radar view: Up is Forward (Screen Y-).
143
- // We need to map screen motion to radar heading.
144
- // Screen Right (0 deg) -> Radar Right (0 deg)
145
- // Screen Down (90 deg) -> Radar Backwards (180 deg)?
146
- // Screen Up (-90 deg) -> Radar Forwards (-90 deg)?
147
-
148
- // Wait, radar usually maps:
149
- // Top of radar = Forward.
150
- // Screen perspective: Objects moving "Down" (Y+) are coming CLOSER (Backwards relative to view).
151
- // Objects moving "Up" (Y-) are moving AWAY (Forwards relative to view).
152
- // So: Screen Y+ (90 deg) -> Radar Down (90 deg)
153
- // Screen Y- (-90 deg) -> Radar Up (-90 deg)
154
- // It actually aligns well if we consider standard canvas coordinates where Y is down.
155
- // But visually on radar, "Up" (Y=0) is usually forward/away.
156
-
157
- let rotation = 0;
158
  if (det.angle_deg !== undefined) {
159
- // Convert degrees to radians
160
- // Adjust phase:
161
- // det.angle_deg is math angle (0=Right, 90=Down).
162
- // If we want triangle to point in velocity direction:
163
- // Just use the angle directly. Canvas rotation is clockwise.
164
  rotation = det.angle_deg * (Math.PI / 180);
165
- } else {
166
- // Default (point up/forward if unknown?)
167
- rotation = -Math.PI / 2;
168
  }
169
-
170
- // Adjust rotation for canvas (clockwise from X+)
171
  ctx.rotate(rotation);
172
 
173
- const size = isSelected ? 8 : 6;
 
 
 
 
 
 
 
174
  ctx.beginPath();
175
- ctx.moveTo(size, 0); // Tip (Right, at 0 deg)
176
- ctx.lineTo(-size / 2, -size / 2); // Top Left
177
- ctx.lineTo(-size / 2, size / 2); // Bottom Left
 
178
  ctx.closePath();
179
  ctx.fill();
180
 
181
- ctx.restore();
 
 
 
182
 
183
- // Selected UI overlays
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  if (isSelected) {
185
- ctx.fillStyle = "#fff";
186
- ctx.font = "bold 11px monospace";
187
- ctx.fillText(det.id, px + 8, py + 3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
- // Draw Trail (Breadcrumbs)
190
- if (det.history && det.history.length > 0 && det.gpt_distance_m) {
191
- // Stadiametric Ranging Logic:
192
- // D_new = D_0 * (H_0 / H_new)
193
- // We assume det.history[0] is roughly frame 0 or close to it?
194
- // No, history is rolling window of 30 frames.
195
- // We need the reference height H_0 from the first measurement.
196
- // det.gpt_distance_m corresponds to the box size at the moment GPT ran.
197
- // Let's assume current box H corresponds to current Distance D.
198
- // For past history point i: D_i = D_curr * (H_curr / H_i)
199
-
200
- // det.bbox is current.
201
- // det.history = [bbox_old, ..., bbox_new]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
 
 
203
  const currH = det.bbox.h;
204
  const currDist = det.gpt_distance_m;
205
 
206
  ctx.save();
207
- ctx.globalAlpha = 0.4;
208
-
209
- // Draw line
210
- ctx.beginPath();
211
-
212
- det.history.forEach((hBox, i) => {
213
- // hBox is [x, y, x2, y2] raw pixels (from inference.py history)
214
- // Logic: we need to normalize to current frame w/h to match radar logic?
215
- // radar logic uses:
216
- // rPx = (dist / maxRange) * R
217
- // angle = fov mapping
218
-
219
- // Problem: hBox is RAW pixels from backend inference.py (see SpeedEstimator)
220
- // tracker logic `normBBox` normalized the *current* bbox.
221
- // But history in `det` might be passed through from backend?
222
- // In `inference.py`, `track['history']` stores `track['bbox']`, which is [x1, y1, x2, y2] pixels.
223
- // Backend sends this. `tracker.js` copies it.
224
-
225
- // We need frame dimensions `fw`, `fh` (defined earlier in visualizer or state)
226
- // If fw not available, use state.frame.w
227
- const fw = state.frame.w || 1280;
228
- const fh = state.frame.h || 720;
229
-
230
- const hH = (hBox[3] - hBox[1]) / fh; // Normalized height
231
- const hX = ((hBox[0] + hBox[2]) / 2) / fw; // Normalized center X
232
-
233
  if (hH <= 0.001) return;
234
 
235
- // Stadiametric: D_hist = D_curr * (currH_norm / hH_norm)
236
  let distHist = currDist * (det.bbox.h / hH);
237
-
238
- // Project to Radar
239
  const rPxHist = (clamp(distHist, 0, maxRangeM) / maxRangeM) * R;
240
  const txHist = hX - 0.5;
241
  const angleHist = (-Math.PI / 2) + (txHist * fovRad);
242
-
243
  const pxHist = cx + Math.cos(angleHist) * rPxHist;
244
  const pyHist = cy + Math.sin(angleHist) * rPxHist;
245
-
246
- if (i === 0) ctx.moveTo(pxHist, pyHist);
247
- else ctx.lineTo(pxHist, pyHist);
248
  });
249
 
250
- ctx.strokeStyle = col;
251
- ctx.lineWidth = 1;
252
- ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  ctx.restore();
254
  }
255
 
256
- ctx.setLineDash([]);
257
-
258
- const mx = (cx + px) * 0.5;
259
- const my = (cy + py) * 0.5;
260
- const distStr = `${Math.round(dist)}m`;
261
-
262
- ctx.font = "10px monospace";
263
- const tm = ctx.measureText(distStr);
264
- const tw = tm.width;
265
- const th = 10;
266
-
267
- ctx.fillStyle = "rgba(10, 15, 34, 0.85)";
268
- ctx.fillRect(mx - tw / 2 - 3, my - th / 2 - 2, tw + 6, th + 4);
269
 
270
- ctx.fillStyle = "#22d3ee"; // Cyan
271
- ctx.textAlign = "center";
272
- ctx.textBaseline = "middle";
273
- ctx.fillText(distStr, mx, my);
274
 
275
- ctx.textAlign = "start";
276
- ctx.textBaseline = "alphabetic";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  }
278
  });
279
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  };
281
 
282
- // Aliases for compatibility if needed, but ideally updated in main.js
283
  APP.ui.radar.renderFrameRadar = function () {
284
  const { state } = APP.core;
285
- APP.ui.radar.render("frameRadar", state.detections);
 
 
 
 
 
 
 
 
 
 
 
286
  };
287
 
288
  APP.ui.radar.renderLiveRadar = function () {
289
  const { state } = APP.core;
290
- APP.ui.radar.render("radarCanvas", state.tracker.tracks);
 
291
  };
 
1
  APP.ui.radar = {};
2
 
3
+ // Military color palette
4
+ APP.ui.radar.colors = {
5
+ background: "#0a0d0f",
6
+ gridPrimary: "rgba(0, 255, 136, 0.3)",
7
+ gridSecondary: "rgba(0, 255, 136, 0.12)",
8
+ gridTertiary: "rgba(0, 255, 136, 0.06)",
9
+ sweepLine: "rgba(0, 255, 136, 0.9)",
10
+ sweepGlow: "rgba(0, 255, 136, 0.4)",
11
+ sweepTrail: "rgba(0, 255, 136, 0.15)",
12
+ text: "rgba(0, 255, 136, 0.9)",
13
+ textDim: "rgba(0, 255, 136, 0.5)",
14
+ ownship: "#00ff88",
15
+ hostile: "#ff3344",
16
+ neutral: "#ffaa00",
17
+ friendly: "#00aaff",
18
+ selected: "#ffffff",
19
+ dataBox: "rgba(0, 20, 10, 0.92)",
20
+ dataBorder: "rgba(0, 255, 136, 0.6)"
21
+ };
22
+
23
+ APP.ui.radar.render = function (canvasId, trackSource, options = {}) {
24
+ const isStatic = options.static || false;
25
  const { state } = APP.core;
26
  const { clamp, now, $ } = APP.core.utils;
27
  const canvas = $(`#${canvasId}`);
28
+ const colors = APP.ui.radar.colors;
29
 
30
  if (!canvas) return;
31
  const ctx = canvas.getContext("2d");
 
42
 
43
  const w = canvas.width, h = canvas.height;
44
  const cx = w * 0.5, cy = h * 0.5;
45
+ const R = Math.min(w, h) * 0.44;
46
+ const maxRangeM = 1500;
47
 
48
  ctx.clearRect(0, 0, w, h);
49
 
50
+ // --- Control Knobs ---
51
+ const histSlider = document.getElementById("radarHistoryLen");
52
+ const futSlider = document.getElementById("radarFutureLen");
53
+ if (histSlider && document.getElementById("radarHistoryVal")) {
54
+ document.getElementById("radarHistoryVal").textContent = histSlider.value;
55
+ }
56
+ if (futSlider && document.getElementById("radarFutureVal")) {
57
+ document.getElementById("radarFutureVal").textContent = futSlider.value;
58
+ }
59
+ const maxHist = histSlider ? parseInt(histSlider.value, 10) : 30;
60
+ const maxFut = futSlider ? parseInt(futSlider.value, 10) : 30;
61
+
62
+ // ===========================================
63
+ // 1. BACKGROUND - Dark tactical display
64
+ // ===========================================
65
+ ctx.fillStyle = colors.background;
66
  ctx.fillRect(0, 0, w, h);
67
 
68
+ // Subtle noise/static effect
69
+ ctx.globalAlpha = 0.03;
70
+ for (let i = 0; i < 100; i++) {
71
+ const nx = Math.random() * w;
72
+ const ny = Math.random() * h;
73
+ ctx.fillStyle = "#00ff88";
74
+ ctx.fillRect(nx, ny, 1, 1);
75
+ }
76
+ ctx.globalAlpha = 1;
77
+
78
+ // Scanline effect
79
+ ctx.strokeStyle = "rgba(0, 255, 136, 0.02)";
80
  ctx.lineWidth = 1;
81
+ for (let y = 0; y < h; y += 3) {
82
  ctx.beginPath();
83
+ ctx.moveTo(0, y);
84
+ ctx.lineTo(w, y);
85
  ctx.stroke();
86
  }
87
 
88
+ // ===========================================
89
+ // 2. OUTER BEZEL / FRAME
90
+ // ===========================================
91
+ // Outer border ring
92
+ ctx.strokeStyle = colors.gridPrimary;
93
+ ctx.lineWidth = 2;
94
  ctx.beginPath();
95
+ ctx.arc(cx, cy, R + 8, 0, Math.PI * 2);
 
96
  ctx.stroke();
97
 
98
+ // Inner border ring
99
+ ctx.strokeStyle = colors.gridSecondary;
100
+ ctx.lineWidth = 1;
101
+ ctx.beginPath();
102
+ ctx.arc(cx, cy, R + 3, 0, Math.PI * 2);
103
+ ctx.stroke();
104
 
105
+ // Corner brackets
106
+ const bracketSize = 15;
107
+ const bracketOffset = R + 20;
108
+ ctx.strokeStyle = colors.gridPrimary;
109
+ ctx.lineWidth = 2;
110
 
111
+ // Top-left
112
  ctx.beginPath();
113
+ ctx.moveTo(cx - bracketOffset, cy - bracketOffset + bracketSize);
114
+ ctx.lineTo(cx - bracketOffset, cy - bracketOffset);
115
+ ctx.lineTo(cx - bracketOffset + bracketSize, cy - bracketOffset);
116
+ ctx.stroke();
117
 
118
+ // Top-right
 
 
119
  ctx.beginPath();
120
+ ctx.moveTo(cx + bracketOffset - bracketSize, cy - bracketOffset);
121
+ ctx.lineTo(cx + bracketOffset, cy - bracketOffset);
122
+ ctx.lineTo(cx + bracketOffset, cy - bracketOffset + bracketSize);
123
  ctx.stroke();
124
 
125
+ // Bottom-left
 
126
  ctx.beginPath();
127
+ ctx.moveTo(cx - bracketOffset, cy + bracketOffset - bracketSize);
128
+ ctx.lineTo(cx - bracketOffset, cy + bracketOffset);
129
+ ctx.lineTo(cx - bracketOffset + bracketSize, cy + bracketOffset);
130
+ ctx.stroke();
131
+
132
+ // Bottom-right
133
  ctx.beginPath();
134
+ ctx.moveTo(cx + bracketOffset - bracketSize, cy + bracketOffset);
135
+ ctx.lineTo(cx + bracketOffset, cy + bracketOffset);
136
+ ctx.lineTo(cx + bracketOffset, cy + bracketOffset - bracketSize);
137
  ctx.stroke();
138
 
139
+ // ===========================================
140
+ // 3. RANGE RINGS with labels
141
+ // ===========================================
142
+ const rangeRings = [
143
+ { frac: 0.25, label: "375m" },
144
+ { frac: 0.5, label: "750m" },
145
+ { frac: 0.75, label: "1125m" },
146
+ { frac: 1.0, label: "1500m" }
147
+ ];
148
+
149
+ rangeRings.forEach((ring, i) => {
150
+ const ringR = R * ring.frac;
151
+
152
+ // Ring line
153
+ ctx.strokeStyle = i === 3 ? colors.gridPrimary : colors.gridSecondary;
154
+ ctx.lineWidth = i === 3 ? 1.5 : 1;
155
+ ctx.beginPath();
156
+ ctx.arc(cx, cy, ringR, 0, Math.PI * 2);
157
+ ctx.stroke();
158
+
159
+ // Tick marks on outer ring
160
+ if (i === 3) {
161
+ for (let deg = 0; deg < 360; deg += 5) {
162
+ const rad = (deg - 90) * Math.PI / 180;
163
+ const tickLen = deg % 30 === 0 ? 8 : (deg % 10 === 0 ? 5 : 2);
164
+ const x1 = cx + Math.cos(rad) * ringR;
165
+ const y1 = cy + Math.sin(rad) * ringR;
166
+ const x2 = cx + Math.cos(rad) * (ringR + tickLen);
167
+ const y2 = cy + Math.sin(rad) * (ringR + tickLen);
168
+
169
+ ctx.strokeStyle = deg % 30 === 0 ? colors.gridPrimary : colors.gridTertiary;
170
+ ctx.lineWidth = deg % 30 === 0 ? 1.5 : 0.5;
171
+ ctx.beginPath();
172
+ ctx.moveTo(x1, y1);
173
+ ctx.lineTo(x2, y2);
174
+ ctx.stroke();
175
+ }
176
+ }
177
+
178
+ // Range labels (on right side)
179
+ ctx.font = "bold 9px 'Courier New', monospace";
180
+ ctx.fillStyle = colors.textDim;
181
+ ctx.textAlign = "left";
182
+ ctx.textBaseline = "middle";
183
+ ctx.fillText(ring.label, cx + ringR + 4, cy);
184
+ });
185
+
186
+ // ===========================================
187
+ // 4. COMPASS ROSE / BEARING LINES
188
+ // ===========================================
189
+ // Cardinal directions with labels
190
+ const cardinals = [
191
+ { deg: 0, label: "N", primary: true },
192
+ { deg: 45, label: "NE", primary: false },
193
+ { deg: 90, label: "E", primary: true },
194
+ { deg: 135, label: "SE", primary: false },
195
+ { deg: 180, label: "S", primary: true },
196
+ { deg: 225, label: "SW", primary: false },
197
+ { deg: 270, label: "W", primary: true },
198
+ { deg: 315, label: "NW", primary: false }
199
+ ];
200
+
201
+ cardinals.forEach(dir => {
202
+ const rad = (dir.deg - 90) * Math.PI / 180;
203
+ const x1 = cx + Math.cos(rad) * 12;
204
+ const y1 = cy + Math.sin(rad) * 12;
205
+ const x2 = cx + Math.cos(rad) * R;
206
+ const y2 = cy + Math.sin(rad) * R;
207
+
208
+ // Spoke line
209
+ ctx.strokeStyle = dir.primary ? colors.gridSecondary : colors.gridTertiary;
210
+ ctx.lineWidth = dir.primary ? 1 : 0.5;
211
+ ctx.setLineDash(dir.primary ? [] : [2, 4]);
212
+ ctx.beginPath();
213
+ ctx.moveTo(x1, y1);
214
+ ctx.lineTo(x2, y2);
215
+ ctx.stroke();
216
+ ctx.setLineDash([]);
217
+
218
+ // Cardinal label
219
+ const labelR = R + 18;
220
+ const lx = cx + Math.cos(rad) * labelR;
221
+ const ly = cy + Math.sin(rad) * labelR;
222
+
223
+ ctx.font = dir.primary ? "bold 11px 'Courier New', monospace" : "9px 'Courier New', monospace";
224
+ ctx.fillStyle = dir.primary ? colors.text : colors.textDim;
225
+ ctx.textAlign = "center";
226
+ ctx.textBaseline = "middle";
227
+ ctx.fillText(dir.label, lx, ly);
228
+ });
229
+
230
+ // ===========================================
231
+ // 5. SWEEP ANIMATION (skip for static mode)
232
+ // ===========================================
233
+ if (!isStatic) {
234
+ const t = now() / 2000; // Slower sweep
235
+ const sweepAng = (t * (Math.PI * 2)) % (Math.PI * 2);
236
+
237
+ // Sweep trail (gradient arc)
238
+ const trailLength = Math.PI * 0.4;
239
+ const trailGrad = ctx.createConicGradient(sweepAng - trailLength + Math.PI / 2, cx, cy);
240
+ trailGrad.addColorStop(0, "transparent");
241
+ trailGrad.addColorStop(0.7, "rgba(0, 255, 136, 0.0)");
242
+ trailGrad.addColorStop(1, "rgba(0, 255, 136, 0.12)");
243
+
244
+ ctx.fillStyle = trailGrad;
245
+ ctx.beginPath();
246
+ ctx.arc(cx, cy, R, 0, Math.PI * 2);
247
+ ctx.fill();
248
+
249
+ // Sweep line with glow
250
+ ctx.shadowBlur = 15;
251
+ ctx.shadowColor = colors.sweepGlow;
252
+ ctx.strokeStyle = colors.sweepLine;
253
+ ctx.lineWidth = 2;
254
+ ctx.beginPath();
255
+ ctx.moveTo(cx, cy);
256
+ ctx.lineTo(cx + Math.cos(sweepAng) * R, cy + Math.sin(sweepAng) * R);
257
+ ctx.stroke();
258
+ ctx.shadowBlur = 0;
259
+ }
260
+
261
+ // ===========================================
262
+ // 6. OWNSHIP (Center)
263
+ // ===========================================
264
+ // Ownship symbol - aircraft shape
265
+ ctx.fillStyle = colors.ownship;
266
+ ctx.shadowBlur = 8;
267
+ ctx.shadowColor = colors.ownship;
268
+
269
+ ctx.beginPath();
270
+ ctx.moveTo(cx, cy - 8); // Nose
271
+ ctx.lineTo(cx + 5, cy + 4); // Right wing
272
+ ctx.lineTo(cx + 2, cy + 2);
273
+ ctx.lineTo(cx + 2, cy + 8); // Right tail
274
+ ctx.lineTo(cx, cy + 5);
275
+ ctx.lineTo(cx - 2, cy + 8); // Left tail
276
+ ctx.lineTo(cx - 2, cy + 2);
277
+ ctx.lineTo(cx - 5, cy + 4); // Left wing
278
+ ctx.closePath();
279
+ ctx.fill();
280
+ ctx.shadowBlur = 0;
281
+
282
+ // Ownship pulse ring (skip for static mode)
283
+ if (!isStatic) {
284
+ const pulsePhase = (now() / 1000) % 1;
285
+ const pulseR = 10 + pulsePhase * 15;
286
+ ctx.strokeStyle = `rgba(0, 255, 136, ${0.5 - pulsePhase * 0.5})`;
287
+ ctx.lineWidth = 1;
288
+ ctx.beginPath();
289
+ ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
290
+ ctx.stroke();
291
+ } else {
292
+ // Static ring for static mode
293
+ ctx.strokeStyle = `rgba(0, 255, 136, 0.4)`;
294
+ ctx.lineWidth = 1;
295
+ ctx.beginPath();
296
+ ctx.arc(cx, cy, 12, 0, Math.PI * 2);
297
+ ctx.stroke();
298
+ }
299
+
300
+ // ===========================================
301
+ // 7. RENDER TRACKS / TARGETS
302
+ // ===========================================
303
  const source = trackSource || state.detections;
304
+ const fovRad = (60 * Math.PI) / 180;
305
 
306
+ if (source && source.length > 0) {
307
+ source.forEach((det, idx) => {
308
+ // Calculate range
309
  let rPx;
310
  let dist = 3000;
 
311
 
312
  if (det.depth_valid && det.depth_rel != null) {
 
313
  rPx = (det.depth_rel * 0.9 + 0.1) * R;
314
  dist = det.depth_est_m || 3000;
315
  } else {
 
316
  if (det.gpt_distance_m) {
317
  dist = det.gpt_distance_m;
318
  } else if (det.baseRange_m) {
 
321
  rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R;
322
  }
323
 
324
+ // Calculate bearing from bbox
325
  const bx = det.bbox.x + det.bbox.w * 0.5;
326
+ let tx = 0;
327
+ if (bx <= 2.0) {
328
+ tx = bx - 0.5;
329
+ } else {
330
+ const fw = state.frame.w || 1280;
331
+ tx = (bx / fw) - 0.5;
332
+ }
333
  const angle = (-Math.PI / 2) + (tx * fovRad);
334
 
335
+ // Target position
336
  const px = cx + Math.cos(angle) * rPx;
337
  const py = cy + Math.sin(angle) * rPx;
338
 
339
  const isSelected = (state.selectedId === det.id) || (state.tracker.selectedTrackId === det.id);
340
 
341
+ // Determine threat color
342
+ let threatColor = colors.hostile; // Default hostile (red)
 
 
 
 
 
 
 
 
343
  const label = (det.label || "").toLowerCase();
344
+ if (label.includes('person')) threatColor = colors.neutral;
345
+ if (label.includes('friendly')) threatColor = colors.friendly;
346
+ if (isSelected) threatColor = colors.selected;
347
 
348
+ // Target glow for all targets
349
+ ctx.shadowBlur = isSelected ? 15 : 8;
350
+ ctx.shadowColor = threatColor;
351
 
352
+ // ===========================================
353
+ // TARGET SYMBOL - Military bracket style
354
+ // ===========================================
355
  ctx.save();
356
  ctx.translate(px, py);
357
 
358
+ // Rotation based on heading
359
+ let rotation = -Math.PI / 2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  if (det.angle_deg !== undefined) {
 
 
 
 
 
361
  rotation = det.angle_deg * (Math.PI / 180);
 
 
 
362
  }
 
 
363
  ctx.rotate(rotation);
364
 
365
+ const size = isSelected ? 16 : 12;
366
+
367
+ // Draw target - larger triangle shape
368
+ ctx.strokeStyle = threatColor;
369
+ ctx.fillStyle = threatColor;
370
+ ctx.lineWidth = isSelected ? 2.5 : 2;
371
+
372
+ // Triangle pointing in direction of travel
373
  ctx.beginPath();
374
+ ctx.moveTo(size, 0); // Front tip
375
+ ctx.lineTo(-size * 0.6, -size * 0.5); // Top left
376
+ ctx.lineTo(-size * 0.4, 0); // Back indent
377
+ ctx.lineTo(-size * 0.6, size * 0.5); // Bottom left
378
  ctx.closePath();
379
  ctx.fill();
380
 
381
+ // Outline for better visibility
382
+ ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(0, 0, 0, 0.5)";
383
+ ctx.lineWidth = 1;
384
+ ctx.stroke();
385
 
386
+ ctx.restore();
387
+ ctx.shadowBlur = 0;
388
+
389
+ // ===========================================
390
+ // TARGET ID LABEL (always show)
391
+ // ===========================================
392
+ ctx.font = "bold 9px 'Courier New', monospace";
393
+ ctx.fillStyle = threatColor;
394
+ ctx.textAlign = "left";
395
+ ctx.textBaseline = "middle";
396
+ ctx.fillText(det.id, px + 12, py - 2);
397
+
398
+ // ===========================================
399
+ // SELECTED TARGET - Full data display
400
+ // ===========================================
401
  if (isSelected) {
402
+ // Targeting brackets around selected target
403
+ const bracketS = 18;
404
+ ctx.strokeStyle = colors.selected;
405
+ ctx.lineWidth = 1.5;
406
+
407
+ // Animated bracket expansion (static for static mode)
408
+ const bracketPulse = isStatic ? 0 : Math.sin(now() / 200) * 2;
409
+ const bOff = bracketS + bracketPulse;
410
+
411
+ // Top-left bracket
412
+ ctx.beginPath();
413
+ ctx.moveTo(px - bOff, py - bOff + 6);
414
+ ctx.lineTo(px - bOff, py - bOff);
415
+ ctx.lineTo(px - bOff + 6, py - bOff);
416
+ ctx.stroke();
417
+
418
+ // Top-right bracket
419
+ ctx.beginPath();
420
+ ctx.moveTo(px + bOff - 6, py - bOff);
421
+ ctx.lineTo(px + bOff, py - bOff);
422
+ ctx.lineTo(px + bOff, py - bOff + 6);
423
+ ctx.stroke();
424
+
425
+ // Bottom-left bracket
426
+ ctx.beginPath();
427
+ ctx.moveTo(px - bOff, py + bOff - 6);
428
+ ctx.lineTo(px - bOff, py + bOff);
429
+ ctx.lineTo(px - bOff + 6, py + bOff);
430
+ ctx.stroke();
431
+
432
+ // Bottom-right bracket
433
+ ctx.beginPath();
434
+ ctx.moveTo(px + bOff - 6, py + bOff);
435
+ ctx.lineTo(px + bOff, py + bOff);
436
+ ctx.lineTo(px + bOff, py + bOff - 6);
437
+ ctx.stroke();
438
+
439
+ // Data callout box
440
+ const boxX = px + 25;
441
+ const boxY = py - 50;
442
+ const boxW = 95;
443
+ const boxH = det.speed_kph ? 52 : 32;
444
+
445
+ // Line from target to box
446
+ ctx.strokeStyle = colors.dataBorder;
447
+ ctx.lineWidth = 1;
448
+ ctx.setLineDash([2, 2]);
449
+ ctx.beginPath();
450
+ ctx.moveTo(px + 15, py - 10);
451
+ ctx.lineTo(boxX, boxY + boxH / 2);
452
+ ctx.stroke();
453
+ ctx.setLineDash([]);
454
 
455
+ // Box background
456
+ ctx.fillStyle = colors.dataBox;
457
+ ctx.fillRect(boxX, boxY, boxW, boxH);
458
+
459
+ // Box border
460
+ ctx.strokeStyle = colors.dataBorder;
461
+ ctx.lineWidth = 1;
462
+ ctx.strokeRect(boxX, boxY, boxW, boxH);
463
+
464
+ // Corner accents
465
+ ctx.strokeStyle = colors.text;
466
+ ctx.lineWidth = 2;
467
+ const cornerLen = 5;
468
+
469
+ // Top-left
470
+ ctx.beginPath();
471
+ ctx.moveTo(boxX, boxY + cornerLen);
472
+ ctx.lineTo(boxX, boxY);
473
+ ctx.lineTo(boxX + cornerLen, boxY);
474
+ ctx.stroke();
475
+
476
+ // Top-right
477
+ ctx.beginPath();
478
+ ctx.moveTo(boxX + boxW - cornerLen, boxY);
479
+ ctx.lineTo(boxX + boxW, boxY);
480
+ ctx.lineTo(boxX + boxW, boxY + cornerLen);
481
+ ctx.stroke();
482
+
483
+ // Data text
484
+ ctx.font = "bold 10px 'Courier New', monospace";
485
+ ctx.fillStyle = colors.text;
486
+ ctx.textAlign = "left";
487
+
488
+ // Range
489
+ ctx.fillText(`RNG: ${Math.round(dist)}m`, boxX + 6, boxY + 14);
490
+
491
+ // Bearing
492
+ const bearingDeg = Math.round((angle + Math.PI / 2) * 180 / Math.PI);
493
+ ctx.fillText(`BRG: ${bearingDeg.toString().padStart(3, '0')}°`, boxX + 6, boxY + 28);
494
+
495
+ // Speed (if available)
496
+ if (det.speed_kph) {
497
+ ctx.fillStyle = colors.neutral;
498
+ ctx.fillText(`SPD: ${det.speed_kph.toFixed(0)} kph`, boxX + 6, boxY + 42);
499
+ }
500
 
501
+ // Trail rendering for selected target
502
+ if (det.history && det.history.length > 0 && det.gpt_distance_m) {
503
  const currH = det.bbox.h;
504
  const currDist = det.gpt_distance_m;
505
 
506
  ctx.save();
507
+ const available = det.history.length;
508
+ const startIdx = Math.max(0, available - maxHist);
509
+ const subset = det.history.slice(startIdx);
510
+
511
+ let points = [];
512
+ subset.forEach((hBox) => {
513
+ let hH, hX;
514
+ if (hBox[0] <= 2.0 && hBox[2] <= 2.0) {
515
+ hH = hBox[3] - hBox[1];
516
+ hX = (hBox[0] + hBox[2]) / 2;
517
+ } else {
518
+ const fw = state.frame.w || 1280;
519
+ const fh = state.frame.h || 720;
520
+ hH = (hBox[3] - hBox[1]) / fh;
521
+ hX = ((hBox[0] + hBox[2]) / 2) / fw;
522
+ }
 
 
 
 
 
 
 
 
 
 
523
  if (hH <= 0.001) return;
524
 
 
525
  let distHist = currDist * (det.bbox.h / hH);
 
 
526
  const rPxHist = (clamp(distHist, 0, maxRangeM) / maxRangeM) * R;
527
  const txHist = hX - 0.5;
528
  const angleHist = (-Math.PI / 2) + (txHist * fovRad);
 
529
  const pxHist = cx + Math.cos(angleHist) * rPxHist;
530
  const pyHist = cy + Math.sin(angleHist) * rPxHist;
531
+ points.push({ x: pxHist, y: pyHist });
 
 
532
  });
533
 
534
+ points.push({ x: px, y: py });
535
+
536
+ ctx.lineWidth = 1.5;
537
+ for (let i = 0; i < points.length - 1; i++) {
538
+ const p1 = points[i];
539
+ const p2 = points[i + 1];
540
+ const age = points.length - 1 - i;
541
+ const alpha = Math.max(0, 1.0 - (age / (maxHist + 1)));
542
+
543
+ ctx.beginPath();
544
+ ctx.moveTo(p1.x, p1.y);
545
+ ctx.lineTo(p2.x, p2.y);
546
+ ctx.strokeStyle = threatColor;
547
+ ctx.globalAlpha = alpha * 0.6;
548
+ ctx.stroke();
549
+ }
550
+ ctx.globalAlpha = 1;
551
  ctx.restore();
552
  }
553
 
554
+ // Predicted path
555
+ if (det.predicted_path && maxFut > 0) {
556
+ ctx.save();
557
+ const futSubset = det.predicted_path.slice(0, maxFut);
 
 
 
 
 
 
 
 
 
558
 
559
+ if (futSubset.length > 0) {
560
+ const currDist = det.gpt_distance_m || (det.depth_est_m || 2000);
561
+ const fw = state.frame.w || 1280;
562
+ const fh = state.frame.h || 720;
563
 
564
+ let predPoints = [{ x: px, y: py }];
565
+
566
+ futSubset.forEach((pt) => {
567
+ const pX = pt[0] <= 2.0 ? pt[0] : (pt[0] / fw);
568
+ const pY = pt[0] <= 2.0 ? pt[1] : (pt[1] / fh);
569
+ const txP = pX - 0.5;
570
+ const angP = (-Math.PI / 2) + (txP * fovRad);
571
+ const cY = (det.bbox.y <= 2.0) ? (det.bbox.y + det.bbox.h / 2) : ((det.bbox.y + det.bbox.h / 2) / fh);
572
+ let distP = currDist * (cY / Math.max(0.01, pY));
573
+ const rPxP = (clamp(distP, 0, maxRangeM) / maxRangeM) * R;
574
+ const pxP = cx + Math.cos(angP) * rPxP;
575
+ const pyP = cy + Math.sin(angP) * rPxP;
576
+ predPoints.push({ x: pxP, y: pyP });
577
+ });
578
+
579
+ ctx.lineWidth = 1.5;
580
+ ctx.setLineDash([4, 4]);
581
+
582
+ for (let i = 0; i < predPoints.length - 1; i++) {
583
+ const p1 = predPoints[i];
584
+ const p2 = predPoints[i + 1];
585
+ const alpha = Math.max(0, 1.0 - (i / maxFut));
586
+
587
+ ctx.beginPath();
588
+ ctx.moveTo(p1.x, p1.y);
589
+ ctx.lineTo(p2.x, p2.y);
590
+ ctx.strokeStyle = threatColor;
591
+ ctx.globalAlpha = alpha * 0.8;
592
+ ctx.stroke();
593
+ }
594
+ ctx.setLineDash([]);
595
+ ctx.globalAlpha = 1;
596
+ }
597
+ ctx.restore();
598
+ }
599
  }
600
  });
601
  }
602
+
603
+ // ===========================================
604
+ // 8. STATUS OVERLAY - Top corners
605
+ // ===========================================
606
+ ctx.font = "bold 9px 'Courier New', monospace";
607
+ ctx.fillStyle = colors.textDim;
608
+ ctx.textAlign = "left";
609
+ ctx.textBaseline = "top";
610
+
611
+ // Top left - Mode
612
+ ctx.fillText(isStatic ? "SNAPSHOT" : "TGT ACQUISITION", 8, 8);
613
+
614
+ // Track count
615
+ const trackCount = source ? source.length : 0;
616
+ ctx.fillStyle = trackCount > 0 ? colors.text : colors.textDim;
617
+ ctx.fillText(`TRACKS: ${trackCount}`, 8, 22);
618
+
619
+ // Top right - Range setting
620
+ ctx.textAlign = "right";
621
+ ctx.fillStyle = colors.textDim;
622
+ ctx.fillText(`MAX RNG: ${maxRangeM}m`, w - 8, 8);
623
+
624
+ // Time
625
+ const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
626
+ ctx.fillText(timeStr, w - 8, 22);
627
+
628
+ // Bottom center - FOV indicator
629
+ ctx.textAlign = "center";
630
+ ctx.fillStyle = colors.textDim;
631
+ ctx.fillText("FOV: 60°", cx, h - 12);
632
  };
633
 
634
+ // Aliases for compatibility
635
  APP.ui.radar.renderFrameRadar = function () {
636
  const { state } = APP.core;
637
+
638
+ // In demo mode, use demo data for first frame (time=0) to match video radar initial state
639
+ let trackSource = state.detections;
640
+ if (APP.core.demo.active && APP.core.demo.data) {
641
+ const demoTracks = APP.core.demo.getFrameData(0); // Get frame 0 data
642
+ if (demoTracks && demoTracks.length > 0) {
643
+ trackSource = demoTracks;
644
+ }
645
+ }
646
+
647
+ // First frame radar is static - no sweep animation
648
+ APP.ui.radar.render("frameRadar", trackSource, { static: true });
649
  };
650
 
651
  APP.ui.radar.renderLiveRadar = function () {
652
  const { state } = APP.core;
653
+ // Live radar has sweep animation
654
+ APP.ui.radar.render("radarCanvas", state.tracker.tracks, { static: false });
655
  };
inference.py CHANGED
@@ -1272,12 +1272,12 @@ def run_inference(
1272
  # Append Track ID
1273
  if 'track_id' in d:
1274
  lbl = f"{d['track_id']} {lbl}"
1275
- # Append Speed/Direction if available
1276
- if 'speed_kph' in d and d['speed_kph'] > 1.0: # Threshold static
1277
- lbl += f" {int(d['speed_kph'])}km/h"
1278
- # Append GPT Distance if available (from first frame persistence)
1279
- if d.get('gpt_distance_m'):
1280
- lbl += f" {int(d['gpt_distance_m'])}m"
1281
 
1282
  display_labels.append(lbl)
1283
 
 
1272
  # Append Track ID
1273
  if 'track_id' in d:
1274
  lbl = f"{d['track_id']} {lbl}"
1275
+ # Speed display removed per user request
1276
+ # if 'speed_kph' in d and d['speed_kph'] > 1.0:
1277
+ # lbl += f" {int(d['speed_kph'])}km/h"
1278
+ # Distance display removed per user request
1279
+ # if d.get('gpt_distance_m'):
1280
+ # lbl += f" {int(d['gpt_distance_m'])}m"
1281
 
1282
  display_labels.append(lbl)
1283