Zhen Ye Claude Opus 4.6 (1M context) commited on
Commit
165863b
·
1 Parent(s): 9574811

refactor: replace frontend SPA with minimal detection UI

Browse files

Delete the full Mission Console frontend (frontend/ directory) and
replace with a single index.html — vanilla HTML/JS with video upload,
mode/model picker, live MJPEG stream, processed video playback,
and collapsible detection JSON viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

frontend/data/helicopter_demo_data.js DELETED
@@ -1,38 +0,0 @@
1
- window.HELICOPTER_DEMO_DATA = {
2
- "fps": 24,
3
- "totalFrames": 192,
4
- "video": "Enhance_Video_Movement.mp4",
5
- "format": "keyframes",
6
- "keyframes": {
7
- "0": [
8
- {"id": "H01", "label": "helicopter", "bbox": {"x": 200, "y": 260, "w": 180, "h": 120}, "gpt_distance_m": 800, "angle_deg": -90, "speed_kph": 180},
9
- {"id": "H02", "label": "helicopter", "bbox": {"x": 420, "y": 240, "w": 150, "h": 100}, "gpt_distance_m": 900, "angle_deg": 90, "speed_kph": 180},
10
- {"id": "H03", "label": "helicopter", "bbox": {"x": 620, "y": 250, "w": 100, "h": 68}, "gpt_distance_m": 1200, "angle_deg": 90, "speed_kph": 185},
11
- {"id": "H04", "label": "helicopter", "bbox": {"x": 850, "y": 235, "w": 90, "h": 62}, "gpt_distance_m": 1300, "angle_deg": 90, "speed_kph": 190}
12
- ],
13
- "48": [
14
- {"id": "H01", "label": "helicopter", "bbox": {"x": 150, "y": 280, "w": 200, "h": 130}, "gpt_distance_m": 700, "angle_deg": -90, "speed_kph": 190},
15
- {"id": "H02", "label": "helicopter", "bbox": {"x": 400, "y": 255, "w": 160, "h": 105}, "gpt_distance_m": 850, "angle_deg": 90, "speed_kph": 185},
16
- {"id": "H03", "label": "helicopter", "bbox": {"x": 640, "y": 255, "w": 115, "h": 78}, "gpt_distance_m": 1050, "angle_deg": 90, "speed_kph": 188},
17
- {"id": "H04", "label": "helicopter", "bbox": {"x": 870, "y": 245, "w": 105, "h": 72}, "gpt_distance_m": 1150, "angle_deg": 90, "speed_kph": 192}
18
- ],
19
- "96": [
20
- {"id": "H01", "label": "helicopter", "bbox": {"x": 80, "y": 300, "w": 220, "h": 145}, "gpt_distance_m": 600, "angle_deg": -90, "speed_kph": 200},
21
- {"id": "H02", "label": "helicopter", "bbox": {"x": 380, "y": 265, "w": 155, "h": 102}, "gpt_distance_m": 820, "angle_deg": 90, "speed_kph": 182},
22
- {"id": "H03", "label": "helicopter", "bbox": {"x": 660, "y": 262, "w": 135, "h": 92}, "gpt_distance_m": 900, "angle_deg": 90, "speed_kph": 190},
23
- {"id": "H04", "label": "helicopter", "bbox": {"x": 890, "y": 258, "w": 125, "h": 85}, "gpt_distance_m": 980, "angle_deg": 90, "speed_kph": 195}
24
- ],
25
- "144": [
26
- {"id": "H01", "label": "helicopter", "bbox": {"x": 50, "y": 280, "w": 200, "h": 130}, "gpt_distance_m": 650, "angle_deg": -90, "speed_kph": 195},
27
- {"id": "H02", "label": "helicopter", "bbox": {"x": 360, "y": 270, "w": 148, "h": 98}, "gpt_distance_m": 800, "angle_deg": 90, "speed_kph": 178},
28
- {"id": "H03", "label": "helicopter", "bbox": {"x": 680, "y": 272, "w": 158, "h": 108}, "gpt_distance_m": 750, "angle_deg": 90, "speed_kph": 192},
29
- {"id": "H04", "label": "helicopter", "bbox": {"x": 910, "y": 270, "w": 148, "h": 100}, "gpt_distance_m": 800, "angle_deg": 90, "speed_kph": 198}
30
- ],
31
- "191": [
32
- {"id": "H01", "label": "helicopter", "bbox": {"x": 30, "y": 260, "w": 190, "h": 125}, "gpt_distance_m": 680, "angle_deg": -90, "speed_kph": 190},
33
- {"id": "H02", "label": "helicopter", "bbox": {"x": 340, "y": 275, "w": 142, "h": 94}, "gpt_distance_m": 780, "angle_deg": 90, "speed_kph": 175},
34
- {"id": "H03", "label": "helicopter", "bbox": {"x": 700, "y": 280, "w": 180, "h": 122}, "gpt_distance_m": 620, "angle_deg": 90, "speed_kph": 195},
35
- {"id": "H04", "label": "helicopter", "bbox": {"x": 930, "y": 282, "w": 172, "h": 116}, "gpt_distance_m": 650, "angle_deg": 90, "speed_kph": 200}
36
- ]
37
- }
38
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/data/helicopter_demo_tracks.json DELETED
@@ -1,38 +0,0 @@
1
- {
2
- "fps": 24,
3
- "totalFrames": 192,
4
- "video": "Enhance_Video_Movement.mp4",
5
- "format": "keyframes",
6
- "keyframes": {
7
- "0": [
8
- {"id": "H01", "label": "helicopter", "bbox": {"x": 200, "y": 260, "w": 180, "h": 120}, "gpt_distance_m": 800, "angle_deg": -90, "speed_kph": 180},
9
- {"id": "H02", "label": "helicopter", "bbox": {"x": 420, "y": 240, "w": 150, "h": 100}, "gpt_distance_m": 900, "angle_deg": 90, "speed_kph": 180},
10
- {"id": "H03", "label": "helicopter", "bbox": {"x": 620, "y": 250, "w": 100, "h": 68}, "gpt_distance_m": 1200, "angle_deg": 90, "speed_kph": 185},
11
- {"id": "H04", "label": "helicopter", "bbox": {"x": 850, "y": 235, "w": 90, "h": 62}, "gpt_distance_m": 1300, "angle_deg": 90, "speed_kph": 190}
12
- ],
13
- "48": [
14
- {"id": "H01", "label": "helicopter", "bbox": {"x": 150, "y": 280, "w": 200, "h": 130}, "gpt_distance_m": 700, "angle_deg": -90, "speed_kph": 190},
15
- {"id": "H02", "label": "helicopter", "bbox": {"x": 400, "y": 255, "w": 160, "h": 105}, "gpt_distance_m": 850, "angle_deg": 90, "speed_kph": 185},
16
- {"id": "H03", "label": "helicopter", "bbox": {"x": 640, "y": 255, "w": 115, "h": 78}, "gpt_distance_m": 1050, "angle_deg": 90, "speed_kph": 188},
17
- {"id": "H04", "label": "helicopter", "bbox": {"x": 870, "y": 245, "w": 105, "h": 72}, "gpt_distance_m": 1150, "angle_deg": 90, "speed_kph": 192}
18
- ],
19
- "96": [
20
- {"id": "H01", "label": "helicopter", "bbox": {"x": 80, "y": 300, "w": 220, "h": 145}, "gpt_distance_m": 600, "angle_deg": -90, "speed_kph": 200},
21
- {"id": "H02", "label": "helicopter", "bbox": {"x": 380, "y": 265, "w": 155, "h": 102}, "gpt_distance_m": 820, "angle_deg": 90, "speed_kph": 182},
22
- {"id": "H03", "label": "helicopter", "bbox": {"x": 660, "y": 262, "w": 135, "h": 92}, "gpt_distance_m": 900, "angle_deg": 90, "speed_kph": 190},
23
- {"id": "H04", "label": "helicopter", "bbox": {"x": 890, "y": 258, "w": 125, "h": 85}, "gpt_distance_m": 980, "angle_deg": 90, "speed_kph": 195}
24
- ],
25
- "144": [
26
- {"id": "H01", "label": "helicopter", "bbox": {"x": 50, "y": 280, "w": 200, "h": 130}, "gpt_distance_m": 650, "angle_deg": -90, "speed_kph": 195},
27
- {"id": "H02", "label": "helicopter", "bbox": {"x": 360, "y": 270, "w": 148, "h": 98}, "gpt_distance_m": 800, "angle_deg": 90, "speed_kph": 178},
28
- {"id": "H03", "label": "helicopter", "bbox": {"x": 680, "y": 272, "w": 158, "h": 108}, "gpt_distance_m": 750, "angle_deg": 90, "speed_kph": 192},
29
- {"id": "H04", "label": "helicopter", "bbox": {"x": 910, "y": 270, "w": 148, "h": 100}, "gpt_distance_m": 800, "angle_deg": 90, "speed_kph": 198}
30
- ],
31
- "191": [
32
- {"id": "H01", "label": "helicopter", "bbox": {"x": 30, "y": 260, "w": 190, "h": 125}, "gpt_distance_m": 680, "angle_deg": -90, "speed_kph": 190},
33
- {"id": "H02", "label": "helicopter", "bbox": {"x": 340, "y": 275, "w": 142, "h": 94}, "gpt_distance_m": 780, "angle_deg": 90, "speed_kph": 175},
34
- {"id": "H03", "label": "helicopter", "bbox": {"x": 700, "y": 280, "w": 180, "h": 122}, "gpt_distance_m": 620, "angle_deg": 90, "speed_kph": 195},
35
- {"id": "H04", "label": "helicopter", "bbox": {"x": 930, "y": 282, "w": 172, "h": 116}, "gpt_distance_m": 650, "angle_deg": 90, "speed_kph": 200}
36
- ]
37
- }
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/index.html DELETED
@@ -1,302 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
-
4
- <head>
5
- <meta charset="UTF-8" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <link rel="stylesheet" href="style.css">
8
- <title>Mission Console</title>
9
- </head>
10
-
11
- <body>
12
- <div id="app">
13
- <header>
14
- <div class="brand">
15
- <div class="logo" aria-hidden="true"></div>
16
- <div>
17
- <h1>Mission Console</h1>
18
- <div class="sub">Video → detection → analysis → tracking → mission assessment</div>
19
- </div>
20
- </div>
21
- <div class="status-row">
22
- <div class="pill">
23
- <span class="dot" id="sys-dot"></span>
24
- <span id="sys-status">STANDBY · No video loaded</span>
25
- </div>
26
- <div class="pill">
27
- <span class="kbd">Detect</span>
28
- <span>First-frame analysis</span>
29
- </div>
30
- <div class="pill">
31
- <span class="kbd">Track</span>
32
- <span>Continuous monitoring</span>
33
- </div>
34
- </div>
35
- </header>
36
-
37
- <div class="workspace">
38
- <aside>
39
- <div class="card">
40
- <h2>Video Input</h2>
41
- <div class="hint">Upload one video. Tab 1 uses only the first frame. Tab 2 reuses the same video for tracking
42
- and monitoring.</div>
43
-
44
- <div class="row mt-md">
45
- <label for="videoFile">Video file</label>
46
- <span class="badge"><span id="videoMeta">No file</span></span>
47
- </div>
48
- <input id="videoFile" type="file" accept="video/*" />
49
-
50
- <div class="mt-md">
51
- <label>Mission Objective (optional · enables class filtering)</label>
52
- <textarea id="missionText" rows="3"
53
- placeholder="Optional: e.g., Detect people and vehicles; highlight hazards and key objects."></textarea>
54
-
55
- <div class="hint mt-sm">
56
- Mission objective is <b>optional</b>. If provided, it will be used directly as input to the detector.
57
- If left blank, the detector will detect <b>all</b> objects without filtering.
58
- <div class="mini mt-xs" id="hfBackendStatus">HF Backend: STANDBY</div>
59
- </div>
60
- </div>
61
-
62
- <div class="btnrow">
63
- <button id="btnLoadSample" class="btn secondary" title="Optional: wire up sample videos later" disabled>Load
64
- Sample</button>
65
- <button id="btnEject" class="btn danger" title="Unload video">Eject</button>
66
- </div>
67
-
68
- <div class="grid2">
69
- <div>
70
- <label>Detector</label>
71
- <select id="detectorSelect">
72
- <optgroup label="Object Detection Models">
73
- <option value="yolo11" data-kind="object" selected>Lite</option>
74
- <option value="detr_resnet50" data-kind="object">Big</option>
75
- <option value="grounding_dino" data-kind="object">Large</option>
76
- </optgroup>
77
- <optgroup label="Segmentation Models">
78
- <option value="GSAM2-L" data-kind="segmentation">GSAM2-L</option>
79
- <option value="GSAM2-B" data-kind="segmentation">GSAM2-B</option>
80
- <option value="GSAM2-S" data-kind="segmentation">GSAM2-S</option>
81
- <option value="YSAM2-L" data-kind="segmentation">YSAM2-L (Fast)</option>
82
- <option value="YSAM2-B" data-kind="segmentation">YSAM2-B (Fast)</option>
83
- <option value="YSAM2-S" data-kind="segmentation">YSAM2-S (Fast)</option>
84
- </optgroup>
85
- <optgroup label="Drone Detection Models">
86
- <option value="drone_yolo" data-kind="drone">Drone</option>
87
- </optgroup>
88
-
89
- </select>
90
- </div>
91
- <div>
92
- <label>Tracking</label>
93
- <select id="trackerSelect">
94
- <option value="iou">IOU + velocity (built-in)</option>
95
- <option value="external">External hook (user API)</option>
96
- </select>
97
- </div>
98
-
99
- <label class="checkbox-row" for="enableDepthToggle" style="display:none">
100
- <input type="checkbox" id="enableDepthToggle">
101
- <span>Enable Legacy Depth Map (Slow)</span>
102
- </label>
103
- <label class="checkbox-row" for="enableGPTToggle" style="margin-top: 4px;">
104
- <input type="checkbox" id="enableGPTToggle" checked>
105
- <span style="color: var(--accent-light);">Enable GPT Reasoning</span>
106
- </label>
107
- <label class="checkbox-row" for="enableStreamToggle" style="margin-top: 4px;">
108
- <input type="checkbox" id="enableStreamToggle" checked>
109
- <span>Enable Stream Processing</span>
110
- </label>
111
- </div>
112
-
113
- </div>
114
-
115
-
116
- <div class="card" style="flex:1; min-height:0">
117
- <h2>System Log</h2>
118
- <div class="log" id="sysLog"></div>
119
- </div>
120
- </aside>
121
-
122
- <main>
123
- <div class="tabs">
124
- <button class="tabbtn active" data-tab="frame">Tab 1 · Detect & Analyze</button>
125
- <button class="tabbtn" data-tab="engage">Tab 2 · Track & Monitor</button>
126
- </div>
127
-
128
- <!-- ===== Tab 1 ===== -->
129
- <section class="tab active" id="tab-frame">
130
- <div class="frame-grid">
131
- <div class="panel panel-monitor">
132
- <h3>
133
- <span>First Frame · Detection + Analysis</span>
134
- <span class="rightnote" id="frameNote">Awaiting video</span>
135
- </h3>
136
- <div class="viewbox" id="frameViewBox">
137
- <canvas id="frameCanvas" width="1280" height="720"></canvas>
138
- <canvas id="frameOverlay" class="overlay" width="1280" height="720"></canvas>
139
- <div class="watermark">EO/IR · Track-ID · Classification</div>
140
- <div class="empty" id="frameEmpty">
141
- <div class="big">Upload a video to begin</div>
142
- <div class="small">This demo performs first-frame detection and analysis. Then it replays the video
143
- with continuous object tracking.</div>
144
- <div style="display:flex; gap:10px; margin-top:6px; flex-wrap:wrap; justify-content:center;">
145
- <span class="badge"><span class="dot"></span> If you are online, COCO-SSD loads automatically</span>
146
- </div>
147
- </div>
148
- </div>
149
-
150
- <div class="btnrow" style="margin-top:10px">
151
- <button id="btnReason" class="btn">Detect</button>
152
- <button id="btnCancelReason" class="btn danger" style="display: none;">Cancel</button>
153
- <button id="btnRecompute" class="btn secondary">Reanalyze</button>
154
- <button id="btnClear" class="btn secondary">Clear</button>
155
- </div>
156
-
157
- <div class="strip mt-md">
158
- <span class="chip" id="chipFrameDepth"
159
- title="Toggle depth view of first frame (if available)" style="display:none">VIEW:DEFAULT</span>
160
- </div>
161
- </div>
162
-
163
-
164
-
165
- <div class="panel panel-summary" style="display:flex; flex-direction:column; min-height: 0; overflow: hidden;">
166
- <h3>
167
- <span>Object Track Cards</span>
168
- <span class="rightnote" id="trackCount">0</span>
169
- </h3>
170
- <div class="list" id="frameTrackList" style="flex:1; overflow-y:auto; padding:6px; max-height:none;">
171
- <!-- Cards injected here -->
172
- <div style="font-style:italic; color:var(--faint); text-align:center; margin-top:20px; font-size:12px;">
173
- No objects tracked.
174
- </div>
175
- </div>
176
- </div>
177
-
178
- <!-- Threat Chat Panel -->
179
- <div class="panel panel-chat" id="chatPanel">
180
- <h3>
181
- <span>Mission Analyst</span>
182
- <button class="collapse-btn" id="chatToggle" style="font-size: 0.75rem;">▲ Close Chat</button>
183
- </h3>
184
- <div class="chat-container">
185
- <div class="chat-messages" id="chatMessages">
186
- <div class="chat-message chat-system">
187
- <span class="chat-icon">SYS</span>
188
- <span class="chat-content">Run detection first, then ask questions about detected objects.</span>
189
- </div>
190
- </div>
191
- <div class="chat-input-row">
192
- <input type="text" id="chatInput" placeholder="Ask about detections..." autocomplete="off">
193
- <button id="chatSend" class="btn">Send</button>
194
- </div>
195
- </div>
196
- </div>
197
-
198
- </div>
199
-
200
- </section>
201
-
202
- <!-- ===== Tab 2 ===== -->
203
- <section class="tab" id="tab-engage">
204
- <div class="engage-grid">
205
- <div class="panel">
206
- <h3>
207
- <span>Video Tracking · Continuous Monitoring</span>
208
- <div style="display: flex; gap: 8px; align-items: center;">
209
- <button class="collapse-btn" id="btnToggleSidebar">◀ Hide Sidebar</button>
210
- <span class="rightnote" id="engageNote">Awaiting video</span>
211
- </div>
212
- </h3>
213
-
214
- <div class="viewbox" style="min-height: 420px;">
215
- <video id="videoEngage" playsinline muted></video>
216
- <canvas id="engageOverlay" class="overlay"></canvas>
217
- <div class="watermark">TRACK · ID · DIST · STATUS</div>
218
- <div class="empty" id="engageEmpty">
219
- <div class="big">No video loaded</div>
220
- <div class="small">Upload a video. Run <b>Detect</b> first to analyze objects.
221
- Then click <b>Track</b>.</div>
222
- </div>
223
- </div>
224
-
225
- <div class="btnrow mt-md">
226
- <button id="btnEngage" class="btn">Track</button>
227
- <button id="btnPause" class="btn secondary">Pause</button>
228
- <button id="btnReset" class="btn secondary">Reset</button>
229
- </div>
230
-
231
- <div class="strip mt-md">
232
- <span class="chip" id="chipPolicy">POLICY:AUTO</span>
233
- <span class="chip" id="chipTracks">TRACKS:0</span>
234
- <span class="chip" id="chipBeam">MODE:AUTO</span>
235
- <span class="chip" id="chipHz">DET:6Hz</span>
236
- <span class="chip" id="chipFeed" title="Toggle raw vs HF-processed feed (if available)">FEED:RAW</span>
237
- <span class="chip" id="chipDepth" title="Toggle depth view (if available)" style="display:none">VIEW:DEFAULT</span>
238
- </div>
239
-
240
- <div class="mt-md">
241
- <div class="row"><label>Active track progress (selected)</label><small class="mini"
242
- id="dwellText">—</small>
243
- </div>
244
- <div class="bar">
245
- <div id="dwellBar"></div>
246
- </div>
247
- </div>
248
-
249
- <div class="hint mt-md">Manual selection: choose "Manual" in Policy, then click a target in the video.</div>
250
- </div>
251
-
252
- <div class="engage-right">
253
- <div class="panel" style="flex:1; min-height:0">
254
- <h3>
255
- <span>Live Track Cards</span>
256
- <span class="rightnote" id="liveStamp">—</span>
257
- </h3>
258
- <div class="list" id="trackList" style="max-height:none"></div>
259
- </div>
260
- </div>
261
- </div>
262
- </section>
263
-
264
- </div>
265
-
266
- <footer>
267
- <div>Mission Console · Unclassified visuals</div>
268
- <div class="mono" id="telemetry">VIS=16km · DET=6Hz</div>
269
- </footer>
270
-
271
- <!-- Hidden video used only for first-frame capture -->
272
- <video id="videoHidden" playsinline muted style="display:none"></video>
273
- </div>
274
-
275
- <script>
276
- window.API_CONFIG = {
277
- BACKEND_BASE: "https://biaslab2025-perception.hf.space"
278
- };
279
- </script>
280
- <script src="./js/init.js"></script>
281
- <script src="./js/core/config.js"></script>
282
- <script src="./js/core/utils.js"></script>
283
- <script src="./js/core/state.js"></script>
284
- <script src="./js/core/physics.js"></script>
285
- <script src="./js/core/video.js"></script>
286
- <script src="./js/core/hel.js"></script>
287
- <script src="./js/ui/logging.js"></script>
288
- <script src="./js/core/gptMapping.js"></script>
289
- <script src="./js/core/tracker.js"></script>
290
- <script src="./js/api/client.js"></script>
291
- <script src="./js/ui/overlays.js"></script>
292
- <script src="./js/ui/cards.js"></script>
293
- <script src="./js/ui/features.js"></script>
294
- <script src="./js/ui/cursor.js"></script>
295
- <script src="./js/ui/chat.js"></script>
296
- <script src="./data/helicopter_demo_data.js"></script>
297
- <script src="./js/core/demo.js"></script>
298
- <script src="./js/main.js"></script>
299
-
300
- </body>
301
-
302
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/api/client.js DELETED
@@ -1,372 +0,0 @@
1
- // API Client Module - Backend communication
2
- APP.api.client = {};
3
-
4
- APP.api.client.hfDetectAsync = async function (formData) {
5
- const { state } = APP.core;
6
- if (!state.hf.baseUrl) return;
7
-
8
- const resp = await fetch(`${state.hf.baseUrl}/detect/async`, {
9
- method: "POST",
10
- body: formData
11
- });
12
-
13
- if (!resp.ok) {
14
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
15
- throw new Error(err.detail || "Async detection submission failed");
16
- }
17
-
18
- const data = await resp.json();
19
-
20
- // Store URLs from response
21
- if (data.status_url) {
22
- state.hf.statusUrl = data.status_url.startsWith("http")
23
- ? data.status_url
24
- : `${state.hf.baseUrl}${data.status_url}`;
25
- }
26
-
27
- if (data.video_url) {
28
- state.hf.videoUrl = data.video_url.startsWith("http")
29
- ? data.video_url
30
- : `${state.hf.baseUrl}${data.video_url}`;
31
- }
32
-
33
- if (data.depth_video_url) {
34
- state.hf.depthVideoUrl = data.depth_video_url.startsWith("http")
35
- ? data.depth_video_url
36
- : `${state.hf.baseUrl}${data.depth_video_url}`;
37
- }
38
-
39
- if (data.depth_first_frame_url) {
40
- state.hf.depthFirstFrameUrl = data.depth_first_frame_url.startsWith("http")
41
- ? data.depth_first_frame_url
42
- : `${state.hf.baseUrl}${data.depth_first_frame_url}`;
43
- }
44
-
45
- return data;
46
- };
47
-
48
- APP.api.client.checkJobStatus = async function (jobId) {
49
- const { state } = APP.core;
50
- if (!state.hf.baseUrl) return { status: "error" };
51
-
52
- const url = state.hf.statusUrl || `${state.hf.baseUrl}/detect/job/${jobId}`;
53
- const resp = await fetch(url, { cache: "no-store" });
54
-
55
- if (!resp.ok) {
56
- if (resp.status === 404) return { status: "not_found" };
57
- throw new Error(`Status check failed: ${resp.status}`);
58
- }
59
-
60
- return await resp.json();
61
- };
62
-
63
- APP.api.client.cancelBackendJob = async function (jobId, reason) {
64
- const { state } = APP.core;
65
- const { log } = APP.ui.logging;
66
-
67
- if (!state.hf.baseUrl || !jobId) return;
68
-
69
- // Don't attempt cancel on HF Space (it doesn't support it)
70
- if (state.hf.baseUrl.includes("hf.space")) {
71
- log(`Job cancel skipped for HF Space (${reason || "user request"})`, "w");
72
- return { status: "skipped", message: "Cancel disabled for HF Space" };
73
- }
74
-
75
- try {
76
- const resp = await fetch(`${state.hf.baseUrl}/detect/job/${jobId}`, {
77
- method: "DELETE"
78
- });
79
-
80
- if (resp.ok) {
81
- const result = await resp.json();
82
- log(`Job ${jobId.substring(0, 8)} cancelled`, "w");
83
- return result;
84
- }
85
- if (resp.status === 404) return { status: "not_found" };
86
- throw new Error("Cancel failed");
87
- } catch (err) {
88
- log(`Cancel error: ${err.message}`, "e");
89
- return { status: "error", message: err.message };
90
- }
91
- };
92
-
93
- /**
94
- * Sync GPT enrichment data from polled first_frame_detections into state.detections.
95
- * Returns true if any card was updated and needs re-render.
96
- */
97
- APP.api.client._syncGptFromDetections = function (rawDets, logLabel) {
98
- const { state } = APP.core;
99
- const { log } = APP.ui.logging;
100
- let needsRender = false;
101
-
102
- // Phase A: Sync assessment status, relevance fields
103
- for (const rd of rawDets) {
104
- const tid = rd.track_id || `T${String(rawDets.indexOf(rd) + 1).padStart(2, "0")}`;
105
- const existing = (state.detections || []).find(d => d.id === tid);
106
- if (existing) {
107
- if (rd.assessment_status && existing.assessment_status !== rd.assessment_status) {
108
- existing.assessment_status = rd.assessment_status;
109
- needsRender = true;
110
- }
111
- if (rd.mission_relevant !== undefined && rd.mission_relevant !== null) {
112
- existing.mission_relevant = rd.mission_relevant;
113
- }
114
- if (rd.relevance_reason) {
115
- existing.relevance_reason = rd.relevance_reason;
116
- }
117
- }
118
- }
119
-
120
- // Phase B: Full GPT feature merge (gated on gpt_raw)
121
- const hasGptData = rawDets.some(d => d.gpt_raw);
122
- if (hasGptData) {
123
- state.hf.firstFrameDetections = rawDets;
124
- for (const rd of rawDets) {
125
- const tid = rd.track_id || `T${String(rawDets.indexOf(rd) + 1).padStart(2, "0")}`;
126
- const existing = (state.detections || []).find(d => d.id === tid);
127
- if (existing && rd.gpt_raw) {
128
- const g = rd.gpt_raw;
129
- existing.features = APP.core.gptMapping.buildFeatures(g);
130
- existing.threat_level_score = rd.threat_level_score || g.threat_level_score || 0;
131
- existing.threat_classification = rd.threat_classification || g.threat_classification || "Unknown";
132
- existing.weapon_readiness = rd.weapon_readiness || g.weapon_readiness || "Unknown";
133
- existing.gpt_distance_m = rd.gpt_distance_m || null;
134
- existing.gpt_direction = rd.gpt_direction || null;
135
- needsRender = true;
136
- }
137
- }
138
- log(`Track cards updated with GPT assessment${logLabel ? " (" + logLabel + ")" : ""}`, "g");
139
- }
140
-
141
- if (needsRender && APP.ui && APP.ui.cards && APP.ui.cards.renderFrameTrackList) {
142
- APP.ui.cards.renderFrameTrackList();
143
- }
144
-
145
- return needsRender;
146
- };
147
-
148
- APP.api.client.pollAsyncJob = async function () {
149
- const { state } = APP.core;
150
- const { log, setHfStatus } = APP.ui.logging;
151
- const { fetchProcessedVideo, fetchDepthVideo, fetchDepthFirstFrame } = APP.core.video;
152
- const syncGpt = APP.api.client._syncGptFromDetections;
153
-
154
- const pollInterval = 3000; // 3 seconds
155
- const maxAttempts = 200; // 10 minutes max
156
- let attempts = 0;
157
- let fetchingVideo = false;
158
-
159
- return new Promise((resolve, reject) => {
160
- state.hf.asyncPollInterval = setInterval(async () => {
161
- attempts++;
162
-
163
- try {
164
- const resp = await fetch(state.hf.statusUrl, { cache: "no-store" });
165
-
166
- if (!resp.ok) {
167
- if (resp.status === 404) {
168
- clearInterval(state.hf.asyncPollInterval);
169
- reject(new Error("Job expired or not found"));
170
- return;
171
- }
172
- throw new Error(`Status check failed: ${resp.statusText}`);
173
- }
174
-
175
- const status = await resp.json();
176
- state.hf.asyncStatus = status.status;
177
- state.hf.asyncProgress = status;
178
-
179
- if (status.status === "completed") {
180
- if (fetchingVideo) return;
181
- fetchingVideo = true;
182
-
183
- const completedJobId = state.hf.asyncJobId;
184
- log(`✓ Backend job ${completedJobId.substring(0, 8)}: completed successfully`, "g");
185
- setHfStatus("job completed, fetching video...");
186
-
187
- // Final GPT sync — enrichment may have completed during
188
- // processing but the poll never landed on a "processing"
189
- // cycle that picked it up (common for segmentation mode
190
- // where _enrich_first_frame_gpt is skipped).
191
- if (status.first_frame_detections && status.first_frame_detections.length > 0) {
192
- syncGpt(status.first_frame_detections, "final sync");
193
- }
194
-
195
- try {
196
- await fetchProcessedVideo();
197
- await fetchDepthVideo();
198
- await fetchDepthFirstFrame();
199
-
200
- clearInterval(state.hf.asyncPollInterval);
201
- state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
202
- state.hf.asyncJobId = null;
203
- setHfStatus("ready");
204
- resolve();
205
- } catch (err) {
206
- if (err && err.code === "VIDEO_PENDING") {
207
- setHfStatus("job completed, finalizing video...");
208
- fetchingVideo = false;
209
- return;
210
- }
211
- clearInterval(state.hf.asyncPollInterval);
212
- state.hf.asyncJobId = null;
213
- reject(err);
214
- }
215
- } else if (status.status === "failed") {
216
- clearInterval(state.hf.asyncPollInterval);
217
- const errMsg = status.error || "Processing failed";
218
- log(`✗ Backend job ${state.hf.asyncJobId.substring(0, 8)}: failed - ${errMsg}`, "e");
219
- state.hf.asyncJobId = null;
220
- setHfStatus(`error: ${errMsg}`);
221
- reject(new Error(errMsg));
222
- } else {
223
- // Still processing
224
- const progressInfo = status.progress ? ` (${Math.round(status.progress * 100)}%)` : "";
225
- setHfStatus(`job ${state.hf.asyncJobId.substring(0, 8)}: ${status.status}${progressInfo} (${attempts})`);
226
-
227
- // Check if GPT enrichment has updated first-frame detections
228
- if (status.first_frame_detections && status.first_frame_detections.length > 0) {
229
- syncGpt(status.first_frame_detections);
230
- }
231
- }
232
-
233
- if (attempts >= maxAttempts) {
234
- clearInterval(state.hf.asyncPollInterval);
235
- reject(new Error("Polling timeout (10 minutes)"));
236
- }
237
- } catch (err) {
238
- clearInterval(state.hf.asyncPollInterval);
239
- reject(err);
240
- }
241
- }, pollInterval);
242
- });
243
- };
244
-
245
- // External detection hook (can be replaced by user)
246
- APP.api.client.externalDetect = async function (input) {
247
- console.log("externalDetect called", input);
248
- return [];
249
- };
250
-
251
- // External features hook (can be replaced by user)
252
- APP.api.client.externalFeatures = async function (detections, frameInfo) {
253
- console.log("externalFeatures called for", detections.length, "objects");
254
- return {};
255
- };
256
-
257
- // External tracker hook (can be replaced by user)
258
- APP.api.client.externalTrack = async function (videoEl) {
259
- console.log("externalTrack called");
260
- return [];
261
- };
262
-
263
- // Call HF object detection directly (for first frame)
264
- APP.api.client.callHfObjectDetection = async function (canvas) {
265
- const { state } = APP.core;
266
- const { canvasToBlob } = APP.core.utils;
267
- const { CONFIG } = APP.core;
268
-
269
- const proxyBase = (CONFIG.PROXY_URL || "").trim();
270
-
271
- if (proxyBase) {
272
- const blob = await canvasToBlob(canvas);
273
- const form = new FormData();
274
- form.append("image", blob, "frame.jpg");
275
-
276
- const resp = await fetch(`${proxyBase.replace(/\/$/, "")}/detect`, {
277
- method: "POST",
278
- body: form
279
- });
280
-
281
- if (!resp.ok) {
282
- let detail = `Proxy inference failed (${resp.status})`;
283
- try {
284
- const err = await resp.json();
285
- detail = err.detail || err.error || detail;
286
- } catch (_) { }
287
- throw new Error(detail);
288
- }
289
-
290
- return await resp.json();
291
- }
292
-
293
- // Default: use the backend base URL
294
- const blob = await canvasToBlob(canvas);
295
- const form = new FormData();
296
- form.append("image", blob, "frame.jpg");
297
-
298
- const resp = await fetch(`${state.hf.baseUrl}/detect/frame`, {
299
- method: "POST",
300
- body: form
301
- });
302
-
303
- if (!resp.ok) {
304
- throw new Error(`Frame detection failed: ${resp.statusText}`);
305
- }
306
-
307
- return await resp.json();
308
- };
309
-
310
- // Capture current video frame and send to backend for GPT analysis
311
- APP.api.client.analyzeFrame = async function (videoEl, tracks) {
312
- const { state } = APP.core;
313
- const { canvasToBlob } = APP.core.utils;
314
-
315
- // Capture current video frame
316
- const canvas = document.createElement("canvas");
317
- canvas.width = videoEl.videoWidth;
318
- canvas.height = videoEl.videoHeight;
319
- canvas.getContext("2d").drawImage(videoEl, 0, 0);
320
- const blob = await canvasToBlob(canvas);
321
-
322
- // Convert normalized bbox (0-1) back to pixel coords for backend
323
- const w = canvas.width, h = canvas.height;
324
- const dets = tracks.map(t => ({
325
- track_id: t.id,
326
- label: t.label,
327
- bbox: [
328
- Math.round(t.bbox.x * w),
329
- Math.round(t.bbox.y * h),
330
- Math.round((t.bbox.x + t.bbox.w) * w),
331
- Math.round((t.bbox.y + t.bbox.h) * h),
332
- ],
333
- score: t.score,
334
- }));
335
-
336
- const form = new FormData();
337
- form.append("image", blob, "frame.jpg");
338
- form.append("detections", JSON.stringify(dets));
339
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
340
- if (jobId) form.append("job_id", jobId);
341
-
342
- const resp = await fetch(`${state.hf.baseUrl}/detect/analyze-frame`, {
343
- method: "POST",
344
- body: form,
345
- });
346
- if (!resp.ok) throw new Error(`Frame analysis failed: ${resp.statusText}`);
347
- return await resp.json();
348
- };
349
-
350
- // Chat about threats using GPT
351
- APP.api.client.chatAboutThreats = async function (question, detections) {
352
- const { state } = APP.core;
353
-
354
- const form = new FormData();
355
- form.append("question", question);
356
- form.append("detections", JSON.stringify(detections));
357
- if (state.hf.missionSpec) {
358
- form.append("mission_context", JSON.stringify(state.hf.missionSpec));
359
- }
360
-
361
- const resp = await fetch(`${state.hf.baseUrl}/chat/threat`, {
362
- method: "POST",
363
- body: form
364
- });
365
-
366
- if (!resp.ok) {
367
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
368
- throw new Error(err.detail || "Chat request failed");
369
- }
370
-
371
- return await resp.json();
372
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/config.js DELETED
@@ -1,15 +0,0 @@
1
- APP.core.CONFIG = {
2
- // API Endpoints will be loaded from window.API_CONFIG or defaults
3
- BACKEND_BASE: (window.API_CONFIG?.BACKEND_BASE || window.API_CONFIG?.BASE_URL || "").replace(/\/$/, "") || window.location.origin,
4
- HF_TOKEN: window.API_CONFIG?.HF_TOKEN || "",
5
- PROXY_URL: (window.API_CONFIG?.PROXY_URL || "").trim(),
6
-
7
- // Tracking Constants
8
- REASON_INTERVAL: 30,
9
- MAX_TRACKS: 50,
10
- TRACK_PRUNE_MS: 1500,
11
- TRACK_MATCH_THRESHOLD: 0.25,
12
-
13
- // Default Queries
14
- DEFAULT_QUERY_CLASSES: ["drone", "uav", "quadcopter", "fixed-wing", "missile", "person", "vehicle"]
15
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/demo.js DELETED
@@ -1,142 +0,0 @@
1
- APP.core.demo = {};
2
-
3
- APP.core.demo.data = null;
4
- APP.core.demo.active = false;
5
-
6
- APP.core.demo.load = async function () {
7
- // Demo data is now loaded per-video via loadForVideo()
8
- // This function is kept for compatibility but does nothing
9
- };
10
-
11
- APP.core.demo.getFrameData = function (currentTime) {
12
- if (!APP.core.demo.data) return null;
13
-
14
- // Use interpolation for keyframe format
15
- if (APP.core.demo.data.format === "keyframes") {
16
- return APP.core.demo.getFrameDataInterpolated(currentTime);
17
- }
18
-
19
- // Original logic for frame-by-frame data
20
- const fps = APP.core.demo.data.fps || 30;
21
- const frameIdx = Math.floor(currentTime * fps);
22
-
23
- // Get tracks for this frame
24
- // Handle both string and number keys if needed
25
- const tracks = APP.core.demo.data.frames[frameIdx] || APP.core.demo.data.frames[frameIdx.toString()];
26
-
27
- if (!tracks) return [];
28
- return tracks;
29
- };
30
-
31
- APP.core.demo.enable = function (force = true) {
32
- const { log } = APP.ui.logging;
33
- APP.core.demo.active = force;
34
- if (force) {
35
- log("DEMO MODE ACTIVATED", "g");
36
- const chipFeed = document.getElementById("chipFeed");
37
- if (chipFeed) chipFeed.textContent = "MODE:DEMO";
38
- }
39
- };
40
-
41
- // Keyframe interpolation for smooth radar movement
42
- APP.core.demo._keyframeIndices = null;
43
-
44
- APP.core.demo.getKeyframeIndices = function() {
45
- if (!APP.core.demo.data || APP.core.demo.data.format !== "keyframes") return null;
46
- if (APP.core.demo._keyframeIndices) return APP.core.demo._keyframeIndices;
47
-
48
- const indices = Object.keys(APP.core.demo.data.keyframes)
49
- .map(k => parseInt(k, 10))
50
- .sort((a, b) => a - b);
51
-
52
- APP.core.demo._keyframeIndices = indices;
53
- return indices;
54
- };
55
-
56
- APP.core.demo.interpolateTrack = function(trackA, trackB, t) {
57
- const { lerp } = APP.core.utils;
58
-
59
- return {
60
- id: trackA.id,
61
- label: trackA.label,
62
- bbox: {
63
- x: lerp(trackA.bbox.x, trackB.bbox.x, t),
64
- y: lerp(trackA.bbox.y, trackB.bbox.y, t),
65
- w: lerp(trackA.bbox.w, trackB.bbox.w, t),
66
- h: lerp(trackA.bbox.h, trackB.bbox.h, t)
67
- },
68
- gpt_distance_m: lerp(trackA.gpt_distance_m, trackB.gpt_distance_m, t),
69
- angle_deg: trackA.angle_deg,
70
- speed_kph: lerp(trackA.speed_kph, trackB.speed_kph, t),
71
- depth_valid: true,
72
- depth_est_m: lerp(trackA.gpt_distance_m, trackB.gpt_distance_m, t),
73
- history: [],
74
- predicted_path: []
75
- };
76
- };
77
-
78
- APP.core.demo.getFrameDataInterpolated = function(currentTime) {
79
- const data = APP.core.demo.data;
80
- if (!data || data.format !== "keyframes") return null;
81
-
82
- const fps = data.fps || 24;
83
- const frameIdx = Math.floor(currentTime * fps);
84
- const keyframes = APP.core.demo.getKeyframeIndices();
85
-
86
- if (!keyframes || keyframes.length === 0) return [];
87
-
88
- // Find surrounding keyframes
89
- let beforeIdx = keyframes[0];
90
- let afterIdx = keyframes[keyframes.length - 1];
91
-
92
- for (let i = 0; i < keyframes.length; i++) {
93
- if (keyframes[i] <= frameIdx) beforeIdx = keyframes[i];
94
- if (keyframes[i] >= frameIdx) { afterIdx = keyframes[i]; break; }
95
- }
96
-
97
- // Edge cases
98
- if (frameIdx <= keyframes[0]) return data.keyframes[keyframes[0]] || [];
99
- if (frameIdx >= keyframes[keyframes.length - 1]) return data.keyframes[keyframes[keyframes.length - 1]] || [];
100
-
101
- // Interpolation factor
102
- const t = (beforeIdx === afterIdx) ? 0 : (frameIdx - beforeIdx) / (afterIdx - beforeIdx);
103
-
104
- const tracksBefore = data.keyframes[beforeIdx] || [];
105
- const tracksAfter = data.keyframes[afterIdx] || [];
106
-
107
- // Match by ID and interpolate
108
- const result = [];
109
- for (const trackA of tracksBefore) {
110
- const trackB = tracksAfter.find(tr => tr.id === trackA.id);
111
- if (trackB) {
112
- result.push(APP.core.demo.interpolateTrack(trackA, trackB, t));
113
- }
114
- }
115
-
116
- return result;
117
- };
118
-
119
- // Video-specific loading for demo tracks
120
- APP.core.demo.loadForVideo = async function(videoName) {
121
- const { log } = APP.ui.logging;
122
- if (videoName.toLowerCase().includes("enhance_video_movement")) {
123
- // Use global variable injected by helicopter_demo_data.js script tag (CORS-safe)
124
- if (window.HELICOPTER_DEMO_DATA) {
125
- APP.core.demo.data = window.HELICOPTER_DEMO_DATA;
126
- APP.core.demo._keyframeIndices = null; // Reset cache
127
- log("Helicopter demo tracks loaded (CORS-safe mode).", "g");
128
- return;
129
- }
130
- // Fallback to fetch (works when served from HTTP server)
131
- try {
132
- const resp = await fetch("data/helicopter_demo_tracks.json");
133
- if (resp.ok) {
134
- APP.core.demo.data = await resp.json();
135
- APP.core.demo._keyframeIndices = null; // Reset cache
136
- log("Helicopter demo tracks loaded.", "g");
137
- }
138
- } catch (err) {
139
- console.warn("Failed to load helicopter demo tracks:", err);
140
- }
141
- }
142
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/gptMapping.js DELETED
@@ -1,46 +0,0 @@
1
- /**
2
- * gptMapping.js — canonical GPT-raw → features field mapping.
3
- *
4
- * Replaces 4 identical inline mapping blocks across main.js, client.js,
5
- * and tracker.js (2 locations).
6
- */
7
- APP.core.gptMapping = {};
8
-
9
- /** Frozen assessment-status string constants. */
10
- APP.core.gptMapping.STATUS = Object.freeze({
11
- ASSESSED: "ASSESSED",
12
- UNASSESSED: "UNASSESSED",
13
- STALE: "STALE",
14
- PENDING_GPT: "PENDING_GPT",
15
- });
16
-
17
- /**
18
- * Build a features object from a gpt_raw payload.
19
- *
20
- * @param {Object|null|undefined} gptRaw - The gpt_raw dict from a detection.
21
- * @returns {Object} Features key-value map (empty object if gptRaw is falsy).
22
- */
23
- APP.core.gptMapping.buildFeatures = function (gptRaw) {
24
- if (!gptRaw) return {};
25
- const rangeStr = gptRaw.range_estimate && gptRaw.range_estimate !== "Unknown"
26
- ? gptRaw.range_estimate + " (est.)" : "Unknown";
27
- const features = {
28
- "Type": gptRaw.object_type || "Unknown",
29
- "Size": gptRaw.size || "Unknown",
30
- "Threat Lvl": (gptRaw.threat_level || gptRaw.threat_level_score || "?") + "/10",
31
- "Status": gptRaw.threat_classification || "?",
32
- "Weapons": (gptRaw.visible_weapons || []).join(", ") || "None Visible",
33
- "Readiness": gptRaw.weapon_readiness || "Unknown",
34
- "Motion": gptRaw.motion_status || "Unknown",
35
- "Range": rangeStr,
36
- "Bearing": gptRaw.bearing || "Unknown",
37
- "Intent": gptRaw.tactical_intent || "Unknown",
38
- };
39
- const dynFeats = gptRaw.dynamic_features || [];
40
- for (const feat of dynFeats) {
41
- if (feat && feat.key && feat.value) {
42
- features[feat.key] = feat.value;
43
- }
44
- }
45
- return features;
46
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/hel.js DELETED
@@ -1,245 +0,0 @@
1
- // HEL (High-Energy Laser) Physics and Computation Module
2
- APP.core.hel = {};
3
-
4
- // Get current knob values from UI
5
- APP.core.hel.getKnobs = function () {
6
- const { $ } = APP.core.utils;
7
-
8
- const helPower = $("#helPower");
9
- const helAperture = $("#helAperture");
10
- const helM2 = $("#helM2");
11
- const helJitter = $("#helJitter");
12
- const helDuty = $("#helDuty");
13
- const helMode = $("#helMode");
14
- const atmVis = $("#atmVis");
15
- const atmCn2 = $("#atmCn2");
16
- const seaSpray = $("#seaSpray");
17
- const aoQ = $("#aoQ");
18
- const rangeBase = $("#rangeBase");
19
-
20
- if (!helPower) return {};
21
-
22
- return {
23
- PkW: +helPower.value,
24
- aperture: +helAperture.value,
25
- M2: +helM2.value,
26
- jitter_urad: +helJitter.value,
27
- duty: (+helDuty.value) / 100,
28
- mode: helMode ? helMode.value : "cw",
29
- vis_km: +atmVis.value,
30
- cn2: +atmCn2.value,
31
- spray: +seaSpray.value,
32
- ao: +aoQ.value,
33
- baseRange: +rangeBase.value
34
- };
35
- };
36
-
37
- // Compute max power at target considering atmospheric effects
38
- APP.core.hel.maxPowerAtTarget = function (range_m) {
39
- const k = APP.core.hel.getKnobs();
40
- if (!k.PkW) return { Ptar: 0, Pout: k.PkW || 0, trans: 0, turb: 0, beam: 0 };
41
-
42
- const { clamp } = APP.core.utils;
43
-
44
- // Atmospheric transmission (Beer-Lambert approximation)
45
- const sigma_km = 3.912 / Math.max(1, k.vis_km);
46
- const range_km = range_m / 1000;
47
- const trans = Math.exp(-sigma_km * range_km);
48
-
49
- // Turbulence factor (simplified Cn² model)
50
- const cn2_factor = clamp(1 - (k.cn2 / 10) * 0.3, 0.5, 1);
51
- const ao_correction = 1 + (k.ao / 10) * 0.25;
52
- const turb = cn2_factor * ao_correction;
53
-
54
- // Sea spray attenuation
55
- const spray_factor = 1 - (k.spray / 10) * 0.15;
56
-
57
- // Beam quality degradation with range
58
- const beam_spread = 1 + (k.M2 - 1) * (range_km / 5);
59
- const jitter_loss = 1 / (1 + (k.jitter_urad / 10) * (range_km / 3));
60
- const beam = jitter_loss / beam_spread;
61
-
62
- // Total power at target
63
- const Pout = k.PkW * k.duty;
64
- const Ptar = Pout * trans * turb * spray_factor * beam;
65
-
66
- return {
67
- Ptar: Math.max(0, Ptar),
68
- Pout,
69
- trans,
70
- turb: turb * spray_factor,
71
- beam
72
- };
73
- };
74
-
75
- // Estimate required power based on target features
76
- APP.core.hel.requiredPowerFromFeatures = function (feat) {
77
- if (!feat) return 35; // Default
78
-
79
- let base = 30; // kW base requirement
80
-
81
- // Adjust based on material
82
- const material = (feat.material || "").toLowerCase();
83
- if (material.includes("metal") || material.includes("aluminum")) base *= 1.2;
84
- if (material.includes("composite") || material.includes("carbon")) base *= 0.8;
85
- if (material.includes("plastic") || material.includes("polymer")) base *= 0.6;
86
-
87
- // Adjust based on reflectivity
88
- const refl = feat.reflectivity;
89
- if (typeof refl === "number") {
90
- base *= (1 + refl * 0.5); // Higher reflectivity = more power needed
91
- }
92
-
93
- // Adjust based on size
94
- const size = feat.physical_size;
95
- if (typeof size === "number") {
96
- base *= Math.max(0.5, Math.min(2, size / 2)); // Assuming size in meters
97
- }
98
-
99
- return Math.round(base);
100
- };
101
-
102
- // Calculate required dwell time
103
- APP.core.hel.requiredDwell = function (range_m, reqP_kW, maxP_kW, baseDwell_s) {
104
- if (maxP_kW <= 0) return Infinity;
105
- if (maxP_kW >= reqP_kW) return baseDwell_s;
106
-
107
- // Dwell inflates quadratically as power drops below requirement
108
- const ratio = reqP_kW / maxP_kW;
109
- return baseDwell_s * ratio * ratio;
110
- };
111
-
112
- // Calculate probability of kill
113
- APP.core.hel.pkillFromMargin = function (margin_kW, baseDwell_s, reqDwell_s) {
114
- if (margin_kW <= 0) return 0;
115
- if (reqDwell_s <= 0) return 0;
116
-
117
- const dwellRatio = baseDwell_s / reqDwell_s;
118
- const marginFactor = Math.min(1, margin_kW / 50); // Normalize margin
119
-
120
- return Math.min(0.99, dwellRatio * marginFactor * 0.95);
121
- };
122
-
123
- // Recompute HEL synthesis for all detections
124
- APP.core.hel.recomputeHEL = async function () {
125
- const { state } = APP.core;
126
- const { log } = APP.ui.logging;
127
- const knobs = APP.core.hel.getKnobs();
128
-
129
- if (!state.detections || state.detections.length === 0) return;
130
-
131
- for (const det of state.detections) {
132
- // Get range from GPT or use baseline
133
- const range = det.gpt_distance_m || knobs.baseRange || 1500;
134
-
135
- // Compute power at target
136
- const power = APP.core.hel.maxPowerAtTarget(range);
137
- det.maxP_kW = power.Ptar;
138
-
139
- // Required power from features
140
- det.reqP_kW = APP.core.hel.requiredPowerFromFeatures(det.features);
141
-
142
- // Dwell calculation
143
- det.baseDwell_s = det.baseDwell_s || 5.0;
144
- det.reqDwell_s = APP.core.hel.requiredDwell(range, det.reqP_kW, det.maxP_kW, det.baseDwell_s);
145
-
146
- // P(kill)
147
- const margin = det.maxP_kW - det.reqP_kW;
148
- det.pkill = APP.core.hel.pkillFromMargin(margin, det.baseDwell_s, det.reqDwell_s);
149
-
150
- // Store range
151
- det.baseRange_m = range;
152
- }
153
-
154
- log("HEL synthesis updated for all targets.", "t");
155
- };
156
-
157
- // External hook for HEL synthesis (can be replaced by user)
158
- APP.core.hel.externalHEL = async function (detections, knobs) {
159
- // Default implementation - can be replaced by user
160
- console.log("externalHEL called for", detections.length, "objects", knobs);
161
- return {
162
- targets: {},
163
- system: { maxP_kW: 0, reqP_kW: 0, margin_kW: 0, medianRange_m: 0 }
164
- };
165
- };
166
-
167
- // Sync knob display values in the UI
168
- APP.core.hel.syncKnobDisplays = function () {
169
- const { $ } = APP.core.utils;
170
-
171
- const helPower = $("#helPower");
172
- const helAperture = $("#helAperture");
173
- const helM2 = $("#helM2");
174
- const helJitter = $("#helJitter");
175
- const helDuty = $("#helDuty");
176
- const atmVis = $("#atmVis");
177
- const atmCn2 = $("#atmCn2");
178
- const seaSpray = $("#seaSpray");
179
- const aoQ = $("#aoQ");
180
- const rangeBase = $("#rangeBase");
181
- const detHz = $("#detHz");
182
- const assessWindow = $("#assessWindow");
183
- const policyMode = $("#policyMode");
184
-
185
- if (helPower) {
186
- const v = $("#helPowerVal");
187
- if (v) v.textContent = helPower.value;
188
- }
189
- if (helAperture) {
190
- const v = $("#helApertureVal");
191
- if (v) v.textContent = (+helAperture.value).toFixed(2);
192
- }
193
- if (helM2) {
194
- const v = $("#helM2Val");
195
- if (v) v.textContent = (+helM2.value).toFixed(1);
196
- }
197
- if (helJitter) {
198
- const v = $("#helJitterVal");
199
- if (v) v.textContent = (+helJitter.value).toFixed(1);
200
- }
201
- if (helDuty) {
202
- const v = $("#helDutyVal");
203
- if (v) v.textContent = helDuty.value;
204
- }
205
- if (atmVis) {
206
- const v = $("#atmVisVal");
207
- if (v) v.textContent = atmVis.value;
208
- }
209
- if (atmCn2) {
210
- const v = $("#atmCn2Val");
211
- if (v) v.textContent = atmCn2.value;
212
- }
213
- if (seaSpray) {
214
- const v = $("#seaSprayVal");
215
- if (v) v.textContent = seaSpray.value;
216
- }
217
- if (aoQ) {
218
- const v = $("#aoQVal");
219
- if (v) v.textContent = aoQ.value;
220
- }
221
- if (rangeBase) {
222
- const v = $("#rangeBaseVal");
223
- if (v) v.textContent = rangeBase.value;
224
- }
225
- if (detHz) {
226
- const v = $("#detHzVal");
227
- if (v) v.textContent = detHz.value;
228
- }
229
- if (assessWindow) {
230
- const v = $("#assessWindowVal");
231
- if (v) v.textContent = (+assessWindow.value).toFixed(1);
232
- }
233
-
234
- // Update chips
235
- const chipPolicy = $("#chipPolicy");
236
- const chipHz = $("#chipHz");
237
- if (chipPolicy && policyMode) chipPolicy.textContent = `POLICY:${policyMode.value.toUpperCase()}`;
238
- if (chipHz && detHz) chipHz.textContent = `DET:${detHz.value}Hz`;
239
-
240
- // Update telemetry footer
241
- const telemetry = $("#telemetry");
242
- if (telemetry && helPower && atmVis && atmCn2 && aoQ && detHz) {
243
- telemetry.textContent = `VIS=${atmVis.value}km · DET=${detHz.value}Hz`;
244
- }
245
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/physics.js DELETED
@@ -1,57 +0,0 @@
1
- APP.core.physics = {};
2
-
3
- APP.core.physics.defaultAimpoint = function (label) {
4
- const l = (label || "object").toLowerCase();
5
- if (l.includes("airplane") || l.includes("drone") || l.includes("uav") || l.includes("kite") || l.includes("bird")) {
6
- return { relx: 0.62, rely: 0.55, label: "engine" };
7
- }
8
- if (l.includes("helicopter")) {
9
- return { relx: 0.50, rely: 0.45, label: "rotor_hub" };
10
- }
11
- if (l.includes("boat") || l.includes("ship")) {
12
- return { relx: 0.60, rely: 0.55, label: "bridge/engine" };
13
- }
14
- if (l.includes("truck") || l.includes("car")) {
15
- return { relx: 0.55, rely: 0.62, label: "engine_block" };
16
- }
17
- return { relx: 0.50, rely: 0.55, label: "center_mass" };
18
- };
19
-
20
- APP.core.physics.aimpointByLabel = function (label) {
21
- const l = String(label || "").toLowerCase();
22
- if (l.includes("engine") || l.includes("fuel")) return { relx: 0.64, rely: 0.58, label: label };
23
- if (l.includes("wing")) return { relx: 0.42, rely: 0.52, label: label };
24
- if (l.includes("nose") || l.includes("sensor")) return { relx: 0.28, rely: 0.48, label: label };
25
- if (l.includes("rotor")) return { relx: 0.52, rely: 0.42, label: label };
26
- return { relx: 0.50, rely: 0.55, label: label || "center_mass" };
27
- };
28
-
29
- APP.core.physics.getKnobs = function () {
30
- const { $ } = APP.core.utils;
31
- const helPower = $("#helPower");
32
- const helAperture = $("#helAperture");
33
- const helM2 = $("#helM2");
34
- const helJitter = $("#helJitter");
35
- const helDuty = $("#helDuty");
36
- const helMode = $("#helMode");
37
- const atmVis = $("#atmVis");
38
- const atmCn2 = $("#atmCn2");
39
- const seaSpray = $("#seaSpray");
40
- const aoQ = $("#aoQ");
41
- const rangeBase = $("#rangeBase");
42
-
43
- if (!helPower) return {};
44
-
45
- const PkW = +helPower.value;
46
- const aperture = +helAperture.value;
47
- const M2 = +helM2.value;
48
- const jitter_urad = +helJitter.value;
49
- const duty = (+helDuty.value) / 100;
50
- const mode = helMode ? helMode.value : "cw";
51
- const vis_km = +atmVis.value;
52
- const cn2 = +atmCn2.value;
53
- const spray = +seaSpray.value;
54
- const ao = +aoQ.value;
55
- const baseRange = +rangeBase.value;
56
- return { PkW, aperture, M2, jitter_urad, duty, mode, vis_km, cn2, spray, ao, baseRange };
57
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/state.js DELETED
@@ -1,80 +0,0 @@
1
- APP.core.state = {
2
- videoUrl: null,
3
- videoFile: null,
4
- videoLoaded: false,
5
- useProcessedFeed: false,
6
- useDepthFeed: false, // Flag for depth view (Tab 2 video)
7
- useFrameDepthView: false, // Flag for first frame depth view (Tab 1)
8
- hasReasoned: false,
9
- firstFrameReady: false, // Flag for first frame radar display
10
- isReasoning: false, // Flag to prevent concurrent Reason executions
11
-
12
- hf: {
13
- // Will be properly initialized after CONFIG loads
14
- baseUrl: (window.API_CONFIG?.BACKEND_BASE || window.API_CONFIG?.BASE_URL || "").replace(/\/$/, "") || window.location.origin,
15
- detector: "auto",
16
- asyncJobId: null, // Current job ID from /detect/async
17
- completedJobId: null, // Preserved job ID for post-completion track sync
18
- asyncPollInterval: null, // Polling timer handle
19
- firstFrameUrl: null, // First frame preview URL
20
- firstFrameDetections: null, // First-frame detections from backend
21
- statusUrl: null, // Status polling URL
22
- videoUrl: null, // Final video URL
23
- asyncStatus: "idle", // "idle"|"processing"|"completed"|"failed"
24
- asyncProgress: null, // Progress data from status endpoint
25
- queries: [], // Mission objective used as query
26
- processedUrl: null,
27
- processedBlob: null,
28
- depthVideoUrl: null, // Depth video URL
29
- depthFirstFrameUrl: null, // First frame depth URL
30
- depthBlob: null, // Depth video blob
31
- depthFirstFrameBlob: null, // Depth first frame blob
32
- summary: null,
33
- busy: false,
34
- lastError: null,
35
- missionId: null,
36
- missionSpec: null,
37
- plan: null
38
- },
39
-
40
- detector: {
41
- mode: "coco",
42
- kind: "object",
43
- loaded: false,
44
- model: null,
45
- loading: false,
46
- cocoBlocked: false,
47
- hfTrackingWarned: false
48
- },
49
-
50
- tracker: {
51
- mode: "iou",
52
- tracks: [],
53
- nextId: 1,
54
- lastDetTime: 0,
55
- lastHFSync: 0,
56
- running: false,
57
- selectedTrackId: null,
58
- beamOn: false,
59
- lastFrameTime: 0,
60
- frameCount: 0,
61
- _lastCardRenderFrame: 0, // Frame count at last card render
62
- _gptBusy: false // Prevent overlapping GPT calls
63
- },
64
-
65
- frame: {
66
- w: 1280,
67
- h: 720,
68
- bitmap: null
69
- },
70
-
71
- detections: [], // from Tab 1
72
- selectedId: null,
73
-
74
- intelBusy: false,
75
-
76
- ui: {
77
- cursorMode: "on",
78
- agentCursor: { x: 0.65, y: 0.28, vx: 0, vy: 0, visible: false, target: null, mode: "idle", t0: 0 }
79
- }
80
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/tracker.js DELETED
@@ -1,278 +0,0 @@
1
- APP.core.tracker = {};
2
-
3
- APP.core.tracker.matchAndUpdateTracks = function (dets, dtSec) {
4
- const { state } = APP.core;
5
- const { CONFIG } = APP.core;
6
- const { normBBox, lerp, now, $ } = APP.core.utils;
7
- const { defaultAimpoint } = APP.core.physics;
8
- const { log } = APP.ui.logging;
9
-
10
- const videoEngage = $("#videoEngage");
11
- const rangeBase = $("#rangeBase"); // Fixed Selector
12
-
13
- if (!videoEngage) return;
14
-
15
- // IOU helper
16
- function iou(a, b) {
17
- const ax2 = a.x + a.w, ay2 = a.y + a.h;
18
- const bx2 = b.x + b.w, by2 = b.y + b.h;
19
- const ix1 = Math.max(a.x, b.x), iy1 = Math.max(a.y, b.y);
20
- const ix2 = Math.min(ax2, bx2), iy2 = Math.min(ay2, by2);
21
- const iw = Math.max(0, ix2 - ix1), ih = Math.max(0, iy2 - iy1);
22
- const inter = iw * ih;
23
- const ua = a.w * a.h + b.w * b.h - inter;
24
- return ua <= 0 ? 0 : inter / ua;
25
- }
26
-
27
- // Convert detections to bbox in video coordinates
28
- const w = videoEngage.videoWidth || state.frame.w;
29
- const h = videoEngage.videoHeight || state.frame.h;
30
-
31
- const detObjs = dets.map(d => ({
32
- bbox: normBBox(d.bbox, w, h),
33
- label: d.class,
34
- score: d.score,
35
- depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null,
36
- depth_est_m: d.depth_est_m,
37
- depth_valid: d.depth_valid
38
- }));
39
-
40
- // mark all tracks as unmatched
41
- const tracks = state.tracker.tracks;
42
- const used = new Set();
43
-
44
- for (const tr of tracks) {
45
- let best = null;
46
- let bestI = 0.0;
47
- let bestIdx = -1;
48
- for (let i = 0; i < detObjs.length; i++) {
49
- if (used.has(i)) continue;
50
- const IoU = iou(tr.bbox, detObjs[i].bbox);
51
- if (IoU > bestI) {
52
- bestI = IoU;
53
- best = detObjs[i];
54
- bestIdx = i;
55
- }
56
- }
57
-
58
- // Strict matching threshold
59
- if (best && bestI >= CONFIG.TRACK_MATCH_THRESHOLD) {
60
- used.add(bestIdx);
61
-
62
- // Velocity with Exponential Moving Average (EMA) for smoothing
63
- const cx0 = tr.bbox.x + tr.bbox.w * 0.5;
64
- const cy0 = tr.bbox.y + tr.bbox.h * 0.5;
65
- const cx1 = best.bbox.x + best.bbox.w * 0.5;
66
- const cy1 = best.bbox.y + best.bbox.h * 0.5;
67
-
68
- const rawVx = (cx1 - cx0) / Math.max(1e-3, dtSec);
69
- const rawVy = (cy1 - cy0) / Math.max(1e-3, dtSec);
70
-
71
- // Alpha of 0.3 means 30% new value, 70% history
72
- tr.vx = tr.vx * 0.7 + rawVx * 0.3;
73
- tr.vy = tr.vy * 0.7 + rawVy * 0.3;
74
-
75
- // smooth bbox update
76
- tr.bbox.x = lerp(tr.bbox.x, best.bbox.x, 0.7);
77
- tr.bbox.y = lerp(tr.bbox.y, best.bbox.y, 0.7);
78
- tr.bbox.w = lerp(tr.bbox.w, best.bbox.w, 0.6);
79
- tr.bbox.h = lerp(tr.bbox.h, best.bbox.h, 0.6);
80
-
81
- // Logic: Only update label if the new detection is highly confident
82
- // AND the current track doesn't have a "premium" label (like 'drone').
83
- const protectedLabels = ["drone", "uav", "missile"];
84
- const isProtected = protectedLabels.some(l => (tr.label || "").toLowerCase().includes(l));
85
-
86
- if (!isProtected || (best.label && protectedLabels.some(l => best.label.toLowerCase().includes(l)))) {
87
- tr.label = best.label || tr.label;
88
- }
89
-
90
- tr.score = best.score || tr.score;
91
- if (Number.isFinite(best.depth_rel)) {
92
- tr.depth_rel = best.depth_rel;
93
- }
94
- if (best.depth_valid) {
95
- // EMA Smoothing
96
- const newD = best.depth_est_m;
97
- if (tr.depth_est_m == null) tr.depth_est_m = newD;
98
- else tr.depth_est_m = tr.depth_est_m * 0.7 + newD * 0.3;
99
- tr.depth_valid = true;
100
- }
101
- tr.lastSeen = now();
102
- } else {
103
- // Decay velocity
104
- tr.vx *= 0.9;
105
- tr.vy *= 0.9;
106
- }
107
- }
108
-
109
- // Limit total tracks
110
- if (tracks.length < CONFIG.MAX_TRACKS) {
111
- for (let i = 0; i < detObjs.length; i++) {
112
- if (used.has(i)) continue;
113
- // create new track only if big enough
114
- const a = detObjs[i].bbox.w * detObjs[i].bbox.h;
115
- if (a < (w * h) * 0.0005) continue;
116
-
117
- const newId = `T${String(state.tracker.nextId++).padStart(2, "0")}`;
118
- const ap = defaultAimpoint(detObjs[i].label);
119
- tracks.push({
120
- id: newId,
121
- label: detObjs[i].label,
122
- bbox: { ...detObjs[i].bbox },
123
- score: detObjs[i].score,
124
- aimRel: { relx: ap.relx, rely: ap.rely, label: ap.label },
125
- baseAreaFrac: (detObjs[i].bbox.w * detObjs[i].bbox.h) / (w * h),
126
- baseRange_m: rangeBase ? +rangeBase.value : 1000,
127
- baseDwell_s: 5.5,
128
- reqP_kW: 42,
129
- depth_rel: detObjs[i].depth_rel,
130
- depth_est_m: detObjs[i].depth_est_m,
131
- depth_valid: detObjs[i].depth_valid,
132
-
133
- // GPT properties
134
- gpt_distance_m: null,
135
- gpt_direction: null,
136
- gpt_description: null,
137
-
138
- // Track state
139
- lastSeen: now(),
140
- vx: 0, vy: 0,
141
- dwellAccum: 0,
142
- killed: false,
143
- state: "TRACK",
144
- assessT: 0
145
- });
146
- log(`New track created: ${newId} (${detObjs[i].label})`, "t");
147
- }
148
- }
149
-
150
- // prune old tracks
151
- const tNow = now();
152
- state.tracker.tracks = tracks.filter(tr => (tNow - tr.lastSeen) < CONFIG.TRACK_PRUNE_MS || tr.killed);
153
- };
154
-
155
- // Polling for backend tracks
156
- APP.core.tracker.syncWithBackend = async function (frameIdx) {
157
- const { state } = APP.core;
158
- const { $ } = APP.core.utils;
159
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
160
-
161
- if (!jobId || !state.hf.baseUrl) return;
162
-
163
- try {
164
- const resp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/${frameIdx}`);
165
- if (!resp.ok) return;
166
-
167
- const dets = await resp.json();
168
- if (!dets || !Array.isArray(dets)) return;
169
-
170
- // Transform backend format to frontend track format
171
- // Backend: { bbox: [x1, y1, x2, y2], label: "car", track_id: "T01", angle_deg: 90, ... }
172
- // Frontend: { id: "T01", bbox: {x,y,w,h}, label: "car", angle_deg: 90, ... }
173
-
174
- const videoEngage = $("#videoEngage");
175
- const w = videoEngage ? (videoEngage.videoWidth || state.frame.w) : state.frame.w;
176
- const h = videoEngage ? (videoEngage.videoHeight || state.frame.h) : state.frame.h;
177
-
178
- const newTracks = dets.map(d => {
179
- const x = d.bbox[0], y = d.bbox[1];
180
- const wBox = d.bbox[2] - d.bbox[0];
181
- const hBox = d.bbox[3] - d.bbox[1];
182
-
183
- // Normalize
184
- const nx = x / w;
185
- const ny = y / h;
186
- const nw = wBox / w;
187
- const nh = hBox / h;
188
-
189
- return {
190
- id: d.track_id || `T${Math.floor(Math.random() * 1000)}`, // Fallback
191
- label: d.label,
192
- bbox: { x: nx, y: ny, w: nw, h: nh },
193
- score: d.score,
194
- angle_deg: d.angle_deg,
195
- gpt_distance_m: d.gpt_distance_m,
196
- gpt_direction: d.gpt_direction,
197
- gpt_description: d.gpt_description,
198
- speed_kph: d.speed_kph,
199
- depth_est_m: d.depth_est_m,
200
- depth_rel: d.depth_rel,
201
- depth_valid: d.depth_valid,
202
- // Threat intelligence
203
- threat_level_score: d.threat_level_score || 0,
204
- threat_classification: d.threat_classification || "Unknown",
205
- weapon_readiness: d.weapon_readiness || "Unknown",
206
- // Mission relevance and assessment status
207
- mission_relevant: d.mission_relevant ?? null,
208
- relevance_reason: d.relevance_reason || null,
209
- assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
210
- assessment_frame_index: d.assessment_frame_index ?? null,
211
- // GPT raw data for feature table
212
- gpt_raw: d.gpt_raw || null,
213
- features: APP.core.gptMapping.buildFeatures(d.gpt_raw),
214
- // Keep UI state fields
215
- lastSeen: Date.now(),
216
- state: "TRACK"
217
- };
218
- });
219
-
220
- // Preserve GPT enrichment data that syncWithBackend would otherwise wipe
221
- const gptCache = state.hf.firstFrameDetections;
222
- if (gptCache && gptCache.length > 0) {
223
- const gptMap = {};
224
- for (const gd of gptCache) {
225
- const tid = gd.track_id || `T${String(gptCache.indexOf(gd) + 1).padStart(2, "0")}`;
226
- if (gd.gpt_raw) gptMap[tid] = gd;
227
- }
228
- for (const track of newTracks) {
229
- const cached = gptMap[track.id];
230
- if (!cached || track.gpt_raw) continue;
231
- const g = cached.gpt_raw;
232
- track.gpt_raw = g;
233
- track.assessment_status = cached.assessment_status || APP.core.gptMapping.STATUS.ASSESSED;
234
- track.threat_level_score = cached.threat_level_score || g.threat_level_score || 0;
235
- track.threat_classification = cached.threat_classification || g.threat_classification || "Unknown";
236
- track.weapon_readiness = cached.weapon_readiness || g.weapon_readiness || "Unknown";
237
- track.gpt_distance_m = cached.gpt_distance_m || null;
238
- track.gpt_direction = cached.gpt_direction || null;
239
- track.mission_relevant = cached.mission_relevant ?? track.mission_relevant;
240
- track.relevance_reason = cached.relevance_reason || track.relevance_reason;
241
- track.features = APP.core.gptMapping.buildFeatures(g);
242
- }
243
- }
244
-
245
- // Detect new objects before state update
246
- const oldIds = new Set(state.tracker.tracks.map(t => t.id));
247
- const brandNew = newTracks.filter(t => !oldIds.has(t.id));
248
- if (brandNew.length > 0) {
249
- state.tracker._newObjectDetected = true;
250
- APP.ui.logging.log(`New objects: ${brandNew.map(t => t.id).join(", ")}`, "t");
251
- }
252
-
253
- // Update state
254
- state.tracker.tracks = newTracks;
255
- state.detections = newTracks; // Keep synced
256
-
257
- } catch (e) {
258
- console.warn("Track sync failed", e);
259
- }
260
- };
261
-
262
- APP.core.tracker.predictTracks = function (dtSec) {
263
- const { state } = APP.core;
264
- const { $ } = APP.core.utils;
265
- const videoEngage = $("#videoEngage");
266
- if (!videoEngage) return;
267
- const w = videoEngage.videoWidth || state.frame.w;
268
- const h = videoEngage.videoHeight || state.frame.h;
269
-
270
- // Simple clamp util locally or imported
271
- const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
272
-
273
- state.tracker.tracks.forEach(tr => {
274
- if (tr.killed) return;
275
- tr.bbox.x = clamp(tr.bbox.x + tr.vx * dtSec * 0.12, 0, w - 1);
276
- tr.bbox.y = clamp(tr.bbox.y + tr.vy * dtSec * 0.12, 0, h - 1);
277
- });
278
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/utils.js DELETED
@@ -1,55 +0,0 @@
1
- APP.core.utils = {};
2
-
3
- APP.core.utils.$ = (sel, root = document) => root.querySelector(sel);
4
- APP.core.utils.$$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
5
-
6
- APP.core.utils.clamp = (x, a, b) => Math.min(b, Math.max(a, x));
7
- APP.core.utils.lerp = (a, b, t) => a + (b - a) * t;
8
- APP.core.utils.now = () => performance.now();
9
-
10
- APP.core.utils.escapeHtml = function (s) {
11
- return String(s).replace(/[&<>"']/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[m]));
12
- };
13
-
14
- APP.core.utils.canvasToBlob = function (canvas, quality = 0.88) {
15
- return new Promise((resolve, reject) => {
16
- if (!canvas.toBlob) { reject(new Error("Canvas.toBlob not supported")); return; }
17
- canvas.toBlob(blob => {
18
- if (!blob) { reject(new Error("Canvas toBlob failed")); return; }
19
- resolve(blob);
20
- }, "image/jpeg", quality);
21
- });
22
- };
23
-
24
- APP.core.utils.normBBox = function (bbox, w, h) {
25
- const [x, y, bw, bh] = bbox;
26
- return {
27
- x: APP.core.utils.clamp(x, 0, w - 1),
28
- y: APP.core.utils.clamp(y, 0, h - 1),
29
- w: APP.core.utils.clamp(bw, 1, w),
30
- h: APP.core.utils.clamp(bh, 1, h)
31
- };
32
- };
33
-
34
- APP.core.utils.loadedScripts = new Map();
35
-
36
- APP.core.utils.loadScriptOnce = function (key, src) {
37
- return new Promise((resolve, reject) => {
38
- if (APP.core.utils.loadedScripts.get(key) === "loaded") { resolve(); return; }
39
- if (APP.core.utils.loadedScripts.get(key) === "loading") {
40
- const iv = setInterval(() => {
41
- if (APP.core.utils.loadedScripts.get(key) === "loaded") { clearInterval(iv); resolve(); }
42
- if (APP.core.utils.loadedScripts.get(key) === "failed") { clearInterval(iv); reject(new Error("Script failed earlier")); }
43
- }, 50);
44
- return;
45
- }
46
-
47
- APP.core.utils.loadedScripts.set(key, "loading");
48
- const s = document.createElement("script");
49
- s.src = src;
50
- s.async = true;
51
- s.onload = () => { APP.core.utils.loadedScripts.set(key, "loaded"); resolve(); };
52
- s.onerror = () => { APP.core.utils.loadedScripts.set(key, "failed"); reject(new Error(`Failed to load ${src}`)); };
53
- document.head.appendChild(s);
54
- });
55
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/video.js DELETED
@@ -1,621 +0,0 @@
1
- // Video management: loading, unloading, first frame capture, depth handling
2
- APP.core.video = {};
3
-
4
- APP.core.video.captureFirstFrame = async function () {
5
- const { state } = APP.core;
6
- const { $ } = APP.core.utils;
7
- const videoHidden = $("#videoHidden");
8
-
9
- if (!videoHidden || !state.videoUrl) return;
10
-
11
- videoHidden.src = state.videoUrl;
12
- videoHidden.load();
13
-
14
- await new Promise((resolve, reject) => {
15
- videoHidden.onloadeddata = resolve;
16
- videoHidden.onerror = () => reject(new Error("Video failed to load"));
17
- setTimeout(() => reject(new Error("Video load timeout")), 10000);
18
- });
19
-
20
- // Seek to first frame
21
- videoHidden.currentTime = 0;
22
- await new Promise(r => {
23
- videoHidden.onseeked = r;
24
- setTimeout(r, 500);
25
- });
26
-
27
- state.frame.w = videoHidden.videoWidth || 1280;
28
- state.frame.h = videoHidden.videoHeight || 720;
29
- };
30
-
31
- APP.core.video.drawFirstFrame = function () {
32
- const { state } = APP.core;
33
- const { $ } = APP.core.utils;
34
- const frameCanvas = $("#frameCanvas");
35
- const frameOverlay = $("#frameOverlay");
36
- const videoHidden = $("#videoHidden");
37
- const frameEmpty = $("#frameEmpty");
38
-
39
- if (!frameCanvas || !videoHidden) return;
40
-
41
- const ctx = frameCanvas.getContext("2d");
42
- frameCanvas.width = state.frame.w;
43
- frameCanvas.height = state.frame.h;
44
- ctx.drawImage(videoHidden, 0, 0, state.frame.w, state.frame.h);
45
-
46
- // Also resize overlay to match so bbox coordinates align
47
- if (frameOverlay) {
48
- frameOverlay.width = state.frame.w;
49
- frameOverlay.height = state.frame.h;
50
- }
51
-
52
- if (frameEmpty) frameEmpty.style.display = "none";
53
- };
54
-
55
- APP.core.video.frameToBitmap = async function (videoEl) {
56
- const w = videoEl.videoWidth || 1280;
57
- const h = videoEl.videoHeight || 720;
58
- const canvas = document.createElement("canvas");
59
- canvas.width = w;
60
- canvas.height = h;
61
- const ctx = canvas.getContext("2d");
62
- ctx.drawImage(videoEl, 0, 0, w, h);
63
- return canvas;
64
- };
65
-
66
- APP.core.video.seekTo = function (videoEl, time) {
67
- return new Promise((resolve) => {
68
- if (!videoEl) { resolve(); return; }
69
- videoEl.currentTime = Math.max(0, time);
70
- videoEl.onseeked = () => resolve();
71
- setTimeout(resolve, 600);
72
- });
73
- };
74
-
75
- APP.core.video.unloadVideo = async function (options = {}) {
76
- const { state } = APP.core;
77
- const { $, $$ } = APP.core.utils;
78
- const { log, setStatus, setHfStatus } = APP.ui.logging;
79
- const preserveInput = !!options.preserveInput;
80
-
81
- // Stop polling if running
82
- if (state.hf.asyncPollInterval) {
83
- clearInterval(state.hf.asyncPollInterval);
84
- state.hf.asyncPollInterval = null;
85
- }
86
-
87
- // Revoke blob URLs
88
- if (state.videoUrl && state.videoUrl.startsWith("blob:")) {
89
- URL.revokeObjectURL(state.videoUrl);
90
- }
91
- if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) {
92
- try { URL.revokeObjectURL(state.hf.processedUrl); } catch (_) { }
93
- }
94
- if (state.hf.depthVideoUrl && state.hf.depthVideoUrl.startsWith("blob:")) {
95
- try { URL.revokeObjectURL(state.hf.depthVideoUrl); } catch (_) { }
96
- }
97
- if (state.hf.depthFirstFrameUrl && state.hf.depthFirstFrameUrl.startsWith("blob:")) {
98
- try { URL.revokeObjectURL(state.hf.depthFirstFrameUrl); } catch (_) { }
99
- }
100
-
101
- // Reset state
102
- state.videoUrl = null;
103
- state.videoFile = null;
104
- state.videoLoaded = false;
105
- state.useProcessedFeed = false;
106
- state.useDepthFeed = false;
107
- state.useFrameDepthView = false;
108
-
109
- state.hf.missionId = null;
110
- state.hf.plan = null;
111
- state.hf.processedUrl = null;
112
- state.hf.processedBlob = null;
113
- state.hf.depthVideoUrl = null;
114
- state.hf.depthBlob = null;
115
- state.hf.depthFirstFrameUrl = null;
116
- state.hf.depthFirstFrameBlob = null;
117
- state.hf.summary = null;
118
- state.hf.busy = false;
119
- state.hf.lastError = null;
120
- state.hf.asyncJobId = null;
121
- state.hf.completedJobId = null;
122
- state.hf.asyncStatus = "idle";
123
- state.hf.firstFrameUrl = null;
124
- state.hf.videoUrl = null;
125
-
126
- setHfStatus("idle");
127
- state.hasReasoned = false;
128
- state.firstFrameReady = false;
129
- state.isReasoning = false;
130
-
131
- // Reset button states
132
- const btnReason = $("#btnReason");
133
- const btnCancelReason = $("#btnCancelReason");
134
- const btnEngage = $("#btnEngage");
135
-
136
- if (btnReason) {
137
- btnReason.disabled = false;
138
- btnReason.style.opacity = "1";
139
- btnReason.style.cursor = "pointer";
140
- }
141
- if (btnCancelReason) btnCancelReason.style.display = "none";
142
- if (btnEngage) btnEngage.disabled = true;
143
-
144
- state.detections = [];
145
- state.selectedId = null;
146
-
147
- state.tracker.tracks = [];
148
- state.tracker.nextId = 1;
149
- state.tracker.running = false;
150
- state.tracker.selectedTrackId = null;
151
- state.tracker.beamOn = false;
152
-
153
- // Clear video elements
154
- const videoHidden = $("#videoHidden");
155
- const videoEngage = $("#videoEngage");
156
- const videoFile = $("#videoFile");
157
-
158
- if (videoHidden) {
159
- videoHidden.removeAttribute("src");
160
- videoHidden.load();
161
- }
162
- if (videoEngage) {
163
- videoEngage.removeAttribute("src");
164
- videoEngage.load();
165
- }
166
- if (!preserveInput && videoFile) {
167
- videoFile.value = "";
168
- }
169
-
170
- // Update UI
171
- const videoMeta = $("#videoMeta");
172
- const frameEmpty = $("#frameEmpty");
173
- const engageEmpty = $("#engageEmpty");
174
- const frameNote = $("#frameNote");
175
- const engageNote = $("#engageNote");
176
-
177
- if (!preserveInput && videoMeta) videoMeta.textContent = "No file";
178
- if (frameEmpty) frameEmpty.style.display = "flex";
179
- if (engageEmpty) engageEmpty.style.display = "flex";
180
- if (frameNote) frameNote.textContent = "Awaiting video";
181
- if (engageNote) engageNote.textContent = "Awaiting video";
182
-
183
- // Clear canvases
184
- APP.core.video.clearCanvas($("#frameCanvas"));
185
- APP.core.video.clearCanvas($("#frameOverlay"));
186
- APP.core.video.clearCanvas($("#engageOverlay"));
187
-
188
- // Re-render UI components
189
- if (APP.ui.cards.renderFrameTrackList) APP.ui.cards.renderFrameTrackList();
190
-
191
- setStatus("warn", "STANDBY · No video loaded");
192
- log("Video unloaded. Demo reset.", "w");
193
- };
194
-
195
- APP.core.video.clearCanvas = function (canvas) {
196
- if (!canvas) return;
197
- const ctx = canvas.getContext("2d");
198
- ctx.clearRect(0, 0, canvas.width, canvas.height);
199
- };
200
-
201
- APP.core.video.resizeOverlays = function () {
202
- const { state } = APP.core;
203
- const { $ } = APP.core.utils;
204
-
205
- const videoEngage = $("#videoEngage");
206
- const engageOverlay = $("#engageOverlay");
207
- const frameOverlay = $("#frameOverlay");
208
-
209
- if (videoEngage && engageOverlay) {
210
- const w = videoEngage.videoWidth || state.frame.w;
211
- const h = videoEngage.videoHeight || state.frame.h;
212
- engageOverlay.width = w;
213
- engageOverlay.height = h;
214
- engageOverlay.style.pointerEvents = "auto";
215
- }
216
-
217
- if (frameOverlay) {
218
- frameOverlay.width = state.frame.w;
219
- frameOverlay.height = state.frame.h;
220
- }
221
- };
222
-
223
- // Depth video handling
224
- APP.core.video.fetchDepthVideo = async function () {
225
- const { state } = APP.core;
226
- const { log } = APP.ui.logging;
227
-
228
- // Depth is optional - skip silently if no URL
229
- if (!state.hf.depthVideoUrl) {
230
- return;
231
- }
232
-
233
- try {
234
- const resp = await fetch(state.hf.depthVideoUrl, { cache: "no-store" });
235
-
236
- if (!resp.ok) {
237
- // 404 = depth not enabled/available - this is fine, not an error
238
- if (resp.status === 404) {
239
- state.hf.depthVideoUrl = null;
240
- return;
241
- }
242
- // 202 = still processing
243
- if (resp.status === 202) {
244
- return;
245
- }
246
- throw new Error(`Failed to fetch depth video: ${resp.statusText}`);
247
- }
248
-
249
- const nullOrigin = (window.location && window.location.origin) === "null" || (window.location && window.location.protocol === "file:");
250
- if (nullOrigin) {
251
- state.hf.depthBlob = null;
252
- state.hf.depthVideoUrl = `${state.hf.depthVideoUrl}?t=${Date.now()}`;
253
- log("Depth video ready (streaming URL)");
254
- return;
255
- }
256
-
257
- const blob = await resp.blob();
258
- state.hf.depthBlob = blob;
259
- const blobUrl = URL.createObjectURL(blob);
260
- state.hf.depthVideoUrl = blobUrl;
261
-
262
- log(`Depth video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB)`, "g");
263
- APP.core.video.updateDepthChip();
264
- } catch (err) {
265
- // Don't log as error - depth is optional
266
- state.hf.depthVideoUrl = null;
267
- state.hf.depthBlob = null;
268
- }
269
- };
270
-
271
- APP.core.video.fetchDepthFirstFrame = async function () {
272
- const { state } = APP.core;
273
- const { log } = APP.ui.logging;
274
-
275
- // Depth is optional - skip silently if no URL
276
- if (!state.hf.depthFirstFrameUrl) return;
277
-
278
- try {
279
- const resp = await fetch(state.hf.depthFirstFrameUrl, { cache: "no-store" });
280
-
281
- // 404 or other errors - depth not available, that's fine
282
- if (!resp.ok) {
283
- state.hf.depthFirstFrameUrl = null;
284
- return;
285
- }
286
-
287
- const blob = await resp.blob();
288
- state.hf.depthFirstFrameBlob = blob;
289
- state.hf.depthFirstFrameUrl = URL.createObjectURL(blob);
290
- log("First frame depth ready", "g");
291
- } catch (err) {
292
- // Silently clear - depth is optional
293
- state.hf.depthFirstFrameUrl = null;
294
- state.hf.depthFirstFrameBlob = null;
295
- }
296
- };
297
-
298
- APP.core.video.fetchProcessedVideo = async function () {
299
- const { state } = APP.core;
300
- const { log } = APP.ui.logging;
301
- const { $ } = APP.core.utils;
302
-
303
- const resp = await fetch(state.hf.videoUrl, { cache: "no-store" });
304
-
305
- if (!resp.ok) {
306
- if (resp.status === 202) {
307
- const err = new Error("Video still processing");
308
- err.code = "VIDEO_PENDING";
309
- throw err;
310
- }
311
- throw new Error(`Failed to fetch video: ${resp.statusText}`);
312
- }
313
-
314
- const nullOrigin = (window.location && window.location.origin) === "null" || (window.location && window.location.protocol === "file:");
315
- if (nullOrigin) {
316
- state.hf.processedBlob = null;
317
- state.hf.processedUrl = `${state.hf.videoUrl}?t=${Date.now()}`;
318
- const btnEngage = $("#btnEngage");
319
- if (btnEngage) btnEngage.disabled = false;
320
- log("Processed video ready (streaming URL)");
321
- return;
322
- }
323
-
324
- const blob = await resp.blob();
325
-
326
- if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) {
327
- URL.revokeObjectURL(state.hf.processedUrl);
328
- }
329
-
330
- state.hf.processedBlob = blob;
331
- state.hf.processedUrl = URL.createObjectURL(blob);
332
-
333
- const btnEngage = $("#btnEngage");
334
- if (btnEngage) btnEngage.disabled = false;
335
- log(`Processed video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB)`);
336
- };
337
-
338
- APP.core.video.updateDepthChip = function () {
339
- const { state } = APP.core;
340
- const { $ } = APP.core.utils;
341
-
342
- const chipDepth = $("#chipDepth");
343
- if (!chipDepth) return;
344
-
345
- if (state.hf.depthVideoUrl || state.hf.depthBlob) {
346
- chipDepth.style.cursor = "pointer";
347
- chipDepth.style.opacity = "1";
348
- } else {
349
- chipDepth.style.cursor = "not-allowed";
350
- chipDepth.style.opacity = "0.5";
351
- }
352
- };
353
-
354
- APP.core.video.toggleDepthView = function () {
355
- const { state } = APP.core;
356
- const { $, log } = APP.core.utils;
357
- const { log: uiLog } = APP.ui.logging;
358
-
359
- if (!state.hf.depthVideoUrl && !state.hf.depthBlob) {
360
- uiLog("Depth video not available yet. Run Reason and wait for processing.", "w");
361
- return;
362
- }
363
-
364
- state.useDepthFeed = !state.useDepthFeed;
365
-
366
- const videoEngage = $("#videoEngage");
367
- const chipDepth = $("#chipDepth");
368
-
369
- if (state.useDepthFeed) {
370
- if (chipDepth) chipDepth.textContent = "VIEW:DEPTH";
371
- if (videoEngage) {
372
- const currentTime = videoEngage.currentTime;
373
- const wasPlaying = !videoEngage.paused;
374
- videoEngage.src = state.hf.depthVideoUrl;
375
- videoEngage.load();
376
- videoEngage.currentTime = currentTime;
377
- if (wasPlaying) videoEngage.play();
378
- }
379
- } else {
380
- if (chipDepth) chipDepth.textContent = "VIEW:DEFAULT";
381
- if (videoEngage) {
382
- const currentTime = videoEngage.currentTime;
383
- const wasPlaying = !videoEngage.paused;
384
- const feedUrl = state.useProcessedFeed ? state.hf.processedUrl : state.videoUrl;
385
- videoEngage.src = feedUrl;
386
- videoEngage.load();
387
- videoEngage.currentTime = currentTime;
388
- if (wasPlaying) videoEngage.play();
389
- }
390
- }
391
- };
392
-
393
- APP.core.video.toggleFirstFrameDepthView = function () {
394
- const { state } = APP.core;
395
- const { $ } = APP.core.utils;
396
- const { log } = APP.ui.logging;
397
-
398
- if (!state.hf.depthFirstFrameUrl) {
399
- log("First frame depth not available", "w");
400
- return;
401
- }
402
-
403
- state.useFrameDepthView = !state.useFrameDepthView;
404
-
405
- const frameCanvas = $("#frameCanvas");
406
- const chipFrameDepth = $("#chipFrameDepth");
407
-
408
- if (state.useFrameDepthView) {
409
- if (chipFrameDepth) chipFrameDepth.textContent = "VIEW:DEPTH";
410
- // Draw depth first frame
411
- const img = new Image();
412
- img.onload = () => {
413
- if (frameCanvas) {
414
- frameCanvas.width = state.frame.w;
415
- frameCanvas.height = state.frame.h;
416
- frameCanvas.getContext("2d").drawImage(img, 0, 0, state.frame.w, state.frame.h);
417
- APP.ui.overlays.renderFrameOverlay();
418
- }
419
- };
420
- img.src = state.hf.depthFirstFrameUrl;
421
- } else {
422
- if (chipFrameDepth) chipFrameDepth.textContent = "VIEW:DEFAULT";
423
- // Re-draw original first frame
424
- APP.core.video.drawFirstFrame();
425
- APP.ui.overlays.renderFrameOverlay();
426
- }
427
- };
428
-
429
- APP.core.video.toggleProcessedFeed = function () {
430
- const { state } = APP.core;
431
- const { $ } = APP.core.utils;
432
- const { log } = APP.ui.logging;
433
-
434
- if (!state.hf.processedUrl) {
435
- log("Processed video not available yet", "w");
436
- return;
437
- }
438
-
439
- state.useProcessedFeed = !state.useProcessedFeed;
440
- state.useDepthFeed = false; // Reset depth view when switching feeds
441
-
442
- const videoEngage = $("#videoEngage");
443
- const chipFeed = $("#chipFeed");
444
- const chipDepth = $("#chipDepth");
445
-
446
- if (state.useProcessedFeed) {
447
- if (chipFeed) chipFeed.textContent = "FEED:HF";
448
- if (videoEngage) {
449
- const currentTime = videoEngage.currentTime;
450
- const wasPlaying = !videoEngage.paused;
451
- videoEngage.src = state.hf.processedUrl;
452
- videoEngage.load();
453
- videoEngage.currentTime = currentTime;
454
- if (wasPlaying) videoEngage.play();
455
- }
456
- } else {
457
- if (chipFeed) chipFeed.textContent = "FEED:RAW";
458
- if (videoEngage) {
459
- const currentTime = videoEngage.currentTime;
460
- const wasPlaying = !videoEngage.paused;
461
- videoEngage.src = state.videoUrl;
462
- videoEngage.load();
463
- videoEngage.currentTime = currentTime;
464
- if (wasPlaying) videoEngage.play();
465
- }
466
- }
467
-
468
- if (chipDepth) chipDepth.textContent = "VIEW:DEFAULT";
469
- };
470
-
471
- // ========= Streaming Mode for Tab 2 (Live Backend Processing) =========
472
-
473
- APP.core.video.setStreamingMode = function (url) {
474
- const { $ } = APP.core.utils;
475
- const videoEngage = $("#videoEngage");
476
- const engageEmpty = $("#engageEmpty");
477
-
478
- // Ensure stream image element exists
479
- let streamView = $("#streamView");
480
- if (!streamView) {
481
- streamView = document.createElement("img");
482
- streamView.id = "streamView";
483
- streamView.style.width = "100%";
484
- streamView.style.height = "100%";
485
- streamView.style.objectFit = "contain";
486
- streamView.style.position = "absolute";
487
- streamView.style.top = "0";
488
- streamView.style.left = "0";
489
- streamView.style.zIndex = "10"; // Above video
490
- streamView.style.backgroundColor = "#000";
491
-
492
- // Insert into the wrapper (parent of videoEngage)
493
- if (videoEngage && videoEngage.parentNode) {
494
- videoEngage.parentNode.appendChild(streamView);
495
- // Ensure container is relative for absolute positioning
496
- if (getComputedStyle(videoEngage.parentNode).position === "static") {
497
- videoEngage.parentNode.style.position = "relative";
498
- }
499
- }
500
- }
501
-
502
- if (streamView) {
503
- // Reset state
504
- streamView.style.display = "block";
505
- streamView.onerror = () => {
506
- // If stream fails (404 etc), silently revert
507
- streamView.style.display = "none";
508
- if (videoEngage) videoEngage.style.display = "block";
509
- if (engageEmpty && !videoEngage.src) engageEmpty.style.display = "flex";
510
- };
511
- streamView.src = url;
512
-
513
- if (videoEngage) videoEngage.style.display = "none";
514
-
515
- // Also hide empty state
516
- if (engageEmpty) engageEmpty.style.display = "none";
517
- }
518
- };
519
-
520
- APP.core.video.stopStreamingMode = function () {
521
- const { $ } = APP.core.utils;
522
- const videoEngage = $("#videoEngage");
523
-
524
- const streamView = $("#streamView");
525
- if (streamView) {
526
- streamView.src = ""; // Stop connection
527
- streamView.style.display = "none";
528
- }
529
- if (videoEngage) videoEngage.style.display = "block";
530
- };
531
-
532
- // ========= Display Processed First Frame (from backend) =========
533
-
534
- APP.core.video.displayProcessedFirstFrame = function () {
535
- const { state } = APP.core;
536
- const { $ } = APP.core.utils;
537
- const { log } = APP.ui.logging;
538
-
539
- const frameCanvas = $("#frameCanvas");
540
- const frameOverlay = $("#frameOverlay");
541
- const frameEmpty = $("#frameEmpty");
542
- const frameNote = $("#frameNote");
543
-
544
- if (!state.hf.firstFrameUrl) {
545
- log("Processed first frame URL not available", "w");
546
- return;
547
- }
548
-
549
- const img = new Image();
550
- img.crossOrigin = "anonymous";
551
- img.onload = () => {
552
- if (frameCanvas) {
553
- // Update frame dimensions from image
554
- state.frame.w = img.naturalWidth || 1280;
555
- state.frame.h = img.naturalHeight || 720;
556
-
557
- // Resize both canvas and overlay to match frame dimensions
558
- frameCanvas.width = state.frame.w;
559
- frameCanvas.height = state.frame.h;
560
- frameCanvas.getContext("2d").drawImage(img, 0, 0, state.frame.w, state.frame.h);
561
-
562
- // CRITICAL: Resize frameOverlay to match so bbox coordinates align
563
- if (frameOverlay) {
564
- frameOverlay.width = state.frame.w;
565
- frameOverlay.height = state.frame.h;
566
- }
567
-
568
- // Hide empty state
569
- if (frameEmpty) frameEmpty.style.display = "none";
570
- if (frameNote) frameNote.textContent = "Processed (from backend)";
571
-
572
- // Re-render overlay on top (now with matching dimensions)
573
- if (APP.ui.overlays.renderFrameOverlay) {
574
- APP.ui.overlays.renderFrameOverlay();
575
- }
576
-
577
- log("Processed first frame displayed", "g");
578
- }
579
- };
580
- img.onerror = () => {
581
- log("Failed to load processed first frame", "e");
582
- // Fallback to local first frame
583
- APP.core.video.drawFirstFrame();
584
- };
585
- img.src = state.hf.firstFrameUrl;
586
- };
587
-
588
- // ========= Display First Frame with Depth Overlay (if available) =========
589
-
590
- APP.core.video.displayFirstFrameWithDepth = function () {
591
- const { state } = APP.core;
592
- const { $ } = APP.core.utils;
593
-
594
- // Check if we're in depth view mode and depth is available
595
- if (state.useFrameDepthView && state.hf.depthFirstFrameUrl) {
596
- const frameCanvas = $("#frameCanvas");
597
- const img = new Image();
598
- img.crossOrigin = "anonymous";
599
- img.onload = () => {
600
- if (frameCanvas) {
601
- frameCanvas.width = state.frame.w;
602
- frameCanvas.height = state.frame.h;
603
- frameCanvas.getContext("2d").drawImage(img, 0, 0, state.frame.w, state.frame.h);
604
- if (APP.ui.overlays.renderFrameOverlay) {
605
- APP.ui.overlays.renderFrameOverlay();
606
- }
607
- }
608
- };
609
- img.onerror = () => {
610
- // Fallback to processed or raw first frame
611
- APP.core.video.displayProcessedFirstFrame();
612
- };
613
- img.src = state.hf.depthFirstFrameUrl;
614
- } else if (state.hf.firstFrameUrl) {
615
- // Show processed first frame
616
- APP.core.video.displayProcessedFirstFrame();
617
- } else {
618
- // Fallback to local video first frame
619
- APP.core.video.drawFirstFrame();
620
- }
621
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/init.js DELETED
@@ -1,6 +0,0 @@
1
- // Initialize Global Namespace
2
- window.APP = {
3
- core: {},
4
- ui: {},
5
- api: {}
6
- };
 
 
 
 
 
 
 
frontend/js/main.js DELETED
@@ -1,785 +0,0 @@
1
- // Main Entry Point - Wire up all event handlers and run the application
2
- document.addEventListener("DOMContentLoaded", () => {
3
- // Shortcuts
4
- const { state } = APP.core;
5
- const { $, $$ } = APP.core.utils;
6
- const { log, setStatus, setHfStatus } = APP.ui.logging;
7
- const { hfDetectAsync, checkJobStatus, cancelBackendJob, pollAsyncJob } = APP.api.client;
8
-
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 { renderFrameOverlay, renderEngageOverlay, initClickHandler } = APP.ui.overlays;
16
- const { renderFrameTrackList } = APP.ui.cards;
17
- const { tickAgentCursor, moveCursorToRect } = APP.ui.cursor;
18
- const { matchAndUpdateTracks, predictTracks } = APP.core.tracker;
19
- const { defaultAimpoint } = APP.core.physics;
20
-
21
- // DOM Elements
22
- const videoEngage = $("#videoEngage");
23
- const videoHidden = $("#videoHidden");
24
- const videoFile = $("#videoFile");
25
- const btnReason = $("#btnReason");
26
- const btnCancelReason = $("#btnCancelReason");
27
- const btnRecompute = $("#btnRecompute");
28
- const btnClear = $("#btnClear");
29
- const btnEject = $("#btnEject");
30
- const btnEngage = $("#btnEngage");
31
- const btnReset = $("#btnReset");
32
- const btnPause = $("#btnPause");
33
- const btnToggleSidebar = $("#btnToggleSidebar");
34
-
35
- const detectorSelect = $("#detectorSelect");
36
- const missionText = $("#missionText");
37
- const cursorMode = $("#cursorMode");
38
- const frameCanvas = $("#frameCanvas");
39
- const frameTrackList = $("#frameTrackList");
40
- const frameEmpty = $("#frameEmpty");
41
- const frameNote = $("#frameNote");
42
- const engageEmpty = $("#engageEmpty");
43
- const engageNote = $("#engageNote");
44
-
45
- const chipFeed = $("#chipFeed");
46
- const chipDepth = $("#chipDepth");
47
- const chipFrameDepth = $("#chipFrameDepth");
48
-
49
- // Initialization
50
- function init() {
51
- log("System initializing...", "t");
52
-
53
- setupFileUpload();
54
- setupControls();
55
- setupKnobListeners();
56
- setupChipToggles();
57
- setupTabSwitching();
58
-
59
- // Initial UI sync
60
- syncKnobDisplays();
61
- setHfStatus("idle");
62
-
63
- // Enable click-to-select on engage overlay
64
- initClickHandler();
65
-
66
- // Start main loop
67
- requestAnimationFrame(loop);
68
-
69
- // Load demo data (if available)
70
- loadDemo().then(() => {
71
- // hidden usage: enable if video filename matches "demo" or manually
72
- // APP.core.demo.enable(true);
73
- });
74
-
75
- log("System READY.", "g");
76
- }
77
-
78
- function setupFileUpload() {
79
- if (!videoFile) return;
80
-
81
- videoFile.addEventListener("change", async (e) => {
82
- const file = e.target.files[0];
83
- if (!file) return;
84
-
85
- state.videoFile = file;
86
- state.videoUrl = URL.createObjectURL(file);
87
- state.videoLoaded = true;
88
-
89
- // Show meta
90
- const videoMeta = $("#videoMeta");
91
- if (videoMeta) videoMeta.textContent = file.name;
92
-
93
- // Load video into engage player
94
- if (videoEngage) {
95
- videoEngage.src = state.videoUrl;
96
- videoEngage.load();
97
- }
98
-
99
- // Hide empty states
100
- if (engageEmpty) engageEmpty.style.display = "none";
101
-
102
- // Capture first frame dimensions (but don't draw - wait for processed frame from backend)
103
- try {
104
- await captureFirstFrame();
105
- // Show placeholder message - actual frame will come from backend
106
- if (frameNote) frameNote.textContent = "Video loaded (run Detect for processed frame)";
107
- if (engageNote) engageNote.textContent = "Ready for Track";
108
- } catch (err) {
109
- log(`First frame capture failed: ${err.message}`, "e");
110
- }
111
-
112
- setStatus("warn", "READY · Video loaded (run Detect)");
113
- log(`Video loaded: ${file.name}`, "g");
114
-
115
- // Load video-specific demo tracks (e.g., helicopter demo)
116
- if (APP.core.demo.loadForVideo) {
117
- await APP.core.demo.loadForVideo(file.name);
118
- }
119
-
120
- // Auto-enable demo mode if filename contains "demo" or helicopter video
121
- const shouldEnableDemo = file.name.toLowerCase().includes("demo") ||
122
- file.name.toLowerCase().includes("enhance_video_movement");
123
- if (shouldEnableDemo && APP.core.demo.data) {
124
- enableDemo(true);
125
- log("Auto-enabled DEMO mode for this video.", "g");
126
- }
127
- });
128
- }
129
-
130
- function setupControls() {
131
- // Reason button
132
- if (btnReason) {
133
- btnReason.addEventListener("click", runReason);
134
- }
135
-
136
- // Cancel Reason button
137
- if (btnCancelReason) {
138
- btnCancelReason.addEventListener("click", cancelReasoning);
139
- }
140
-
141
- // Recompute HEL button
142
- if (btnRecompute) {
143
- btnRecompute.addEventListener("click", async () => {
144
- if (!state.hasReasoned) return;
145
- await recomputeHEL();
146
- renderFrameOverlay();
147
-
148
- log("Parameters recomputed.", "g");
149
- });
150
- }
151
-
152
- // Clear button
153
- if (btnClear) {
154
- btnClear.addEventListener("click", () => {
155
- state.detections = [];
156
- state.selectedId = null;
157
- renderFrameTrackList();
158
- renderFrameOverlay();
159
-
160
- log("Detections cleared.", "t");
161
- });
162
- }
163
-
164
- // Eject button
165
- if (btnEject) {
166
- btnEject.addEventListener("click", async () => {
167
- await unloadVideo();
168
- });
169
- }
170
-
171
- // Engage button
172
- if (btnEngage) {
173
- btnEngage.addEventListener("click", runEngage);
174
- }
175
-
176
- // Pause button
177
- if (btnPause) {
178
- btnPause.addEventListener("click", () => {
179
- if (videoEngage) videoEngage.pause();
180
- state.tracker.running = false;
181
- log("Tracking paused.", "t");
182
- });
183
- }
184
-
185
- // Reset button
186
- if (btnReset) {
187
- btnReset.addEventListener("click", () => {
188
- if (videoEngage) {
189
- videoEngage.pause();
190
- videoEngage.currentTime = 0;
191
- }
192
- state.tracker.tracks = [];
193
- state.tracker.running = false;
194
- state.tracker.nextId = 1;
195
- renderFrameTrackList();
196
- log("Tracking reset.", "t");
197
- });
198
- }
199
-
200
- // Sidebar toggle (Tab 2)
201
- if (btnToggleSidebar) {
202
- btnToggleSidebar.addEventListener("click", () => {
203
- const engageGrid = $(".engage-grid");
204
- if (engageGrid) {
205
- engageGrid.classList.toggle("sidebar-collapsed");
206
- btnToggleSidebar.textContent = engageGrid.classList.contains("sidebar-collapsed")
207
- ? "▶ Show Sidebar"
208
- : "◀ Hide Sidebar";
209
- }
210
- });
211
- }
212
- }
213
-
214
-
215
-
216
- // Track selection event — re-renders cards (with inline features for active card)
217
- document.addEventListener("track-selected", (e) => {
218
- state.selectedId = e.detail.id;
219
- state.tracker.selectedTrackId = e.detail.id;
220
- renderFrameTrackList();
221
- renderFrameOverlay();
222
- renderEngageOverlay();
223
-
224
- // Scroll selected card into view (Tab 2 sidebar)
225
- const card = document.getElementById(`card-${e.detail.id}`);
226
- if (card) card.scrollIntoView({ behavior: "smooth", block: "nearest" });
227
- });
228
-
229
- // Cursor mode toggle
230
- if (cursorMode) {
231
- cursorMode.addEventListener("change", () => {
232
- state.ui.cursorMode = cursorMode.value;
233
- if (state.ui.cursorMode === "off" && APP.ui.cursor.setCursorVisible) {
234
- APP.ui.cursor.setCursorVisible(false);
235
- }
236
- });
237
- }
238
-
239
- function setupKnobListeners() {
240
- // Listen to all inputs and selects for knob updates
241
- const inputs = Array.from(document.querySelectorAll("input, select"));
242
- inputs.forEach(el => {
243
- el.addEventListener("input", () => {
244
- syncKnobDisplays();
245
- if (state.hasReasoned) {
246
- recomputeHEL();
247
- renderFrameOverlay();
248
-
249
- }
250
- });
251
- });
252
-
253
- // Initial sync
254
- syncKnobDisplays();
255
- }
256
-
257
- function setupChipToggles() {
258
- // Toggle processed/raw feed
259
- if (chipFeed) {
260
- chipFeed.style.cursor = "pointer";
261
- chipFeed.addEventListener("click", () => {
262
- if (!state.videoLoaded) return;
263
- toggleProcessedFeed();
264
- log(`Feed set to: ${state.useProcessedFeed ? "HF" : "RAW"}`, "t");
265
- });
266
- }
267
-
268
- // Toggle depth view (Tab 2)
269
- if (chipDepth) {
270
- chipDepth.style.cursor = "pointer";
271
- chipDepth.addEventListener("click", () => {
272
- if (!state.videoLoaded) return;
273
- toggleDepthView();
274
- log(`Engage view set to: ${state.useDepthFeed ? "DEPTH" : "DEFAULT"}`, "t");
275
- });
276
- }
277
-
278
- // Toggle first frame depth view (Tab 1)
279
- if (chipFrameDepth) {
280
- chipFrameDepth.style.cursor = "pointer";
281
- chipFrameDepth.addEventListener("click", () => {
282
- if (!state.videoLoaded) return;
283
- if (!state.hf.depthFirstFrameUrl) {
284
- log("First frame depth not ready yet. Run Detect and wait for depth processing.", "w");
285
- return;
286
- }
287
- toggleFirstFrameDepthView();
288
- log(`First frame view set to: ${state.useFrameDepthView ? "DEPTH" : "DEFAULT"}`, "t");
289
- });
290
- }
291
- }
292
-
293
- function setupTabSwitching() {
294
- const tabs = Array.from(document.querySelectorAll(".tabbtn"));
295
- tabs.forEach(btn => {
296
- btn.addEventListener("click", () => {
297
- tabs.forEach(b => b.classList.remove("active"));
298
- document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
299
- btn.classList.add("active");
300
- const tabId = `#tab-${btn.dataset.tab}`;
301
- const tab = $(tabId);
302
- if (tab) tab.classList.add("active");
303
-
304
- // Tab-specific actions
305
- if (btn.dataset.tab === "engage") {
306
- resizeOverlays();
307
- }
308
- });
309
- });
310
- }
311
-
312
- async function runReason() {
313
- if (!state.videoLoaded) {
314
- log("No video loaded. Upload a video first.", "w");
315
- setStatus("warn", "READY · Upload a video");
316
- return;
317
- }
318
-
319
- if (state.isReasoning) {
320
- log("Detection already in progress. Please wait.", "w");
321
- return;
322
- }
323
-
324
- // Lock the Reason process
325
- state.isReasoning = true;
326
- if (btnReason) {
327
- btnReason.disabled = true;
328
- btnReason.style.opacity = "0.5";
329
- btnReason.style.cursor = "not-allowed";
330
- }
331
- if (btnCancelReason) btnCancelReason.style.display = "inline-block";
332
- if (btnEngage) btnEngage.disabled = true;
333
-
334
- // Clear previous detections
335
- state.detections = [];
336
- state.selectedId = null;
337
- renderFrameTrackList();
338
- renderFrameOverlay();
339
-
340
- setStatus("warn", "DETECTING · Running perception pipeline");
341
-
342
- // Agent cursor flair
343
- if (state.ui.cursorMode === "on" && moveCursorToRect) {
344
- if (btnReason) moveCursorToRect(btnReason.getBoundingClientRect());
345
- if (frameCanvas) setTimeout(() => moveCursorToRect(frameCanvas.getBoundingClientRect()), 260);
346
- if (frameTrackList) setTimeout(() => moveCursorToRect(frameTrackList.getBoundingClientRect()), 560);
347
- }
348
-
349
- try {
350
- const selectedOption = detectorSelect ? detectorSelect.options[detectorSelect.selectedIndex] : null;
351
- const selectedValue = detectorSelect ? detectorSelect.value : "yolo11";
352
- const kind = selectedOption ? selectedOption.getAttribute("data-kind") : "object";
353
- const queries = missionText ? missionText.value.trim() : "";
354
- const enableGPT = $("#enableGPTToggle")?.checked || false;
355
- const enableDepth = false; // depth mode disabled
356
-
357
- // Determine mode and model parameter from data-kind attribute
358
- let mode, detectorParam, segmenterParam;
359
- if (kind === "segmentation") {
360
- mode = "segmentation";
361
- segmenterParam = selectedValue;
362
- detectorParam = "yolo11"; // default, unused for segmentation
363
- } else if (kind === "drone") {
364
- mode = "drone_detection";
365
- detectorParam = selectedValue;
366
- segmenterParam = "GSAM2-L";
367
- } else {
368
- mode = "object_detection";
369
- detectorParam = selectedValue;
370
- segmenterParam = "GSAM2-L";
371
- }
372
-
373
- const form = new FormData();
374
- form.append("video", state.videoFile);
375
- form.append("mode", mode);
376
- if (queries) form.append("queries", queries);
377
- form.append("detector", detectorParam);
378
- form.append("segmenter", segmenterParam);
379
- form.append("enable_gpt", enableGPT ? "true" : "false");
380
- form.append("enable_depth", enableDepth ? "true" : "false");
381
-
382
- log(`Submitting job to ${state.hf.baseUrl}...`, "t");
383
- setHfStatus("submitting job...");
384
-
385
- const data = await hfDetectAsync(form);
386
-
387
- state.hf.asyncJobId = data.job_id;
388
-
389
- // Store mission specification for chatbot context
390
- if (data.mission_spec) {
391
- state.hf.missionSpec = data.mission_spec;
392
- }
393
-
394
- // Store raw detections (will process after image loads to get correct dimensions)
395
- const rawDetections = data.first_frame_detections || [];
396
-
397
- // Display processed first frame from backend (only processed frame, not raw)
398
- // This is async - image loading will update state.frame.w/h
399
- if (data.first_frame_url) {
400
- state.hf.firstFrameUrl = data.first_frame_url.startsWith("http")
401
- ? data.first_frame_url
402
- : `${state.hf.baseUrl}${data.first_frame_url}`;
403
-
404
- // Wait for image to load so we have correct dimensions before processing detections
405
- await new Promise((resolve, reject) => {
406
- const img = new Image();
407
- img.crossOrigin = "anonymous";
408
- img.onload = () => {
409
- // Update frame dimensions from loaded image
410
- state.frame.w = img.naturalWidth || 1280;
411
- state.frame.h = img.naturalHeight || 720;
412
-
413
- // Resize canvases to match
414
- const frameCanvas = $("#frameCanvas");
415
- const frameOverlay = $("#frameOverlay");
416
- if (frameCanvas) {
417
- frameCanvas.width = state.frame.w;
418
- frameCanvas.height = state.frame.h;
419
- frameCanvas.getContext("2d").drawImage(img, 0, 0, state.frame.w, state.frame.h);
420
- }
421
- if (frameOverlay) {
422
- frameOverlay.width = state.frame.w;
423
- frameOverlay.height = state.frame.h;
424
- }
425
-
426
- // Hide empty state
427
- const frameEmpty = $("#frameEmpty");
428
- const frameNote = $("#frameNote");
429
- if (frameEmpty) frameEmpty.style.display = "none";
430
- if (frameNote) frameNote.textContent = "Processed (from backend)";
431
-
432
- log(`Processed first frame displayed (${state.frame.w}×${state.frame.h})`, "g");
433
- resolve();
434
- };
435
- img.onerror = () => {
436
- log("Failed to load processed first frame, using local frame", "w");
437
- drawFirstFrame();
438
- resolve();
439
- };
440
- img.src = state.hf.firstFrameUrl;
441
- });
442
- }
443
-
444
- // NOW process detections (after frame dimensions are correct)
445
- if (rawDetections.length > 0) {
446
- processFirstFrameDetections(rawDetections);
447
- }
448
-
449
- // Mark first frame as ready (for radar display)
450
- state.firstFrameReady = true;
451
-
452
- // Store depth URLs if provided
453
- if (data.depth_video_url) {
454
- state.hf.depthVideoUrl = data.depth_video_url.startsWith("http")
455
- ? data.depth_video_url
456
- : `${state.hf.baseUrl}${data.depth_video_url}`;
457
- log("Depth video URL received", "t");
458
- }
459
- if (data.first_frame_depth_url) {
460
- state.hf.depthFirstFrameUrl = data.first_frame_depth_url.startsWith("http")
461
- ? data.first_frame_depth_url
462
- : `${state.hf.baseUrl}${data.first_frame_depth_url}`;
463
- log("First frame depth URL received", "t");
464
- }
465
-
466
- // Enable streaming mode if stream_url is provided (Tab 2 live view)
467
- const enableStream = $("#enableStreamToggle")?.checked;
468
-
469
- if (data.stream_url && enableStream) {
470
- const streamUrl = data.stream_url.startsWith("http")
471
- ? data.stream_url
472
- : `${state.hf.baseUrl}${data.stream_url}`;
473
- log("Activating live stream...", "t");
474
- setStreamingMode(streamUrl);
475
- log("Live view available in 'Track' tab.", "g");
476
- setStatus("warn", "Live processing... View in Track tab");
477
-
478
- // Trigger resize/render for Tab 2
479
- resizeOverlays();
480
- }
481
-
482
- // Start polling for completion
483
- pollAsyncJob().then(() => {
484
- log("Video processing complete.", "g");
485
- // Stop streaming mode once video is ready
486
- stopStreamingMode();
487
-
488
- state.hasReasoned = true;
489
- setStatus("good", "READY · Detection complete (you can Track)");
490
- log("Detection complete. Ready to Track.", "g");
491
-
492
- // Seed tracks for Tab 2
493
- seedTracksFromTab1();
494
-
495
- // Re-enable engage button
496
- if (btnEngage) btnEngage.disabled = false;
497
- }).catch(err => {
498
- log(`Polling error: ${err.message}`, "e");
499
- stopStreamingMode();
500
- });
501
-
502
- // Initial status (processing in background)
503
- setStatus("warn", "PROCESSING · Analysing video...");
504
- log("Reasoning started (processing in background)...", "t");
505
-
506
-
507
-
508
- } catch (err) {
509
- setStatus("bad", "ERROR · Detection failed");
510
- log(`Detection failed: ${err.message}`, "e");
511
- console.error(err);
512
- } finally {
513
- state.isReasoning = false;
514
- if (btnReason) {
515
- btnReason.disabled = false;
516
- btnReason.style.opacity = "1";
517
- btnReason.style.cursor = "pointer";
518
- }
519
- if (btnCancelReason) btnCancelReason.style.display = "none";
520
- // Re-enable engage button in case of failure
521
- if (btnEngage) btnEngage.disabled = false;
522
- }
523
- }
524
-
525
- function processFirstFrameDetections(dets) {
526
- state.detections = dets.map((d, i) => {
527
- const id = d.track_id || `T${String(i + 1).padStart(2, "0")}`;
528
- const ap = defaultAimpoint(d.label || d.class);
529
- const bbox = d.bbox
530
- ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] }
531
- : { x: 0, y: 0, w: 10, h: 10 };
532
-
533
- // Build features from universal schema via canonical mapping
534
- let features = APP.core.gptMapping.buildFeatures(d.gpt_raw);
535
-
536
- return {
537
- id,
538
- label: d.label || d.class,
539
- score: d.score || 0.5,
540
- bbox,
541
- aim: { ...ap },
542
- features,
543
- baseRange_m: null,
544
- baseAreaFrac: (bbox.w * bbox.h) / (state.frame.w * state.frame.h),
545
- baseDwell_s: 5.0,
546
- reqP_kW: 40,
547
- maxP_kW: 0,
548
- pkill: 0,
549
- // Depth fields
550
- depth_est_m: (d.depth_est_m !== undefined && d.depth_est_m !== null) ? d.depth_est_m : null,
551
- depth_rel: (d.depth_rel !== undefined && d.depth_rel !== null) ? d.depth_rel : null,
552
- depth_valid: d.depth_valid ?? false,
553
- gpt_distance_m: d.gpt_distance_m || null,
554
- gpt_direction: d.gpt_direction || null,
555
- gpt_description: d.gpt_description || null,
556
- // Threat Intelligence
557
- threat_level_score: d.threat_level_score || 0,
558
- threat_classification: d.threat_classification || "Unknown",
559
- weapon_readiness: d.weapon_readiness || "Unknown",
560
- // Mission relevance and assessment status
561
- mission_relevant: d.mission_relevant ?? null,
562
- relevance_reason: d.relevance_reason || null,
563
- assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
564
- assessment_frame_index: d.assessment_frame_index ?? null,
565
- };
566
- });
567
-
568
- state.selectedId = state.detections[0]?.id || null;
569
-
570
- renderFrameTrackList();
571
- renderFrameOverlay();
572
-
573
- log(`Detected ${state.detections.length} objects in first frame.`, "g");
574
- }
575
-
576
- function seedTracksFromTab1() {
577
- const rangeBase = $("#rangeBase");
578
- state.tracker.tracks = state.detections.map(d => ({
579
- id: d.id,
580
- label: d.label,
581
- bbox: { ...d.bbox },
582
- score: d.score,
583
- aimRel: d.aim ? { relx: d.aim.relx, rely: d.aim.rely, label: d.aim.label } : { relx: 0.5, rely: 0.5, label: "center_mass" },
584
- baseAreaFrac: d.baseAreaFrac || ((d.bbox.w * d.bbox.h) / (state.frame.w * state.frame.h)),
585
- baseRange_m: d.baseRange_m || (rangeBase ? +rangeBase.value : 1500),
586
- baseDwell_s: d.baseDwell_s || 4.0,
587
- reqP_kW: d.reqP_kW || 35,
588
- depth_rel: d.depth_rel,
589
- depth_est_m: d.depth_est_m,
590
- depth_valid: d.depth_valid,
591
- lastDepthBbox: d.depth_valid ? { ...d.bbox } : null,
592
- gpt_distance_m: d.gpt_distance_m,
593
- gpt_direction: d.gpt_direction,
594
- gpt_description: d.gpt_description,
595
- lastSeen: APP.core.utils.now(),
596
- vx: 0,
597
- vy: 0,
598
- dwellAccum: 0,
599
- killed: false,
600
- state: "TRACK",
601
- assessT: 0
602
- }));
603
- state.tracker.nextId = state.detections.length + 1;
604
- log(`Seeded ${state.tracker.tracks.length} tracks from Tab 1 detections.`, "t");
605
- }
606
-
607
- function cancelReasoning() {
608
- // Stop HF polling if running
609
- if (state.hf.asyncPollInterval) {
610
- clearInterval(state.hf.asyncPollInterval);
611
- state.hf.asyncPollInterval = null;
612
- log("HF polling stopped.", "w");
613
- }
614
-
615
- // Stop streaming mode
616
- stopStreamingMode();
617
-
618
- // Cancel backend job if it exists
619
- const jobId = state.hf.asyncJobId;
620
- if (jobId) {
621
- cancelBackendJob(jobId, "cancel button");
622
- }
623
-
624
- // Reset state
625
- state.isReasoning = false;
626
- state.hf.busy = false;
627
- state.hf.asyncJobId = null;
628
- state.hf.completedJobId = null;
629
- state.hf.asyncStatus = "cancelled";
630
-
631
- // Re-enable Reason button
632
- if (btnReason) {
633
- btnReason.disabled = false;
634
- btnReason.style.opacity = "1";
635
- btnReason.style.cursor = "pointer";
636
- }
637
- if (btnCancelReason) btnCancelReason.style.display = "none";
638
-
639
- setStatus("warn", "CANCELLED · Detection stopped");
640
- setHfStatus("cancelled (stopped by user)");
641
- log("Detection cancelled by user.", "w");
642
- }
643
-
644
- function runEngage() {
645
- if (!state.hasReasoned) {
646
- log("Please run Detect first.", "w");
647
- return;
648
- }
649
-
650
- if (state.hf.asyncJobId) {
651
- log("Processing still in progress. Please wait.", "w");
652
- // If we are streaming, make sure we are on the engage tab to see it
653
- const engageTab = $(`.tabbtn[data-tab="engage"]`);
654
- if (engageTab) engageTab.click();
655
- return;
656
- }
657
-
658
- // Switch to engage tab
659
- const engageTab = $(`.tabbtn[data-tab="engage"]`);
660
- if (engageTab) engageTab.click();
661
-
662
- // Set video source
663
- if (videoEngage) {
664
- videoEngage.src = state.hf.processedUrl || state.videoUrl;
665
- videoEngage.play().catch(err => {
666
- log(`Video playback failed: ${err.message}`, "e");
667
- });
668
- }
669
-
670
- state.tracker.running = true;
671
- state.tracker.lastFrameTime = APP.core.utils.now();
672
-
673
- // Ensure tracks are seeded
674
- if (state.tracker.tracks.length === 0) {
675
- seedTracksFromTab1();
676
- }
677
-
678
- log("Tracking started.", "g");
679
- }
680
-
681
- function loop() {
682
- const { now } = APP.core.utils;
683
- const t = now();
684
-
685
- // Guard against huge dt on first frame
686
- if (state.tracker.lastFrameTime === 0) state.tracker.lastFrameTime = t;
687
-
688
- const dt = Math.min((t - state.tracker.lastFrameTime) / 1000, 0.1);
689
- state.tracker.lastFrameTime = t;
690
-
691
- // ── Always keep track positions fresh (playing OR paused) ──
692
- // This ensures bboxes remain clickable regardless of playback state.
693
- if (state.tracker.running && videoEngage && state.tracker.tracks.length > 0) {
694
- if (APP.core.demo.active && APP.core.demo.data) {
695
- // DEMO MODE: sync tracks to current video time (even when paused)
696
- const demoTracks = getDemoFrameData(videoEngage.currentTime);
697
- if (demoTracks) {
698
- const tracksClone = JSON.parse(JSON.stringify(demoTracks));
699
-
700
- state.tracker.tracks = tracksClone.map(d => ({
701
- ...d,
702
- lastSeen: t,
703
- state: "TRACK",
704
- depth_valid: true,
705
- depth_est_m: d.gpt_distance_m || 1000,
706
- }));
707
-
708
- const w = videoEngage.videoWidth || state.frame.w || 1280;
709
- const h = videoEngage.videoHeight || state.frame.h || 720;
710
-
711
- state.tracker.tracks.forEach(tr => {
712
- if (tr.bbox.x > 1 || tr.bbox.w > 1) {
713
- tr.bbox.x /= w;
714
- tr.bbox.y /= h;
715
- tr.bbox.w /= w;
716
- tr.bbox.h /= h;
717
- }
718
- });
719
- }
720
- } else {
721
- // NORMAL MODE: predict positions every frame
722
- predictTracks(dt);
723
-
724
- // Backend sync every 333ms (works while paused too)
725
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
726
- if (jobId && (t - state.tracker.lastHFSync > 333)) {
727
- const frameIdx = Math.floor(videoEngage.currentTime * 30);
728
- APP.core.tracker.syncWithBackend(frameIdx);
729
- state.tracker.lastHFSync = t;
730
- }
731
- }
732
- }
733
-
734
- // ── Card rendering & GPT analysis: only during active playback ──
735
- if (state.tracker.running && videoEngage && !videoEngage.paused) {
736
- state.tracker.frameCount++;
737
-
738
- const framesSinceRender = state.tracker.frameCount - state.tracker._lastCardRenderFrame;
739
- if (state.tracker._newObjectDetected || framesSinceRender >= 40) {
740
- renderFrameTrackList();
741
- state.tracker._lastCardRenderFrame = state.tracker.frameCount;
742
- state.tracker._newObjectDetected = false;
743
-
744
- if (!state.tracker._gptBusy && state.tracker.tracks.length > 0) {
745
- state.tracker._gptBusy = true;
746
- APP.api.client.analyzeFrame(videoEngage, state.tracker.tracks)
747
- .then(enriched => {
748
- for (const rd of enriched) {
749
- const tid = rd.track_id || rd.id;
750
- const existing = (state.detections || []).find(d => d.id === tid);
751
- if (existing && rd.gpt_raw) {
752
- existing.gpt_raw = rd.gpt_raw;
753
- existing.features = APP.core.gptMapping.buildFeatures(rd.gpt_raw);
754
- existing.assessment_status = rd.assessment_status || "ASSESSED";
755
- existing.threat_level_score = rd.threat_level_score || 0;
756
- existing.gpt_description = rd.gpt_description || existing.gpt_description;
757
- existing.gpt_distance_m = rd.gpt_distance_m || existing.gpt_distance_m;
758
- existing.gpt_direction = rd.gpt_direction || existing.gpt_direction;
759
- }
760
- }
761
- renderFrameTrackList();
762
- state.tracker._gptBusy = false;
763
- })
764
- .catch(err => {
765
- console.warn("Frame GPT analysis failed:", err);
766
- state.tracker._gptBusy = false;
767
- });
768
- }
769
- }
770
- }
771
-
772
- // Render UI
773
- if (renderFrameOverlay) renderFrameOverlay();
774
- if (renderEngageOverlay) renderEngageOverlay();
775
- if (tickAgentCursor) tickAgentCursor();
776
-
777
- requestAnimationFrame(loop);
778
- }
779
-
780
- // Expose state for debugging
781
- window.__LP_STATE__ = state;
782
-
783
- // Start
784
- init();
785
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/cards.js DELETED
@@ -1,130 +0,0 @@
1
- APP.ui.cards = {};
2
-
3
- APP.ui.cards.renderFrameTrackList = function () {
4
- const { state } = APP.core;
5
- const { $ } = APP.core.utils;
6
- const frameTrackList = $("#frameTrackList");
7
- const trackList = $("#trackList"); // Tab 2 (Engage) uses the same card logic
8
- const trackCount = $("#trackCount");
9
-
10
- if (!frameTrackList && !trackList) return;
11
- if (frameTrackList) frameTrackList.innerHTML = "";
12
- if (trackList) trackList.innerHTML = "";
13
-
14
- // Filter: only show mission-relevant detections (or all in LEGACY mode)
15
- const dets = (state.detections || []).filter(d => {
16
- if (d.mission_relevant === null || d.mission_relevant === undefined) return true;
17
- return d.mission_relevant === true;
18
- });
19
-
20
- if (trackCount) trackCount.textContent = dets.length;
21
-
22
- if (dets.length === 0) {
23
- const emptyMsg = '<div style="font-style:italic; color:var(--faint); text-align:center; margin-top:20px; font-size:12px;">No objects tracked.</div>';
24
- if (frameTrackList) frameTrackList.innerHTML = emptyMsg;
25
- if (trackList) trackList.innerHTML = emptyMsg;
26
- return;
27
- }
28
-
29
- // Sort: ASSESSED first (by threat score), then UNASSESSED, then STALE
30
- const S = APP.core.gptMapping.STATUS;
31
- const statusOrder = { [S.ASSESSED]: 0, [S.UNASSESSED]: 1, [S.STALE]: 2 };
32
- const sorted = [...dets].sort((a, b) => {
33
- const statusA = statusOrder[a.assessment_status] ?? 1;
34
- const statusB = statusOrder[b.assessment_status] ?? 1;
35
- if (statusA !== statusB) return statusA - statusB;
36
- const scoreA = a.threat_level_score || 0;
37
- const scoreB = b.threat_level_score || 0;
38
- if (scoreB !== scoreA) return scoreB - scoreA;
39
- return (b.score || 0) - (a.score || 0);
40
- });
41
-
42
- sorted.forEach((det, i) => {
43
- const id = det.id || `T${String(i + 1).padStart(2, '0')}`;
44
- const isActive = state.selectedId === id;
45
-
46
- let rangeStr = "---";
47
- let bearingStr = "---";
48
-
49
- if (det.depth_valid && det.depth_est_m != null) {
50
- rangeStr = `${Math.round(det.depth_est_m)}m`;
51
- } else if (det.gpt_distance_m) {
52
- rangeStr = `~${det.gpt_distance_m}m`;
53
- } else if (det.baseRange_m) {
54
- rangeStr = `${Math.round(det.baseRange_m)}m`;
55
- }
56
-
57
- if (det.gpt_direction) {
58
- bearingStr = det.gpt_direction;
59
- }
60
-
61
- const card = document.createElement("div");
62
- card.className = "track-card" + (isActive ? " active" : "");
63
- card.id = `card-${id}`;
64
-
65
- card.onclick = () => {
66
- const ev = new CustomEvent("track-selected", { detail: { id } });
67
- document.dispatchEvent(ev);
68
- };
69
-
70
- // Assessment status badge
71
- let statusBadge = "";
72
- const assessStatus = det.assessment_status || S.UNASSESSED;
73
- if (assessStatus === S.UNASSESSED) {
74
- statusBadge = '<span class="badgemini" style="background:#6c757d; color:white">UNASSESSED</span>';
75
- } else if (assessStatus === S.STALE) {
76
- statusBadge = '<span class="badgemini" style="background:#ffc107; color:#333">STALE</span>';
77
- } else if (det.threat_level_score > 0) {
78
- statusBadge = `<span class="badgemini" style="background:${det.threat_level_score >= 8 ? '#ff4d4d' : '#ff9f43'}; color:white">T-${det.threat_level_score}</span>`;
79
- } else if (assessStatus === S.ASSESSED) {
80
- statusBadge = '<span class="badgemini" style="background:#17a2b8; color:white">ASSESSED</span>';
81
- }
82
-
83
- // GPT description (collapsed summary)
84
- const desc = det.gpt_description
85
- ? `<div class="track-card-body"><span class="gpt-text">${det.gpt_description}</span></div>`
86
- : "";
87
-
88
- // Inline features (only shown when active/expanded)
89
- let featuresHtml = "";
90
- if (isActive && det.features && Object.keys(det.features).length > 0) {
91
- const entries = Object.entries(det.features).slice(0, 12);
92
- const rows = entries.map(([k, v]) =>
93
- `<div class="feat-row"><span class="feat-key">${k}</span><span class="feat-val">${String(v)}</span></div>`
94
- ).join("");
95
- featuresHtml = `<div class="track-card-features">${rows}</div>`;
96
- }
97
-
98
- card.innerHTML = `
99
- <div class="track-card-header">
100
- <span>${id} · ${det.label}</span>
101
- <div style="display:flex; gap:4px; align-items:center">
102
- ${statusBadge}
103
- ${det.mission_relevant === true
104
- ? '<span class="badgemini" style="background:#28a745; color:white">RELEVANT</span>'
105
- : det.mission_relevant === false
106
- ? '<span class="badgemini" style="background:#6c757d; color:white">N/R</span>'
107
- : `<span class="badgemini" style="background:rgba(255,255,255,.08); color:rgba(255,255,255,.7)">${(det.score * 100).toFixed(0)}%</span>`
108
- }
109
- </div>
110
- </div>
111
- <div class="track-card-meta">
112
- RNG ${rangeStr} · BRG ${bearingStr}
113
- </div>
114
- ${desc}
115
- ${featuresHtml}
116
- `;
117
- if (frameTrackList) frameTrackList.appendChild(card);
118
- if (trackList) trackList.appendChild(card.cloneNode(true));
119
- });
120
-
121
- // Wire up click handlers on cloned Tab 2 cards
122
- if (trackList) {
123
- trackList.querySelectorAll(".track-card").forEach(card => {
124
- const id = card.id.replace("card-", "");
125
- card.onclick = () => {
126
- document.dispatchEvent(new CustomEvent("track-selected", { detail: { id } }));
127
- };
128
- });
129
- }
130
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/chat.js DELETED
@@ -1,155 +0,0 @@
1
- // Chat UI Module - Threat-aware chat with GPT
2
- (function () {
3
- const { state } = APP.core;
4
- const { $ } = APP.core.utils;
5
- const { log } = APP.ui.logging;
6
-
7
- let chatHistory = [];
8
-
9
- /**
10
- * Initialize the chat module.
11
- */
12
- function init() {
13
- const chatInput = $("#chatInput");
14
- const chatSend = $("#chatSend");
15
-
16
- if (chatSend) {
17
- chatSend.addEventListener("click", sendMessage);
18
- }
19
-
20
- if (chatInput) {
21
- chatInput.addEventListener("keydown", (e) => {
22
- if (e.key === "Enter" && !e.shiftKey) {
23
- e.preventDefault();
24
- sendMessage();
25
- }
26
- });
27
- }
28
-
29
- // Toggle chat panel
30
- const chatToggle = $("#chatToggle");
31
- const chatPanel = $("#chatPanel");
32
- if (chatToggle && chatPanel) {
33
- chatToggle.addEventListener("click", () => {
34
- chatPanel.classList.toggle("collapsed");
35
- chatToggle.textContent = chatPanel.classList.contains("collapsed")
36
- ? "▼ Chat"
37
- : "▲ Close Chat";
38
- });
39
- }
40
- }
41
-
42
- /**
43
- * Send a chat message about current threats.
44
- */
45
- async function sendMessage() {
46
- const chatInput = $("#chatInput");
47
- const chatMessages = $("#chatMessages");
48
-
49
- if (!chatInput || !chatMessages) return;
50
-
51
- const question = chatInput.value.trim();
52
- if (!question) {
53
- log("Please enter a question.", "w");
54
- return;
55
- }
56
-
57
- // Check if we have detections
58
- if (!state.detections || state.detections.length === 0) {
59
- appendMessage("system", "No detections yet. Run Detect first to analyze the scene.");
60
- return;
61
- }
62
-
63
- // Add user message to UI
64
- appendMessage("user", question);
65
- chatInput.value = "";
66
- chatInput.disabled = true;
67
-
68
- // Show loading indicator
69
- const loadingId = appendMessage("assistant", "Analyzing scene...", true);
70
-
71
- try {
72
- const response = await APP.api.client.chatAboutThreats(question, state.detections);
73
-
74
- // Remove loading message
75
- removeMessage(loadingId);
76
-
77
- if (response.response) {
78
- appendMessage("assistant", response.response);
79
- chatHistory.push({ role: "user", content: question });
80
- chatHistory.push({ role: "assistant", content: response.response });
81
- } else if (response.error || response.detail) {
82
- appendMessage("system", `Error: ${response.error || response.detail}`);
83
- }
84
- } catch (err) {
85
- removeMessage(loadingId);
86
- appendMessage("system", `Failed to get response: ${err.message}`);
87
- log(`Chat error: ${err.message}`, "e");
88
- } finally {
89
- chatInput.disabled = false;
90
- chatInput.focus();
91
- }
92
- }
93
-
94
- /**
95
- * Append a message to the chat display.
96
- */
97
- function appendMessage(role, content, isLoading = false) {
98
- const chatMessages = $("#chatMessages");
99
- if (!chatMessages) return null;
100
-
101
- const msgId = `msg-${Date.now()}`;
102
- const msgDiv = document.createElement("div");
103
- msgDiv.className = `chat-message chat-${role}`;
104
- msgDiv.id = msgId;
105
-
106
- if (isLoading) {
107
- msgDiv.classList.add("loading");
108
- }
109
-
110
- // Format content with line breaks
111
- const formatted = content.replace(/\n/g, "<br>");
112
-
113
- const icon = role === "user" ? "YOU" : role === "assistant" ? "TAC" : "SYS";
114
- msgDiv.innerHTML = `<span class="chat-icon">${icon}</span><span class="chat-content">${formatted}</span>`;
115
-
116
- chatMessages.appendChild(msgDiv);
117
- chatMessages.scrollTop = chatMessages.scrollHeight;
118
-
119
- return msgId;
120
- }
121
-
122
- /**
123
- * Remove a message by ID.
124
- */
125
- function removeMessage(msgId) {
126
- const msg = document.getElementById(msgId);
127
- if (msg) msg.remove();
128
- }
129
-
130
- /**
131
- * Clear chat history.
132
- */
133
- function clearChat() {
134
- const chatMessages = $("#chatMessages");
135
- if (chatMessages) {
136
- chatMessages.innerHTML = "";
137
- }
138
- chatHistory = [];
139
- }
140
-
141
- // Export
142
- APP.ui = APP.ui || {};
143
- APP.ui.chat = {
144
- init,
145
- sendMessage,
146
- clearChat
147
- };
148
-
149
- // Auto-init on DOMContentLoaded
150
- if (document.readyState === "loading") {
151
- document.addEventListener("DOMContentLoaded", init);
152
- } else {
153
- init();
154
- }
155
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/cursor.js DELETED
@@ -1,90 +0,0 @@
1
- // Agent Cursor Animation Module
2
- APP.ui.cursor = {};
3
-
4
- APP.ui.cursor.ensureAgentCursorOverlay = function () {
5
- const { $ } = APP.core.utils;
6
- if ($("#agentCursor")) return;
7
-
8
- const el = document.createElement("div");
9
- el.id = "agentCursor";
10
- el.style.cssText = `
11
- position: fixed;
12
- width: 12px;
13
- height: 12px;
14
- border-radius: 50%;
15
- background: linear-gradient(135deg, rgba(34, 211, 238, 0.9), rgba(124, 58, 237, 0.9));
16
- box-shadow: 0 0 20px rgba(34, 211, 238, 0.6), 0 0 40px rgba(124, 58, 237, 0.4);
17
- pointer-events: none;
18
- z-index: 10000;
19
- opacity: 0;
20
- display: none;
21
- transition: opacity 0.3s ease;
22
- `;
23
- document.body.appendChild(el);
24
- };
25
-
26
- APP.ui.cursor.setCursorVisible = function (visible) {
27
- const { $ } = APP.core.utils;
28
- const { state } = APP.core;
29
-
30
- APP.ui.cursor.ensureAgentCursorOverlay();
31
- const el = $("#agentCursor");
32
-
33
- if (!el) return;
34
-
35
- state.ui.agentCursor.visible = visible;
36
- el.style.opacity = visible ? "1" : "0";
37
- el.style.display = visible ? "block" : "none";
38
- };
39
-
40
- APP.ui.cursor.moveCursorToRect = function (rect) {
41
- const { state } = APP.core;
42
- const { $, now } = APP.core.utils;
43
-
44
- if (state.ui.cursorMode === "off") return;
45
-
46
- APP.ui.cursor.ensureAgentCursorOverlay();
47
- const el = $("#agentCursor");
48
-
49
- if (!el) return;
50
-
51
- const c = state.ui.agentCursor;
52
- c.visible = true;
53
- c.target = rect;
54
- c.t0 = now();
55
- el.style.opacity = "1";
56
- el.style.display = "block";
57
- };
58
-
59
- APP.ui.cursor.tickAgentCursor = function () {
60
- const { state } = APP.core;
61
- const { $, clamp, now } = APP.core.utils;
62
- const el = $("#agentCursor");
63
-
64
- if (!el || state.ui.cursorMode !== "on" || !state.ui.agentCursor.visible) return;
65
-
66
- const c = state.ui.agentCursor;
67
- if (!c.target) return;
68
-
69
- const tx = c.target.left + c.target.width * 0.72;
70
- const ty = c.target.top + c.target.height * 0.50;
71
-
72
- // Smooth spring physics
73
- const dx = tx - (c.x * window.innerWidth);
74
- const dy = ty - (c.y * window.innerHeight);
75
- c.vx = (c.vx + dx * 0.0018) * 0.85;
76
- c.vy = (c.vy + dy * 0.0018) * 0.85;
77
-
78
- const px = (c.x * window.innerWidth) + c.vx * 18;
79
- const py = (c.y * window.innerHeight) + c.vy * 18;
80
- c.x = clamp(px / window.innerWidth, 0.02, 0.98);
81
- c.y = clamp(py / window.innerHeight, 0.02, 0.98);
82
-
83
- el.style.transform = `translate(${c.x * window.innerWidth}px, ${c.y * window.innerHeight}px)`;
84
-
85
- // Hide after settling
86
- const settle = Math.hypot(dx, dy);
87
- if (settle < 6 && (now() - c.t0) > 650) {
88
- el.style.opacity = "0.75";
89
- }
90
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/logging.js DELETED
@@ -1,45 +0,0 @@
1
- APP.ui.logging = {};
2
-
3
- APP.ui.logging.log = function (msg, type = "i") {
4
- const { $ } = APP.core.utils;
5
- const consoleHook = $("#sysLog"); // Fixed ID: sysLog from html
6
- if (!consoleHook) return;
7
- const div = document.createElement("div");
8
- div.className = "log-line";
9
- const ts = new Date().toISOString().split("T")[1].slice(0, 8);
10
- let color = "#94a3b8"; // dim
11
- if (type === "g") color = "var(--good)";
12
- if (type === "w") color = "var(--warn)";
13
- if (type === "e") color = "var(--bad)";
14
- if (type === "t") color = "var(--theme)";
15
-
16
- div.innerHTML = `<span class="ts">[${ts}]</span> <span style="color:${color}">${msg}</span>`;
17
- consoleHook.appendChild(div);
18
- consoleHook.scrollTop = consoleHook.scrollHeight;
19
- };
20
-
21
- APP.ui.logging.setStatus = function (level, text) {
22
- const { $ } = APP.core.utils;
23
- const sysDot = $("#sys-dot");
24
- const sysStatus = $("#sys-status");
25
- if (!sysDot || !sysStatus) return;
26
- let color = "var(--text-dim)";
27
- if (level === "good") color = "var(--good)";
28
- if (level === "warn") color = "var(--warn)";
29
- if (level === "bad") color = "var(--bad)";
30
-
31
- sysDot.style.background = color;
32
- sysDot.style.boxShadow = `0 0 10px ${color}`;
33
- sysStatus.textContent = text;
34
- sysStatus.style.color = color === "var(--text-dim)" ? "var(--text-main)" : color;
35
- };
36
-
37
- APP.ui.logging.setHfStatus = function (msg) {
38
- const { $ } = APP.core.utils;
39
- const el = $("#hfBackendStatus");
40
- if (el) el.textContent = `HF Backend: ${msg}`;
41
-
42
- if (msg && (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("failed"))) {
43
- APP.ui.logging.log(msg, "e");
44
- }
45
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/overlays.js DELETED
@@ -1,113 +0,0 @@
1
- APP.ui.overlays = {};
2
-
3
- APP.ui.overlays.render = function (canvasId, trackSource) {
4
- const { state } = APP.core;
5
- const { $ } = APP.core.utils;
6
-
7
- const canvas = $(`#${canvasId}`);
8
- if (!canvas) return;
9
-
10
- const ctx = canvas.getContext("2d");
11
- ctx.clearRect(0, 0, canvas.width, canvas.height);
12
-
13
- const tracks = trackSource || [];
14
- if (!tracks.length) return;
15
-
16
- const w = canvas.width;
17
- const h = canvas.height;
18
- const selId = state.selectedId || state.tracker.selectedTrackId;
19
-
20
- // Draw selected track highlight (always, even on processed feed)
21
- if (selId) {
22
- const sel = tracks.find(t => t.id === selId);
23
- if (sel && sel.bbox) {
24
- const b = sel.bbox;
25
- const px = b.x * w, py = b.y * h, pw = b.w * w, ph = b.h * h;
26
-
27
- // Subtle filled highlight
28
- ctx.fillStyle = "rgba(124, 58, 237, 0.15)";
29
- ctx.fillRect(px, py, pw, ph);
30
-
31
- // Border glow (double stroke for glow effect)
32
- ctx.strokeStyle = "rgba(124, 58, 237, 0.6)";
33
- ctx.lineWidth = 3;
34
- ctx.strokeRect(px, py, pw, ph);
35
- ctx.strokeStyle = "rgba(34, 211, 238, 0.4)";
36
- ctx.lineWidth = 1;
37
- ctx.strokeRect(px - 1, py - 1, pw + 2, ph + 2);
38
-
39
- // Small label tag
40
- const label = `${sel.id}`;
41
- ctx.font = "bold 11px monospace";
42
- const tw = ctx.measureText(label).width + 8;
43
- ctx.fillStyle = "rgba(124, 58, 237, 0.8)";
44
- ctx.fillRect(px, py - 18, tw, 16);
45
- ctx.fillStyle = "#fff";
46
- ctx.fillText(label, px + 4, py - 5);
47
- }
48
- }
49
- };
50
-
51
- APP.ui.overlays.renderFrameOverlay = function () {
52
- const { state } = APP.core;
53
- const { $ } = APP.core.utils;
54
- const canvas = $("#frameOverlay");
55
- if (!canvas) return;
56
-
57
- if (state.selectedId) {
58
- const sel = (state.detections || []).filter(d => d.id === state.selectedId);
59
- if (sel.length) {
60
- APP.ui.overlays.render("frameOverlay", sel);
61
- return;
62
- }
63
- }
64
- const ctx = canvas.getContext("2d");
65
- ctx.clearRect(0, 0, canvas.width, canvas.height);
66
- };
67
-
68
- APP.ui.overlays.initClickHandler = function () {
69
- const { $ } = APP.core.utils;
70
- const canvas = $("#engageOverlay");
71
- if (!canvas) return;
72
-
73
- canvas.style.pointerEvents = "auto";
74
- canvas.style.cursor = "crosshair";
75
-
76
- canvas.addEventListener("click", (e) => {
77
- const { state } = APP.core;
78
- const tracks = state.tracker.tracks;
79
- if (!tracks || !tracks.length) return;
80
-
81
- // Convert click to normalized 0-1 coords relative to canvas
82
- const rect = canvas.getBoundingClientRect();
83
- const nx = (e.clientX - rect.left) / rect.width;
84
- const ny = (e.clientY - rect.top) / rect.height;
85
-
86
- // Hit-test against track bboxes (smallest area wins for overlaps)
87
- // Margin accounts for drift between visual bbox and tracked position
88
- const margin = 0.015;
89
- let best = null;
90
- let bestArea = Infinity;
91
-
92
- for (const t of tracks) {
93
- const b = t.bbox;
94
- if (!b) continue;
95
- if (nx >= b.x - margin && nx <= b.x + b.w + margin &&
96
- ny >= b.y - margin && ny <= b.y + b.h + margin) {
97
- const area = b.w * b.h;
98
- if (area < bestArea) {
99
- bestArea = area;
100
- best = t;
101
- }
102
- }
103
- }
104
-
105
- const id = best ? best.id : null;
106
- document.dispatchEvent(new CustomEvent("track-selected", { detail: { id } }));
107
- });
108
- };
109
-
110
- APP.ui.overlays.renderEngageOverlay = function () {
111
- const { state } = APP.core;
112
- APP.ui.overlays.render("engageOverlay", state.tracker.tracks);
113
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/style.css DELETED
@@ -1,1142 +0,0 @@
1
- /* =========================================
2
- LaserPerception Design System
3
- ========================================= */
4
-
5
- :root {
6
- /* --- Colors --- */
7
- --bg: #060914;
8
- --panel: #0b1026;
9
- --panel2: #0a0f22;
10
-
11
- --stroke: rgba(255, 255, 255, .08);
12
- --stroke2: rgba(255, 255, 255, .12);
13
-
14
- --text: rgba(255, 255, 255, .92);
15
- --muted: rgba(255, 255, 255, .62);
16
- --faint: rgba(255, 255, 255, .42);
17
-
18
- --good: #22c55e;
19
- --warn: #f59e0b;
20
- --bad: #ef4444;
21
-
22
- --accent: #7c3aed;
23
- --cyan: #22d3ee;
24
- --mag: #fb7185;
25
-
26
- /* --- Effects --- */
27
- --shadow: 0 18px 60px rgba(0, 0, 0, .55);
28
-
29
- /* --- Typography --- */
30
- --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
31
- --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
32
- }
33
-
34
- * {
35
- box-sizing: border-box;
36
- }
37
-
38
- html,
39
- body {
40
- height: 100%;
41
- margin: 0;
42
- }
43
-
44
- body {
45
- background:
46
- radial-gradient(1200px 700px at 20% 8%, rgba(124, 58, 237, .22), transparent 60%),
47
- radial-gradient(900px 500px at 82% 18%, rgba(34, 211, 238, .18), transparent 60%),
48
- radial-gradient(800px 520px at 52% 82%, rgba(251, 113, 133, .10), transparent 65%),
49
- linear-gradient(180deg, #040614, #060914);
50
- color: var(--text);
51
- font-family: var(--sans);
52
- overflow: hidden;
53
- }
54
-
55
- /* =========================================
56
- Layout & Structure
57
- ========================================= */
58
-
59
- #app {
60
- height: 100%;
61
- display: flex;
62
- flex-direction: column;
63
- }
64
-
65
- header {
66
- display: flex;
67
- align-items: center;
68
- justify-content: space-between;
69
- padding: 14px 16px 12px;
70
- border-bottom: 1px solid var(--stroke);
71
- background: linear-gradient(180deg, rgba(255, 255, 255, .035), transparent);
72
- }
73
-
74
- .workspace {
75
- flex: 1;
76
- display: grid;
77
- grid-template-columns: 270px 1fr;
78
- /* Fixed sidebar width - reduced by 50% */
79
- gap: 12px;
80
- padding: 12px;
81
- min-height: 0;
82
- }
83
-
84
- aside {
85
- background: rgba(255, 255, 255, .02);
86
- border: 1px solid var(--stroke);
87
- border-radius: 16px;
88
- box-shadow: var(--shadow);
89
- overflow-y: auto;
90
- overflow-x: hidden;
91
- display: flex;
92
- flex-direction: column;
93
- min-height: 0;
94
- }
95
-
96
- main {
97
- background: rgba(255, 255, 255, .02);
98
- border: 1px solid var(--stroke);
99
- border-radius: 16px;
100
- box-shadow: var(--shadow);
101
- overflow: hidden;
102
- display: flex;
103
- flex-direction: column;
104
- min-height: 0;
105
- }
106
-
107
- footer {
108
- padding: 10px 14px;
109
- border-top: 1px solid var(--stroke);
110
- color: var(--muted);
111
- font-size: 11px;
112
- display: flex;
113
- justify-content: space-between;
114
- align-items: center;
115
- gap: 10px;
116
- background: linear-gradient(0deg, rgba(255, 255, 255, .03), transparent);
117
- }
118
-
119
- footer .mono {
120
- font-family: var(--mono);
121
- color: rgba(255, 255, 255, .76);
122
- }
123
-
124
- /* =========================================
125
- Brand & Status
126
- ========================================= */
127
-
128
- .brand {
129
- display: flex;
130
- gap: 12px;
131
- align-items: center;
132
- min-width: 420px;
133
- }
134
-
135
- .logo {
136
- width: 40px;
137
- height: 40px;
138
- border-radius: 14px;
139
- background:
140
- radial-gradient(circle at 30% 30%, rgba(34, 211, 238, .9), rgba(124, 58, 237, .9) 55%, rgba(0, 0, 0, .1) 70%),
141
- linear-gradient(135deg, rgba(255, 255, 255, .10), transparent 60%);
142
- box-shadow: 0 16px 46px rgba(124, 58, 237, .25);
143
- border: 1px solid rgba(255, 255, 255, .16);
144
- position: relative;
145
- overflow: hidden;
146
- }
147
-
148
- .logo:after {
149
- content: "";
150
- position: absolute;
151
- inset: -40px;
152
- background: conic-gradient(from 180deg, transparent, rgba(255, 255, 255, .10), transparent);
153
- animation: spin 10s linear infinite;
154
- }
155
-
156
- @keyframes spin {
157
- to {
158
- transform: rotate(360deg);
159
- }
160
- }
161
-
162
- .brand h1 {
163
- font-size: 14px;
164
- margin: 0;
165
- letter-spacing: .16em;
166
- text-transform: uppercase;
167
- }
168
-
169
- .brand .sub {
170
- font-size: 12px;
171
- color: var(--muted);
172
- margin-top: 2px;
173
- line-height: 1.2;
174
- }
175
-
176
- .status-row {
177
- display: flex;
178
- gap: 10px;
179
- align-items: center;
180
- flex-wrap: wrap;
181
- justify-content: flex-end;
182
- }
183
-
184
- /* =========================================
185
- Components: Cards & Panels
186
- ========================================= */
187
-
188
- .card {
189
- padding: 12px 12px 10px;
190
- border-bottom: 1px solid var(--stroke);
191
- position: relative;
192
- }
193
-
194
- .card:last-child {
195
- border-bottom: none;
196
- }
197
-
198
- .card h2 {
199
- margin: 0;
200
- font-size: 12px;
201
- letter-spacing: .14em;
202
- text-transform: uppercase;
203
- color: rgba(255, 255, 255, .78);
204
- }
205
-
206
- .card small {
207
- color: var(--muted);
208
- }
209
-
210
- .card .hint {
211
- color: var(--faint);
212
- font-size: 11px;
213
- line-height: 1.35;
214
- margin-top: 6px;
215
- }
216
-
217
- .panel {
218
- background: linear-gradient(180deg, rgba(255, 255, 255, .03), rgba(255, 255, 255, .015));
219
- border: 1px solid var(--stroke);
220
- border-radius: 16px;
221
- padding: 10px;
222
- box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
223
- overflow: hidden;
224
- position: relative;
225
- }
226
-
227
- .panel h3 {
228
- margin: 0 0 8px;
229
- font-size: 12px;
230
- letter-spacing: .14em;
231
- text-transform: uppercase;
232
- color: rgba(255, 255, 255, .78);
233
- display: flex;
234
- align-items: center;
235
- justify-content: space-between;
236
- gap: 8px;
237
- }
238
-
239
- .panel h3 .rightnote {
240
- font-size: 11px;
241
- color: var(--muted);
242
- font-family: var(--mono);
243
- letter-spacing: 0;
244
- text-transform: none;
245
- }
246
-
247
- .collapse-btn {
248
- background: rgba(255, 255, 255, .05);
249
- border: 1px solid rgba(255, 255, 255, .12);
250
- border-radius: 8px;
251
- padding: 4px 8px;
252
- color: var(--muted);
253
- cursor: pointer;
254
- font-size: 11px;
255
- font-family: var(--mono);
256
- transition: all 0.2s ease;
257
- text-transform: none;
258
- letter-spacing: 0;
259
- }
260
-
261
- .collapse-btn:hover {
262
- background: rgba(255, 255, 255, .08);
263
- color: var(--text);
264
- border-color: rgba(255, 255, 255, .18);
265
- }
266
-
267
- /* =========================================
268
- Components: Inputs & Controls
269
- ========================================= */
270
-
271
- .grid2 {
272
- display: grid;
273
- grid-template-columns: 1fr 1fr;
274
- gap: 8px;
275
- margin-top: 10px;
276
- }
277
-
278
- .row {
279
- display: flex;
280
- gap: 8px;
281
- align-items: center;
282
- justify-content: space-between;
283
- margin-top: 8px;
284
- }
285
-
286
- label {
287
- font-size: 11px;
288
- color: var(--muted);
289
- }
290
-
291
- input[type="range"] {
292
- width: 100%;
293
- }
294
-
295
- select,
296
- textarea,
297
- input[type="text"],
298
- input[type="number"] {
299
- width: 100%;
300
- background: rgba(255, 255, 255, .04);
301
- border: 1px solid var(--stroke2);
302
- border-radius: 10px;
303
- padding: 8px 10px;
304
- color: var(--text);
305
- outline: none;
306
- font-size: 12px;
307
- }
308
-
309
- select:focus,
310
- textarea:focus,
311
- input[type="text"]:focus,
312
- input[type="number"]:focus {
313
- border-color: rgba(124, 58, 237, .55);
314
- box-shadow: 0 0 0 3px rgba(124, 58, 237, .16);
315
- }
316
-
317
- .btn {
318
- user-select: none;
319
- cursor: pointer;
320
- border: none;
321
- border-radius: 12px;
322
- padding: 10px 12px;
323
- font-weight: 700;
324
- font-size: 12px;
325
- letter-spacing: .04em;
326
- color: rgba(255, 255, 255, .92);
327
- background: linear-gradient(135deg, rgba(124, 58, 237, .95), rgba(34, 211, 238, .45));
328
- box-shadow: 0 18px 40px rgba(124, 58, 237, .24);
329
- }
330
-
331
- .btn:hover {
332
- filter: brightness(1.06);
333
- }
334
-
335
- .btn:active {
336
- transform: translateY(1px);
337
- }
338
-
339
- .btn.secondary {
340
- background: rgba(255, 255, 255, .06);
341
- border: 1px solid var(--stroke2);
342
- box-shadow: none;
343
- font-weight: 600;
344
- }
345
-
346
- .btn.secondary:hover {
347
- background: rgba(255, 255, 255, .08);
348
- }
349
-
350
- .btn.danger {
351
- background: linear-gradient(135deg, rgba(239, 68, 68, .95), rgba(251, 113, 133, .55));
352
- box-shadow: 0 18px 40px rgba(239, 68, 68, .18);
353
- }
354
-
355
- .btnrow {
356
- display: flex;
357
- gap: 8px;
358
- margin-top: 10px;
359
- }
360
-
361
- .btnrow .btn {
362
- flex: 1;
363
- }
364
-
365
- .pill {
366
- display: flex;
367
- align-items: center;
368
- gap: 10px;
369
- padding: 8px 12px;
370
- border-radius: 999px;
371
- border: 1px solid var(--stroke2);
372
- background: rgba(255, 255, 255, .04);
373
- box-shadow: 0 10px 26px rgba(0, 0, 0, .35);
374
- font-size: 12px;
375
- color: var(--muted);
376
- white-space: nowrap;
377
- }
378
-
379
- .dot {
380
- width: 8px;
381
- height: 8px;
382
- border-radius: 50%;
383
- background: var(--good);
384
- box-shadow: 0 0 16px rgba(34, 197, 94, .6);
385
- }
386
-
387
- .dot.warn {
388
- background: var(--warn);
389
- box-shadow: 0 0 16px rgba(245, 158, 11, .55);
390
- }
391
-
392
- .dot.bad {
393
- background: var(--bad);
394
- box-shadow: 0 0 16px rgba(239, 68, 68, .55);
395
- }
396
-
397
- .kbd {
398
- font-family: var(--mono);
399
- font-size: 11px;
400
- padding: 2px 6px;
401
- border: 1px solid var(--stroke2);
402
- border-bottom-color: rgba(255, 255, 255, .24);
403
- background: rgba(0, 0, 0, .35);
404
- border-radius: 7px;
405
- color: rgba(255, 255, 255, .78);
406
- }
407
-
408
- .badge {
409
- display: inline-flex;
410
- align-items: center;
411
- gap: 6px;
412
- padding: 4px 8px;
413
- border-radius: 999px;
414
- border: 1px solid var(--stroke2);
415
- background: rgba(0, 0, 0, .25);
416
- font-family: var(--mono);
417
- }
418
-
419
- /* =========================================
420
- Navigation: Tabs
421
- ========================================= */
422
-
423
- .tabs {
424
- display: flex;
425
- gap: 8px;
426
- padding: 10px 12px;
427
- border-bottom: 1px solid var(--stroke);
428
- background: linear-gradient(180deg, rgba(255, 255, 255, .035), transparent);
429
- flex-wrap: wrap;
430
- }
431
-
432
- .tabbtn {
433
- cursor: pointer;
434
- border: none;
435
- border-radius: 999px;
436
- padding: 8px 12px;
437
- font-size: 12px;
438
- color: rgba(255, 255, 255, .75);
439
- background: rgba(255, 255, 255, .04);
440
- border: 1px solid var(--stroke2);
441
- }
442
-
443
- .tabbtn.active {
444
- color: rgba(255, 255, 255, .92);
445
- background: linear-gradient(135deg, rgba(124, 58, 237, .35), rgba(34, 211, 238, .10));
446
- border-color: rgba(124, 58, 237, .45);
447
- box-shadow: 0 0 0 3px rgba(124, 58, 237, .14);
448
- }
449
-
450
- .tab {
451
- display: none;
452
- flex: 1;
453
- min-height: 0;
454
- overflow: auto;
455
- padding: 12px;
456
- }
457
-
458
- .tab.active {
459
- display: block;
460
- }
461
-
462
- /* =========================================
463
- Visualization: Views & Canvas
464
- ========================================= */
465
-
466
- .viewbox {
467
- position: relative;
468
- border-radius: 14px;
469
- overflow: hidden;
470
- background: radial-gradient(700px 380px at 30% 30%, rgba(124, 58, 237, .12), rgba(0, 0, 0, .0) 60%),
471
- linear-gradient(180deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .15));
472
- border: 1px solid rgba(255, 255, 255, .08);
473
- min-height: 360px;
474
- }
475
-
476
- .viewbox canvas,
477
- .viewbox video {
478
- width: 100%;
479
- height: 100%;
480
- display: block;
481
- object-fit: contain;
482
- /* Maintain aspect ratio, letterbox if needed */
483
- }
484
-
485
- /* Always show the engage video feed */
486
- #videoEngage {
487
- display: block;
488
- opacity: 1;
489
- }
490
-
491
- /* Overlay must use same object-fit as the main canvas to align properly */
492
- .viewbox .overlay {
493
- position: absolute;
494
- top: 0;
495
- left: 0;
496
- width: 100%;
497
- height: 100%;
498
- pointer-events: none;
499
- object-fit: contain;
500
- /* CRITICAL: match frameCanvas object-fit */
501
- }
502
-
503
- /* Engage overlay: transparent by default, receives clicks for track selection */
504
- #engageOverlay {
505
- pointer-events: auto;
506
- cursor: crosshair;
507
- }
508
-
509
- .viewbox .watermark {
510
- position: absolute;
511
- left: 10px;
512
- bottom: 10px;
513
- font-family: var(--mono);
514
- font-size: 11px;
515
- color: rgba(255, 255, 255, .55);
516
- background: rgba(0, 0, 0, .35);
517
- border: 1px solid rgba(255, 255, 255, .14);
518
- padding: 6px 8px;
519
- border-radius: 10px;
520
- }
521
-
522
- .viewbox .empty {
523
- position: absolute;
524
- inset: 0;
525
- display: flex;
526
- flex-direction: column;
527
- align-items: center;
528
- justify-content: center;
529
- gap: 10px;
530
- color: rgba(255, 255, 255, .72);
531
- text-align: center;
532
- padding: 22px;
533
- }
534
-
535
- .viewbox .empty .big {
536
- font-size: 14px;
537
- letter-spacing: .12em;
538
- text-transform: uppercase;
539
- }
540
-
541
- .viewbox .empty .small {
542
- color: var(--muted);
543
- font-size: 12px;
544
- max-width: 520px;
545
- line-height: 1.4;
546
- }
547
-
548
- /* =========================================
549
- Lists & Tables
550
- ========================================= */
551
-
552
- .list {
553
- display: flex;
554
- flex-direction: column;
555
- gap: 6px;
556
- min-height: 160px;
557
- max-height: none;
558
- overflow-y: auto;
559
- padding-right: 4px;
560
- }
561
-
562
- .obj {
563
- padding: 10px;
564
- border-radius: 14px;
565
- border: 1px solid var(--stroke2);
566
- background: rgba(255, 255, 255, .03);
567
- cursor: pointer;
568
- }
569
-
570
- .obj:hover {
571
- background: rgba(255, 255, 255, .05);
572
- }
573
-
574
- .obj.active {
575
- border-color: rgba(34, 211, 238, .45);
576
- box-shadow: 0 0 0 3px rgba(34, 211, 238, .14);
577
- background: linear-gradient(135deg, rgba(34, 211, 238, .10), rgba(124, 58, 237, .08));
578
- }
579
-
580
- .obj .top {
581
- display: flex;
582
- align-items: center;
583
- justify-content: space-between;
584
- gap: 10px;
585
- }
586
-
587
- .obj .id {
588
- font-family: var(--mono);
589
- font-size: 12px;
590
- color: rgba(255, 255, 255, .90);
591
- }
592
-
593
- .obj .cls {
594
- font-size: 12px;
595
- color: rgba(255, 255, 255, .80);
596
- }
597
-
598
- .obj .meta {
599
- margin-top: 6px;
600
- display: flex;
601
- gap: 10px;
602
- flex-wrap: wrap;
603
- font-size: 11px;
604
- color: var(--muted);
605
- }
606
-
607
- .table {
608
- width: 100%;
609
- border-collapse: separate;
610
- border-spacing: 0;
611
- overflow: hidden;
612
- border-radius: 14px;
613
- border: 1px solid rgba(255, 255, 255, .10);
614
- }
615
-
616
- .table th,
617
- .table td {
618
- padding: 8px 10px;
619
- font-size: 12px;
620
- border-bottom: 1px solid rgba(255, 255, 255, .08);
621
- vertical-align: top;
622
- }
623
-
624
- .table th {
625
- background: rgba(255, 255, 255, .04);
626
- color: rgba(255, 255, 255, .78);
627
- letter-spacing: .12em;
628
- text-transform: uppercase;
629
- font-size: 11px;
630
- }
631
-
632
- .table tr:last-child td {
633
- border-bottom: none;
634
- }
635
-
636
- .k {
637
- font-family: var(--mono);
638
- color: rgba(255, 255, 255, .84);
639
- }
640
-
641
- .mini {
642
- font-size: 11px;
643
- color: var(--muted);
644
- line-height: 1.35;
645
- }
646
-
647
- /* =========================================
648
- Metrics & Logs
649
- ========================================= */
650
-
651
- .metricgrid {
652
- display: grid;
653
- grid-template-columns: 1fr 1fr;
654
- gap: 8px;
655
- }
656
-
657
- .metric {
658
- border: 1px solid rgba(255, 255, 255, .10);
659
- background: rgba(255, 255, 255, .03);
660
- border-radius: 14px;
661
- padding: 10px;
662
- }
663
-
664
- .metric .label {
665
- font-size: 11px;
666
- color: var(--muted);
667
- letter-spacing: .12em;
668
- text-transform: uppercase;
669
- }
670
-
671
- .metric .value {
672
- margin-top: 6px;
673
- font-family: var(--mono);
674
- font-size: 16px;
675
- color: rgba(255, 255, 255, .92);
676
- }
677
-
678
- .metric .sub {
679
- margin-top: 4px;
680
- font-size: 11px;
681
- color: var(--faint);
682
- line-height: 1.35;
683
- }
684
-
685
- .log {
686
- font-family: var(--mono);
687
- font-size: 11px;
688
- color: rgba(255, 255, 255, .78);
689
- line-height: 1.45;
690
- background: rgba(0, 0, 0, .35);
691
- border: 1px solid rgba(255, 255, 255, .12);
692
- border-radius: 14px;
693
- padding: 10px;
694
- height: 210px;
695
- overflow: auto;
696
- white-space: pre-wrap;
697
- }
698
-
699
- .log .t {
700
- color: rgba(34, 211, 238, .95);
701
- }
702
-
703
- .log .w {
704
- color: rgba(245, 158, 11, .95);
705
- }
706
-
707
- .log .e {
708
- color: rgba(239, 68, 68, .95);
709
- }
710
-
711
- .log .g {
712
- color: rgba(34, 197, 94, .95);
713
- }
714
-
715
- /* =========================================
716
- Tab Specific: Intel + Frame
717
- ========================================= */
718
-
719
- .frame-grid {
720
- display: grid;
721
- grid-template-columns: 1.6fr 0.9fr;
722
- grid-template-rows: auto auto;
723
- gap: 12px;
724
- min-height: 0;
725
- }
726
-
727
- /* Video panel on left */
728
- .frame-grid .panel-monitor {
729
- grid-column: 1;
730
- grid-row: 1 / 3;
731
- }
732
-
733
- /* Track cards on right, spanning full height */
734
- .frame-grid .panel-summary {
735
- grid-column: 2;
736
- grid-row: 1 / 3;
737
- }
738
-
739
- .intel {
740
- margin-top: 10px;
741
- display: flex;
742
- flex-direction: column;
743
- gap: 8px;
744
- }
745
-
746
- .intel-top {
747
- display: flex;
748
- align-items: center;
749
- justify-content: space-between;
750
- gap: 8px;
751
- }
752
-
753
- .thumbrow {
754
- display: flex;
755
- gap: 8px;
756
- }
757
-
758
- .thumbrow img {
759
- flex: 1;
760
- height: 86px;
761
- object-fit: cover;
762
- border-radius: 12px;
763
- border: 1px solid rgba(255, 255, 255, .12);
764
- background: rgba(0, 0, 0, .25);
765
- }
766
-
767
- .intelbox {
768
- font-size: 12px;
769
- line-height: 1.45;
770
- color: rgba(255, 255, 255, .84);
771
- background: rgba(0, 0, 0, .35);
772
- border: 1px solid rgba(255, 255, 255, .12);
773
- border-radius: 14px;
774
- padding: 10px;
775
- min-height: 72px;
776
- }
777
-
778
- /* =========================================
779
- Tab Specific: Engage
780
- ========================================= */
781
-
782
- .engage-grid {
783
- display: grid;
784
- grid-template-columns: 1.6fr .9fr;
785
- gap: 12px;
786
- min-height: 0;
787
- transition: grid-template-columns 0.3s ease;
788
- }
789
-
790
- .engage-grid.sidebar-collapsed {
791
- grid-template-columns: 1fr 0fr;
792
- }
793
-
794
- .engage-grid.sidebar-collapsed .engage-right {
795
- display: none;
796
- }
797
-
798
- .engage-right {
799
- display: flex;
800
- flex-direction: column;
801
- gap: 12px;
802
- min-height: 0;
803
- }
804
-
805
- .radar {
806
- height: 540px;
807
- display: flex;
808
- flex-direction: column;
809
- }
810
-
811
- .radar canvas {
812
- flex: 1;
813
- width: 100%;
814
- height: 100%;
815
- display: block;
816
- }
817
-
818
- .strip {
819
- display: flex;
820
- gap: 8px;
821
- flex-wrap: wrap;
822
- align-items: center;
823
- font-size: 12px;
824
- color: var(--muted);
825
- }
826
-
827
- .strip .chip {
828
- padding: 6px 10px;
829
- border-radius: 999px;
830
- border: 1px solid rgba(255, 255, 255, .12);
831
- background: rgba(255, 255, 255, .03);
832
- font-family: var(--mono);
833
- color: rgba(255, 255, 255, .78);
834
- }
835
-
836
- /* Sidebar Checkbox Row */
837
- .checkbox-row {
838
- grid-column: span 2;
839
- margin-top: 8px;
840
- border-top: 1px solid var(--stroke2);
841
- padding-top: 8px;
842
- display: flex;
843
- align-items: center;
844
- gap: 8px;
845
- cursor: pointer;
846
- }
847
-
848
- .checkbox-row input[type="checkbox"] {
849
- width: auto;
850
- margin: 0;
851
- }
852
-
853
- .bar {
854
- height: 10px;
855
- border-radius: 999px;
856
- background: rgba(255, 255, 255, .08);
857
- border: 1px solid rgba(255, 255, 255, .12);
858
- overflow: hidden;
859
- }
860
-
861
- .bar>div {
862
- height: 100%;
863
- width: 0%;
864
- background: linear-gradient(90deg, rgba(34, 211, 238, .95), rgba(124, 58, 237, .95));
865
- transition: width .18s ease;
866
- }
867
-
868
- /* =========================================
869
- Tab Specific: Trade Space
870
- ========================================= */
871
-
872
- .trade-grid {
873
- display: grid;
874
- grid-template-columns: 1.35fr .65fr;
875
- gap: 12px;
876
- min-height: 0;
877
- }
878
-
879
- .plot {
880
- height: 420px;
881
- }
882
-
883
- /* =========================================
884
- Utilities
885
- ========================================= */
886
-
887
- ::-webkit-scrollbar {
888
- width: 10px;
889
- height: 10px;
890
- }
891
-
892
- ::-webkit-scrollbar-thumb {
893
- background: rgba(255, 255, 255, .10);
894
- border-radius: 999px;
895
- border: 2px solid rgba(0, 0, 0, .25);
896
- }
897
-
898
- ::-webkit-scrollbar-thumb:hover {
899
- background: rgba(255, 255, 255, .16);
900
- }
901
-
902
- /* Track Cards */
903
- .track-card {
904
- background: rgba(255, 255, 255, 0.025);
905
- border: 1px solid rgba(255, 255, 255, .10);
906
- border-radius: 10px;
907
- padding: 10px 12px;
908
- margin-bottom: 0;
909
- cursor: pointer;
910
- transition: all 0.15s ease;
911
- }
912
-
913
- .track-card:hover {
914
- background: rgba(255, 255, 255, 0.06);
915
- border-color: rgba(255, 255, 255, .18);
916
- }
917
-
918
- .track-card.active {
919
- border-color: rgba(124, 58, 237, .55);
920
- background: linear-gradient(135deg, rgba(124, 58, 237, .12), rgba(34, 211, 238, .06));
921
- box-shadow: 0 0 0 2px rgba(124, 58, 237, .15);
922
- }
923
-
924
- .track-card-header {
925
- display: flex;
926
- justify-content: space-between;
927
- align-items: center;
928
- font-weight: 600;
929
- margin-bottom: 6px;
930
- font-size: 12px;
931
- color: rgba(255, 255, 255, .92);
932
- letter-spacing: .02em;
933
- }
934
-
935
- .track-card-meta {
936
- font-size: 10px;
937
- font-family: var(--mono);
938
- color: rgba(255, 255, 255, .50);
939
- margin-bottom: 6px;
940
- letter-spacing: .03em;
941
- }
942
-
943
- .track-card-body {
944
- font-size: 11px;
945
- line-height: 1.45;
946
- color: rgba(255, 255, 255, .72);
947
- background: rgba(0, 0, 0, 0.25);
948
- padding: 8px 10px;
949
- border-radius: 8px;
950
- border: 1px solid rgba(255, 255, 255, .05);
951
- }
952
-
953
- .badgemini {
954
- font-size: 10px;
955
- font-weight: 600;
956
- padding: 2px 6px;
957
- border-radius: 6px;
958
- letter-spacing: .03em;
959
- line-height: 1;
960
- }
961
-
962
- .track-card-features {
963
- margin-top: 8px;
964
- border-top: 1px solid rgba(255, 255, 255, .06);
965
- padding-top: 8px;
966
- display: grid;
967
- grid-template-columns: 1fr 1fr;
968
- gap: 4px 12px;
969
- }
970
-
971
- .track-card-features .feat-row {
972
- display: flex;
973
- justify-content: space-between;
974
- align-items: baseline;
975
- font-size: 10px;
976
- padding: 2px 0;
977
- }
978
-
979
- .track-card-features .feat-key {
980
- font-family: var(--mono);
981
- color: rgba(255, 255, 255, .50);
982
- letter-spacing: .02em;
983
- white-space: nowrap;
984
- margin-right: 6px;
985
- }
986
-
987
- .track-card-features .feat-val {
988
- color: rgba(255, 255, 255, .85);
989
- text-align: right;
990
- font-size: 10px;
991
- }
992
-
993
- .gpt-badge {
994
- color: gold;
995
- font-size: 10px;
996
- border: 1px solid gold;
997
- border-radius: 3px;
998
- padding: 1px 4px;
999
- margin-left: 6px;
1000
- }
1001
-
1002
- .gpt-text {
1003
- color: rgba(255, 255, 255, .78);
1004
- }
1005
-
1006
- /* =========================================
1007
- Threat Chat Panel
1008
- ========================================= */
1009
-
1010
- .panel-chat {
1011
- grid-column: 1 / -1;
1012
- grid-row: 3;
1013
- max-height: 260px;
1014
- display: flex;
1015
- flex-direction: column;
1016
- }
1017
-
1018
- .panel-chat.collapsed .chat-container {
1019
- display: none;
1020
- }
1021
-
1022
- .panel-chat.collapsed {
1023
- max-height: 44px;
1024
- }
1025
-
1026
- .chat-container {
1027
- display: flex;
1028
- flex-direction: column;
1029
- flex: 1;
1030
- min-height: 0;
1031
- gap: 8px;
1032
- }
1033
-
1034
- .chat-messages {
1035
- flex: 1;
1036
- overflow-y: auto;
1037
- background: rgba(0, 0, 0, .35);
1038
- border: 1px solid rgba(255, 255, 255, .10);
1039
- border-radius: 12px;
1040
- padding: 10px;
1041
- display: flex;
1042
- flex-direction: column;
1043
- gap: 8px;
1044
- min-height: 100px;
1045
- max-height: 160px;
1046
- }
1047
-
1048
- .chat-message {
1049
- display: flex;
1050
- gap: 8px;
1051
- padding: 8px 10px;
1052
- border-radius: 10px;
1053
- font-size: 12px;
1054
- line-height: 1.4;
1055
- animation: fadeIn 0.2s ease;
1056
- }
1057
-
1058
- @keyframes fadeIn {
1059
- from {
1060
- opacity: 0;
1061
- transform: translateY(4px);
1062
- }
1063
-
1064
- to {
1065
- opacity: 1;
1066
- transform: translateY(0);
1067
- }
1068
- }
1069
-
1070
- .chat-message.chat-user {
1071
- background: linear-gradient(135deg, rgba(124, 58, 237, .25), rgba(34, 211, 238, .10));
1072
- border: 1px solid rgba(124, 58, 237, .35);
1073
- margin-left: 20px;
1074
- }
1075
-
1076
- .chat-message.chat-assistant {
1077
- background: rgba(255, 255, 255, .04);
1078
- border: 1px solid rgba(255, 255, 255, .10);
1079
- margin-right: 20px;
1080
- }
1081
-
1082
- .chat-message.chat-system {
1083
- background: rgba(245, 158, 11, .10);
1084
- border: 1px solid rgba(245, 158, 11, .25);
1085
- color: rgba(245, 158, 11, .95);
1086
- font-size: 11px;
1087
- }
1088
-
1089
- .chat-message.loading .chat-content::after {
1090
- content: "";
1091
- display: inline-block;
1092
- width: 12px;
1093
- height: 12px;
1094
- border: 2px solid rgba(255, 255, 255, .3);
1095
- border-top-color: var(--cyan);
1096
- border-radius: 50%;
1097
- animation: spin 0.8s linear infinite;
1098
- margin-left: 8px;
1099
- vertical-align: middle;
1100
- }
1101
-
1102
- .chat-icon {
1103
- flex-shrink: 0;
1104
- font-size: 14px;
1105
- }
1106
-
1107
- .chat-content {
1108
- flex: 1;
1109
- color: rgba(255, 255, 255, .88);
1110
- word-break: break-word;
1111
- }
1112
-
1113
- .chat-input-row {
1114
- display: flex;
1115
- gap: 8px;
1116
- }
1117
-
1118
- .chat-input-row input {
1119
- flex: 1;
1120
- background: rgba(255, 255, 255, .04);
1121
- border: 1px solid rgba(255, 255, 255, .12);
1122
- border-radius: 10px;
1123
- padding: 10px 12px;
1124
- color: var(--text);
1125
- font-size: 12px;
1126
- outline: none;
1127
- }
1128
-
1129
- .chat-input-row input:focus {
1130
- border-color: rgba(124, 58, 237, .55);
1131
- box-shadow: 0 0 0 3px rgba(124, 58, 237, .16);
1132
- }
1133
-
1134
- .chat-input-row input:disabled {
1135
- opacity: 0.5;
1136
- cursor: not-allowed;
1137
- }
1138
-
1139
- .chat-input-row .btn {
1140
- padding: 10px 16px;
1141
- min-width: 70px;
1142
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Detection Base</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, sans-serif; background: #111; color: #eee; padding: 24px; max-width: 960px; margin: 0 auto; }
10
+ h1 { margin-bottom: 16px; font-size: 1.4em; }
11
+ label { display: block; margin-top: 12px; font-size: 0.85em; color: #aaa; }
12
+ select, input[type="file"], input[type="number"] { width: 100%; padding: 8px; margin-top: 4px; background: #222; color: #eee; border: 1px solid #444; border-radius: 4px; }
13
+ .row { display: flex; gap: 12px; flex-wrap: wrap; }
14
+ .row > div { flex: 1; min-width: 140px; }
15
+ button { margin-top: 16px; padding: 10px 24px; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; }
16
+ button:disabled { background: #555; cursor: not-allowed; }
17
+ #status { margin-top: 12px; padding: 8px; font-size: 0.9em; color: #aaa; }
18
+ .preview { margin-top: 16px; }
19
+ .preview img, .preview video { max-width: 100%; border-radius: 4px; border: 1px solid #333; }
20
+ #detections { margin-top: 12px; background: #1a1a1a; padding: 12px; border-radius: 4px; max-height: 300px; overflow: auto; font-family: monospace; font-size: 0.8em; white-space: pre-wrap; display: none; }
21
+ .toggle { margin-top: 8px; font-size: 0.85em; color: #6ba3ff; cursor: pointer; text-decoration: underline; }
22
+ .hidden { display: none; }
23
+ #segOpts { display: none; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <h1>Detection Base</h1>
28
+
29
+ <label for="video">Video</label>
30
+ <input type="file" id="video" accept="video/*">
31
+
32
+ <div class="row">
33
+ <div>
34
+ <label>Mode</label>
35
+ <select id="mode">
36
+ <option value="object_detection">Object Detection</option>
37
+ <option value="segmentation">Segmentation</option>
38
+ <option value="drone_detection">Drone Detection</option>
39
+ </select>
40
+ </div>
41
+ <div>
42
+ <label>Detector</label>
43
+ <select id="detector">
44
+ <option value="yolo11">YOLO11</option>
45
+ <option value="detr_resnet50">DETR</option>
46
+ <option value="grounding_dino">Grounding DINO</option>
47
+ <option value="drone_yolo">Drone YOLO</option>
48
+ </select>
49
+ </div>
50
+ </div>
51
+
52
+ <div id="segOpts" class="row">
53
+ <div>
54
+ <label>Segmenter</label>
55
+ <select id="segmenter">
56
+ <option value="GSAM2-L">GSAM2-L</option>
57
+ <option value="GSAM2-B">GSAM2-B</option>
58
+ <option value="GSAM2-S">GSAM2-S</option>
59
+ <option value="YSAM2-L">YSAM2-L</option>
60
+ <option value="YSAM2-B">YSAM2-B</option>
61
+ <option value="YSAM2-S">YSAM2-S</option>
62
+ </select>
63
+ </div>
64
+ <div>
65
+ <label>Step (keyframe interval)</label>
66
+ <input type="number" id="step" value="7" min="1" max="60">
67
+ </div>
68
+ </div>
69
+
70
+ <label>Queries (comma-separated, optional)</label>
71
+ <input type="text" id="queries" placeholder="person, car, truck" style="width:100%;padding:8px;margin-top:4px;background:#222;color:#eee;border:1px solid #444;border-radius:4px;">
72
+
73
+ <div class="row">
74
+ <div>
75
+ <label><input type="checkbox" id="enableDepth"> Enable depth estimation</label>
76
+ </div>
77
+ </div>
78
+
79
+ <button id="submit" onclick="submit()">Run Detection</button>
80
+ <div id="status"></div>
81
+
82
+ <div class="preview">
83
+ <div id="firstFrameBox" class="hidden">
84
+ <p>First Frame</p>
85
+ <img id="firstFrame" alt="First frame detection preview">
86
+ </div>
87
+ <div id="streamBox" class="hidden">
88
+ <p>Live Stream</p>
89
+ <img id="stream" alt="Live MJPEG detection stream">
90
+ </div>
91
+ <div id="videoBox" class="hidden">
92
+ <p>Processed Video</p>
93
+ <video id="result" controls type="video/mp4"></video>
94
+ </div>
95
+ </div>
96
+ <span class="toggle hidden" id="detToggle" onclick="toggleDets()">Show detections JSON</span>
97
+ <pre id="detections"></pre>
98
+
99
+ <script>
100
+ const $ = id => document.getElementById(id);
101
+
102
+ $('mode').onchange = () => {
103
+ const seg = $('mode').value === 'segmentation';
104
+ $('segOpts').style.display = seg ? 'flex' : 'none';
105
+ $('detector').parentElement.style.display = seg ? 'none' : '';
106
+ };
107
+
108
+ let pollTimer = null;
109
+
110
+ async function submit() {
111
+ const file = $('video').files[0];
112
+ if (!file) return alert('Select a video file');
113
+
114
+ $('submit').disabled = true;
115
+ $('status').textContent = 'Uploading...';
116
+ hide('firstFrameBox'); hide('streamBox'); hide('videoBox'); hide('detToggle');
117
+ $('detections').style.display = 'none';
118
+
119
+ const fd = new FormData();
120
+ fd.append('video', file);
121
+ fd.append('mode', $('mode').value);
122
+ fd.append('queries', $('queries').value);
123
+ fd.append('detector', $('detector').value);
124
+ fd.append('segmenter', $('segmenter').value);
125
+ fd.append('step', $('step').value);
126
+ fd.append('enable_depth', $('enableDepth').checked);
127
+
128
+ try {
129
+ const res = await fetch('/detect/async', { method: 'POST', body: fd });
130
+ if (!res.ok) { const e = await res.json(); throw new Error(e.detail || res.statusText); }
131
+ const data = await res.json();
132
+
133
+ $('status').textContent = 'Processing...';
134
+
135
+ // Show first frame
136
+ $('firstFrame').src = data.first_frame_url;
137
+ show('firstFrameBox');
138
+
139
+ // Show detections
140
+ if (data.first_frame_detections && data.first_frame_detections.length) {
141
+ $('detections').textContent = JSON.stringify(data.first_frame_detections, null, 2);
142
+ show('detToggle');
143
+ }
144
+
145
+ // Start stream
146
+ $('stream').src = data.stream_url;
147
+ show('streamBox');
148
+
149
+ // Poll for completion
150
+ pollTimer = setInterval(async () => {
151
+ try {
152
+ const sr = await fetch(data.status_url);
153
+ const st = await sr.json();
154
+ $('status').textContent = `Status: ${st.status}`;
155
+
156
+ if (st.status === 'completed') {
157
+ clearInterval(pollTimer);
158
+ hide('streamBox');
159
+ $('result').src = data.video_url;
160
+ show('videoBox');
161
+ $('submit').disabled = false;
162
+ // Update detections with final data
163
+ if (st.first_frame_detections && st.first_frame_detections.length) {
164
+ $('detections').textContent = JSON.stringify(st.first_frame_detections, null, 2);
165
+ show('detToggle');
166
+ }
167
+ } else if (st.status === 'failed') {
168
+ clearInterval(pollTimer);
169
+ $('status').textContent = `Failed: ${st.error}`;
170
+ $('submit').disabled = false;
171
+ } else if (st.status === 'cancelled') {
172
+ clearInterval(pollTimer);
173
+ $('status').textContent = 'Cancelled';
174
+ $('submit').disabled = false;
175
+ }
176
+ } catch(e) { /* keep polling */ }
177
+ }, 2000);
178
+
179
+ } catch(e) {
180
+ $('status').textContent = `Error: ${e.message}`;
181
+ $('submit').disabled = false;
182
+ }
183
+ }
184
+
185
+ function show(id) { $(id).classList.remove('hidden'); }
186
+ function hide(id) {
187
+ $(id).classList.add('hidden');
188
+ const img = $(id).querySelector('img');
189
+ if (img && img.src) img.src = '';
190
+ }
191
+ function toggleDets() {
192
+ const d = $('detections');
193
+ d.style.display = d.style.display === 'none' ? 'block' : 'none';
194
+ }
195
+ </script>
196
+ </body>
197
+ </html>