Zhen Ye commited on
Commit
c17ec01
·
1 Parent(s): 811bd37

Refactor frontend into modular ES modules in frontend/ folder

Browse files
frontend/index.html ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>HEL Perception & Engagement Reasoner (Weapon-Grade Demo)</title>
9
+ </head>
10
+
11
+ <body>
12
+ <div id="app">
13
+ <!-- ... body content ... -->
14
+ </div>
15
+
16
+ <script>
17
+ window.API_CONFIG = {
18
+ BACKEND_BASE: "https://biaslab2025-perception.hf.space"
19
+ };
20
+ </script>
21
+ <script type="module" src="./js/main.js"></script>
22
+
23
+ </body>
24
+
25
+ </html>
frontend/js/LaserPerception_original.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/js/api/client.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from '../core/state.js';
2
+ import { CONFIG } from '../core/config.js';
3
+ import { log, setHfStatus } from '../ui/logging.js';
4
+
5
+ export async function hfDetectAsync(formData) {
6
+ if (!state.hf.baseUrl) return; // Should handle error or fallback
7
+
8
+ // Default to state values if not provided in formData
9
+ // Assuming formData is constructed in UI layer and passed here
10
+
11
+ // Wrapper for the POST /detect/async call
12
+ const resp = await fetch(`${state.hf.baseUrl}/detect/async`, {
13
+ method: "POST",
14
+ body: formData // already contains video, mode, queries, etc.
15
+ });
16
+
17
+ if (!resp.ok) {
18
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
19
+ throw new Error(err.detail || "Async detection submission failed");
20
+ }
21
+
22
+ const data = await resp.json();
23
+ return data;
24
+ }
25
+
26
+ export async function checkJobStatus(jobId) {
27
+ if (!state.hf.baseUrl) return { status: "error" };
28
+
29
+ // Note: Using statusUrl from state if available, or constructing it
30
+ const url = state.hf.statusUrl || `${state.hf.baseUrl}/detect/job/${jobId}`;
31
+ const resp = await fetch(url, { cache: "no-store" });
32
+
33
+ if (!resp.ok) {
34
+ if (resp.status === 404) return { status: "not_found" };
35
+ throw new Error(`Status check failed: ${resp.status}`);
36
+ }
37
+
38
+ return await resp.json();
39
+ }
40
+
41
+ export async function cancelBackendJob(jobId) {
42
+ if (!state.hf.baseUrl || !jobId) return;
43
+ if (state.hf.baseUrl.includes("hf.space")) {
44
+ return { status: "skipped", message: "Cancel disabled for HF Space" };
45
+ }
46
+
47
+ const resp = await fetch(`${state.hf.baseUrl}/detect/job/${jobId}`, {
48
+ method: "DELETE"
49
+ });
50
+
51
+ if (resp.ok) return await resp.json();
52
+ if (resp.status === 404) return { status: "not_found" };
53
+ throw new Error("Cancel failed");
54
+ }
55
+
56
+ export async function reasonTrack(trackPayload) {
57
+ // trackPayload: { frame: "base64...", boxes: [[x,y,x,y],...] }
58
+ const resp = await fetch(`${state.hf.baseUrl}/reason/track`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(trackPayload),
62
+ cache: "no-store"
63
+ });
64
+
65
+ if (!resp.ok) throw new Error("Reasoning failed");
66
+ return await resp.json();
67
+ }
frontend/js/core/config.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const 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/physics.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $ } from './utils.js';
2
+
3
+ // ========= Aimpoint rules =========
4
+ export function defaultAimpoint(label) {
5
+ const l = (label || "object").toLowerCase();
6
+ if (l.includes("airplane") || l.includes("drone") || l.includes("uav") || l.includes("kite") || l.includes("bird")) {
7
+ return { relx: 0.62, rely: 0.55, label: "engine" };
8
+ }
9
+ if (l.includes("helicopter")) {
10
+ return { relx: 0.50, rely: 0.45, label: "rotor_hub" };
11
+ }
12
+ if (l.includes("boat") || l.includes("ship")) {
13
+ return { relx: 0.60, rely: 0.55, label: "bridge/engine" };
14
+ }
15
+ if (l.includes("truck") || l.includes("car")) {
16
+ return { relx: 0.55, rely: 0.62, label: "engine_block" };
17
+ }
18
+ return { relx: 0.50, rely: 0.55, label: "center_mass" };
19
+ }
20
+
21
+ export function aimpointByLabel(label) {
22
+ const l = String(label || "").toLowerCase();
23
+ if (l.includes("engine") || l.includes("fuel")) return { relx: 0.64, rely: 0.58, label: label };
24
+ if (l.includes("wing")) return { relx: 0.42, rely: 0.52, label: label };
25
+ if (l.includes("nose") || l.includes("sensor")) return { relx: 0.28, rely: 0.48, label: label };
26
+ if (l.includes("rotor")) return { relx: 0.52, rely: 0.42, label: label };
27
+ return { relx: 0.50, rely: 0.55, label: label || "center_mass" };
28
+ }
29
+
30
+ // ========= Core Physics & Logic Adapters =========
31
+ export function getKnobs() {
32
+ const helPower = $("#hel-power");
33
+ const helAperture = $("#hel-aperture");
34
+ const helM2 = $("#hel-m2");
35
+ const helJitter = $("#hel-jitter");
36
+ const helDuty = $("#hel-duty");
37
+ const helMode = $("#hel-mode");
38
+ const atmVis = $("#atm-vis");
39
+ const atmCn2 = $("#atm-cn2");
40
+ const seaSpray = $("#sea-spray");
41
+ const aoQ = $("#ao-q");
42
+ const rangeBase = $("#range-base");
43
+
44
+ // Guard against missing UI elements if loaded before DOM
45
+ if (!helPower) return {};
46
+
47
+ const PkW = +helPower.value;
48
+ const aperture = +helAperture.value;
49
+ const M2 = +helM2.value;
50
+ const jitter_urad = +helJitter.value;
51
+ const duty = (+helDuty.value) / 100;
52
+ const mode = helMode.value;
53
+ const vis_km = +atmVis.value;
54
+ const cn2 = +atmCn2.value;
55
+ const spray = +seaSpray.value;
56
+ const ao = +aoQ.value;
57
+ const baseRange = +rangeBase.value;
58
+ return { PkW, aperture, M2, jitter_urad, duty, mode, vis_km, cn2, spray, ao, baseRange };
59
+ }
60
+
61
+ // ========= Safe Stubs for Client-Side Visualization =========
62
+ export function maxPowerAtTarget(range_m) {
63
+ // Placeholder: return 0 or simple fallback
64
+ return { Ptar: 0, Pout: 0, trans: 0, turb: 0, beam: 0 };
65
+ }
66
+
67
+ export function requiredPowerFromFeatures(feat) { return 10; } // Safe default
68
+
69
+ export function requiredDwell(range_m, reqP, maxP, baseDwell) { return 1.0; } // Safe default
70
+
71
+ export function pkillFromMargin(margin_kW, dwell_s, reqDwell_s) { return 0; }
frontend/js/core/state.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from './config.js';
2
+
3
+ export const state = {
4
+ videoUrl: null,
5
+ videoFile: null,
6
+ videoLoaded: false,
7
+ useProcessedFeed: false,
8
+ useDepthFeed: false, // Flag for depth view (Tab 2 video)
9
+ useFrameDepthView: false, // Flag for first frame depth view (Tab 1)
10
+ hasReasoned: false,
11
+ isReasoning: false, // Flag to prevent concurrent Reason executions
12
+
13
+ hf: {
14
+ baseUrl: CONFIG.BACKEND_BASE,
15
+ detector: "auto",
16
+ asyncJobId: null, // Current job ID from /detect/async
17
+ asyncPollInterval: null, // Polling timer handle
18
+ firstFrameUrl: null, // First frame preview URL
19
+ firstFrameDetections: null, // First-frame detections from backend
20
+ statusUrl: null, // Status polling URL
21
+ videoUrl: null, // Final video URL
22
+ asyncStatus: "idle", // "idle"|"processing"|"completed"|"failed"
23
+ asyncProgress: null, // Progress data from status endpoint
24
+ queries: [], // Mission objective used as query
25
+ processedUrl: null,
26
+ processedBlob: null,
27
+ depthVideoUrl: null, // Depth video URL
28
+ depthFirstFrameUrl: null, // First frame depth URL
29
+ depthBlob: null, // Depth video blob
30
+ depthFirstFrameBlob: null, // Depth first frame blob
31
+ summary: null,
32
+ busy: false,
33
+ lastError: null
34
+ },
35
+
36
+ detector: {
37
+ mode: "coco",
38
+ kind: "object",
39
+ loaded: false,
40
+ model: null,
41
+ loading: false,
42
+ cocoBlocked: false,
43
+ hfTrackingWarned: false
44
+ },
45
+
46
+ tracker: {
47
+ mode: "iou",
48
+ tracks: [],
49
+ nextId: 1,
50
+ lastDetTime: 0,
51
+ running: false,
52
+ selectedTrackId: null,
53
+ beamOn: false,
54
+ lastFrameTime: 0,
55
+ frameCount: 0
56
+ },
57
+
58
+ frame: {
59
+ w: 1280,
60
+ h: 720,
61
+ bitmap: null
62
+ },
63
+
64
+ detections: [], // from Tab 1
65
+ selectedId: null,
66
+
67
+ intelBusy: false,
68
+
69
+ ui: {
70
+ cursorMode: "on",
71
+ agentCursor: { x: 0.65, y: 0.28, vx: 0, vy: 0, visible: false, target: null, mode: "idle", t0: 0 }
72
+ }
73
+ };
74
+
75
+ // Simple event bus for state changes if needed
76
+ export const events = new EventTarget();
frontend/js/core/tracker.js ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './state.js';
2
+ import { CONFIG } from './config.js';
3
+ import { normBBox, lerp, now, $ } from './utils.js';
4
+ import { defaultAimpoint } from './physics.js';
5
+ import { log } from '../ui/logging.js';
6
+
7
+ const videoEngage = $("#videoEngage");
8
+ const rangeBase = $("#range-base");
9
+
10
+ function iou(a, b) {
11
+ const ax2 = a.x + a.w, ay2 = a.y + a.h;
12
+ const bx2 = b.x + b.w, by2 = b.y + b.h;
13
+ const ix1 = Math.max(a.x, b.x), iy1 = Math.max(a.y, b.y);
14
+ const ix2 = Math.min(ax2, bx2), iy2 = Math.min(ay2, by2);
15
+ const iw = Math.max(0, ix2 - ix1), ih = Math.max(0, iy2 - iy1);
16
+ const inter = iw * ih;
17
+ const ua = a.w * a.h + b.w * b.h - inter;
18
+ return ua <= 0 ? 0 : inter / ua;
19
+ }
20
+
21
+ export function matchAndUpdateTracks(dets, dtSec) {
22
+ if (!videoEngage) return;
23
+
24
+ // Convert detections to bbox in video coordinates
25
+ const w = videoEngage.videoWidth || state.frame.w;
26
+ const h = videoEngage.videoHeight || state.frame.h;
27
+
28
+ const detObjs = dets.map(d => ({
29
+ bbox: normBBox(d.bbox, w, h),
30
+ label: d.class,
31
+ score: d.score,
32
+ depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null
33
+ }));
34
+
35
+ // mark all tracks as unmatched
36
+ const tracks = state.tracker.tracks;
37
+ const used = new Set();
38
+
39
+ for (const tr of tracks) {
40
+ let best = null;
41
+ let bestI = 0.0;
42
+ let bestIdx = -1;
43
+ for (let i = 0; i < detObjs.length; i++) {
44
+ if (used.has(i)) continue;
45
+ const IoU = iou(tr.bbox, detObjs[i].bbox);
46
+ if (IoU > bestI) {
47
+ bestI = IoU;
48
+ best = detObjs[i];
49
+ bestIdx = i;
50
+ }
51
+ }
52
+
53
+ // Strict matching threshold
54
+ if (best && bestI >= CONFIG.TRACK_MATCH_THRESHOLD) {
55
+ used.add(bestIdx);
56
+
57
+ // Velocity with Exponential Moving Average (EMA) for smoothing
58
+ const cx0 = tr.bbox.x + tr.bbox.w * 0.5;
59
+ const cy0 = tr.bbox.y + tr.bbox.h * 0.5;
60
+ const cx1 = best.bbox.x + best.bbox.w * 0.5;
61
+ const cy1 = best.bbox.y + best.bbox.h * 0.5;
62
+
63
+ const rawVx = (cx1 - cx0) / Math.max(1e-3, dtSec);
64
+ const rawVy = (cy1 - cy0) / Math.max(1e-3, dtSec);
65
+
66
+ // Alpha of 0.3 means 30% new value, 70% history
67
+ tr.vx = tr.vx * 0.7 + rawVx * 0.3;
68
+ tr.vy = tr.vy * 0.7 + rawVy * 0.3;
69
+
70
+ // smooth bbox update
71
+ tr.bbox.x = lerp(tr.bbox.x, best.bbox.x, 0.7);
72
+ tr.bbox.y = lerp(tr.bbox.y, best.bbox.y, 0.7);
73
+ tr.bbox.w = lerp(tr.bbox.w, best.bbox.w, 0.6);
74
+ tr.bbox.h = lerp(tr.bbox.h, best.bbox.h, 0.6);
75
+
76
+ // Logic: Only update label if the new detection is highly confident
77
+ // AND the current track doesn't have a "premium" label (like 'drone').
78
+ const protectedLabels = ["drone", "uav", "missile"];
79
+ const isProtected = protectedLabels.some(l => (tr.label || "").toLowerCase().includes(l));
80
+
81
+ if (!isProtected || (best.label && protectedLabels.some(l => best.label.toLowerCase().includes(l)))) {
82
+ tr.label = best.label || tr.label;
83
+ }
84
+
85
+ tr.score = best.score || tr.score;
86
+ if (Number.isFinite(best.depth_rel)) {
87
+ tr.depth_rel = best.depth_rel;
88
+ }
89
+ tr.lastSeen = now();
90
+ } else {
91
+ // Decay velocity
92
+ tr.vx *= 0.9;
93
+ tr.vy *= 0.9;
94
+ }
95
+ }
96
+
97
+ // Limit total tracks
98
+ if (tracks.length < CONFIG.MAX_TRACKS) {
99
+ for (let i = 0; i < detObjs.length; i++) {
100
+ if (used.has(i)) continue;
101
+ // create new track only if big enough
102
+ const a = detObjs[i].bbox.w * detObjs[i].bbox.h;
103
+ if (a < (w * h) * 0.0025) continue;
104
+
105
+ const newId = `T${String(state.tracker.nextId++).padStart(2, "0")}`;
106
+ const ap = defaultAimpoint(detObjs[i].label);
107
+ tracks.push({
108
+ id: newId,
109
+ label: detObjs[i].label,
110
+ bbox: { ...detObjs[i].bbox },
111
+ score: detObjs[i].score,
112
+ aimRel: { relx: ap.relx, rely: ap.rely, label: ap.label },
113
+ baseAreaFrac: (detObjs[i].bbox.w * detObjs[i].bbox.h) / (w * h),
114
+ baseRange_m: rangeBase ? +rangeBase.value : 1000,
115
+ baseDwell_s: 5.5,
116
+ reqP_kW: 42,
117
+ depth_rel: detObjs[i].depth_rel,
118
+
119
+ // GPT properties
120
+ gpt_distance_m: null,
121
+ gpt_direction: null,
122
+ gpt_description: null,
123
+
124
+ // Track state
125
+ lastSeen: now(),
126
+ vx: 0, vy: 0,
127
+ dwellAccum: 0,
128
+ killed: false,
129
+ state: "TRACK",
130
+ assessT: 0
131
+ });
132
+ log(`New track created: ${newId} (${detObjs[i].label})`, "t");
133
+ }
134
+ }
135
+
136
+ // prune old tracks
137
+ const tNow = now();
138
+ state.tracker.tracks = tracks.filter(tr => (tNow - tr.lastSeen) < CONFIG.TRACK_PRUNE_MS || tr.killed);
139
+ }
140
+
141
+ export function predictTracks(dtSec) {
142
+ if (!videoEngage) return;
143
+ const w = videoEngage.videoWidth || state.frame.w;
144
+ const h = videoEngage.videoHeight || state.frame.h;
145
+
146
+ // Simple clamp util locally or imported
147
+ const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
148
+
149
+ state.tracker.tracks.forEach(tr => {
150
+ if (tr.killed) return;
151
+ tr.bbox.x = clamp(tr.bbox.x + tr.vx * dtSec * 0.12, 0, w - 1);
152
+ tr.bbox.y = clamp(tr.bbox.y + tr.vy * dtSec * 0.12, 0, h - 1);
153
+ });
154
+ }
frontend/js/core/utils.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const $ = (sel, root = document) => root.querySelector(sel);
2
+ export const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
3
+
4
+ export const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
5
+ export const lerp = (a, b, t) => a + (b - a) * t;
6
+ export const now = () => performance.now();
7
+
8
+ export function escapeHtml(s) {
9
+ return String(s).replace(/[&<>"']/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[m]));
10
+ }
11
+
12
+ export function canvasToBlob(canvas, quality = 0.88) {
13
+ return new Promise((resolve, reject) => {
14
+ if (!canvas.toBlob) { reject(new Error("Canvas.toBlob not supported")); return; }
15
+ canvas.toBlob(blob => {
16
+ if (!blob) { reject(new Error("Canvas toBlob failed")); return; }
17
+ resolve(blob);
18
+ }, "image/jpeg", quality);
19
+ });
20
+ }
21
+
22
+ export function normBBox(bbox, w, h) {
23
+ const [x, y, bw, bh] = bbox;
24
+ return {
25
+ x: clamp(x, 0, w - 1),
26
+ y: clamp(y, 0, h - 1),
27
+ w: clamp(bw, 1, w),
28
+ h: clamp(bh, 1, h)
29
+ };
30
+ }
31
+
32
+ export const loadedScripts = new Map();
33
+
34
+ export function loadScriptOnce(key, src) {
35
+ return new Promise((resolve, reject) => {
36
+ if (loadedScripts.get(key) === "loaded") { resolve(); return; }
37
+ if (loadedScripts.get(key) === "loading") {
38
+ const iv = setInterval(() => {
39
+ if (loadedScripts.get(key) === "loaded") { clearInterval(iv); resolve(); }
40
+ if (loadedScripts.get(key) === "failed") { clearInterval(iv); reject(new Error("Script failed earlier")); }
41
+ }, 50);
42
+ return;
43
+ }
44
+
45
+ loadedScripts.set(key, "loading");
46
+ const s = document.createElement("script");
47
+ s.src = src;
48
+ s.async = true;
49
+ s.onload = () => { loadedScripts.set(key, "loaded"); resolve(); };
50
+ s.onerror = () => { loadedScripts.set(key, "failed"); reject(new Error(`Failed to load ${src}`)); };
51
+ document.head.appendChild(s);
52
+ });
53
+ }
frontend/js/main.js ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from './core/state.js';
2
+ import { CONFIG } from './core/config.js';
3
+ import { $, now } from './core/utils.js';
4
+ import { getKnobs } from './core/physics.js';
5
+ import { matchAndUpdateTracks, predictTracks } from './core/tracker.js';
6
+ import { hfDetectAsync, checkJobStatus, cancelBackendJob } from './api/client.js';
7
+
8
+ // UI Renderers
9
+ import { renderFrameRadar } from './ui/radar.js';
10
+ import { renderFrameOverlay } from './ui/overlays.js';
11
+ import { renderFrameTrackList } from './ui/cards.js';
12
+ import { renderFeatures } from './ui/features.js';
13
+ import { updateHeadlines } from './ui/trade.js';
14
+ import { log, setStatus, setHfStatus } from './ui/logging.js';
15
+
16
+ // DOM Elements
17
+ const videoEngage = $("#videoEngage");
18
+ const btnUpload = $("#btn-upload");
19
+ const fileInput = $("#file-input");
20
+ const btnReason = $("#btn-reason");
21
+ const btnCancelReason = $("#btnCancelReason");
22
+ const btnEngage = $("#btn-engage");
23
+ const btnReset = $("#btn-reset");
24
+ const btnPause = $("#btn-pause");
25
+ // const btnClear = $("#btn-clear");
26
+ const detectorSelect = $("#detector-select");
27
+ const missionText = $("#mission-text");
28
+
29
+ // Initialization
30
+ function init() {
31
+ log("System initializing...", "t");
32
+
33
+ // Bind Events
34
+ setupFileUpload();
35
+ setupControls();
36
+
37
+ // Start Loop
38
+ requestAnimationFrame(loop);
39
+
40
+ log("System READY.", "g");
41
+ }
42
+
43
+ function setupFileUpload() {
44
+ btnUpload.addEventListener("click", () => fileInput.click());
45
+ fileInput.addEventListener("change", (e) => {
46
+ const file = e.target.files[0];
47
+ if (!file) return;
48
+
49
+ state.videoFile = file;
50
+ state.videoUrl = URL.createObjectURL(file);
51
+ state.videoLoaded = true;
52
+
53
+ // Reset state
54
+ videoEngage.src = state.videoUrl;
55
+ videoEngage.load();
56
+
57
+ setStatus("warn", "READY · Video loaded (run Reason)");
58
+ log(`Video loaded: ${file.name}`, "g");
59
+ });
60
+ }
61
+
62
+ function setupControls() {
63
+ btnReason.addEventListener("click", async () => {
64
+ if (!state.videoLoaded) {
65
+ log("No video loaded.", "w");
66
+ return;
67
+ }
68
+ if (state.isReasoning) {
69
+ log("Reasoning in progress...", "w");
70
+ return;
71
+ }
72
+
73
+ state.isReasoning = true;
74
+ btnReason.disabled = true;
75
+ btnCancelReason.style.display = "inline-block";
76
+ setStatus("warn", "REASONING · Running perception pipeline");
77
+
78
+ try {
79
+ // 1. Prepare Request
80
+ const mode = detectorSelect.value; // e.g., "coco", "hf_yolov8"
81
+ const queries = missionText.value.trim();
82
+
83
+ const form = new FormData();
84
+ form.append("video", state.videoFile);
85
+ form.append("mode", "object_detection"); // Simplified for now
86
+ if (queries) form.append("queries", queries);
87
+
88
+ // Map detector selection to backend param
89
+ if (["hf_yolov8", "detr_resnet50", "grounding_dino", "drone_yolo"].includes(mode)) {
90
+ form.append("detector", mode);
91
+ }
92
+
93
+ form.append("enable_gpt", "true"); // Always on
94
+
95
+ // 2. Submit Async
96
+ log(`Submitting job to ${state.hf.baseUrl}...`, "t");
97
+ const data = await hfDetectAsync(form);
98
+
99
+ state.hf.asyncJobId = data.job_id;
100
+ state.hf.videoUrl = `${state.hf.baseUrl}${data.video_url}`;
101
+ state.hf.firstFrameUrl = `${state.hf.baseUrl}${data.first_frame_url}`;
102
+
103
+ if (data.first_frame_detections) {
104
+ // Populate Tab 1 Detections
105
+ state.detections = data.first_frame_detections.map((d, i) => ({
106
+ id: `T${String(i + 1).padStart(2, '0')}`,
107
+ label: d.label,
108
+ score: d.score,
109
+ bbox: d.bbox ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] } : { x: 0, y: 0, w: 100, h: 100 },
110
+ // Mapping backend fields
111
+ gpt_distance_m: d.gpt_distance_m,
112
+ gpt_description: d.gpt_description,
113
+ // Mock physics fields for now
114
+ pkill: Math.random(),
115
+ features: {}
116
+ }));
117
+
118
+ state.hasReasoned = true;
119
+ log(`Reason complete. ${state.detections.length} objects found.`, "g");
120
+ setStatus("good", "READY · Reason complete");
121
+
122
+ // Force UI Update
123
+ renderFrameTrackList();
124
+ renderFeatures(state.detections[0]);
125
+ renderFrameRadar();
126
+ renderFrameOverlay(); // Draws first frame boxes
127
+ }
128
+
129
+ // Start Polling for Video
130
+ pollJob(data.job_id);
131
+
132
+ } catch (err) {
133
+ log(`Reason failed: ${err.message}`, "e");
134
+ setStatus("bad", "ERROR · Reason failed");
135
+ } finally {
136
+ state.isReasoning = false;
137
+ btnReason.disabled = false;
138
+ btnCancelReason.style.display = "none";
139
+ }
140
+ });
141
+
142
+ btnCancelReason.addEventListener("click", () => {
143
+ if (state.hf.asyncJobId) cancelBackendJob(state.hf.asyncJobId);
144
+ });
145
+
146
+ // ENGAGE Controls
147
+ btnEngage.addEventListener("click", () => {
148
+ if (!state.hasReasoned || !state.hf.processedUrl) {
149
+ // If processed video isn't ready, we play original?
150
+ // Or wait.
151
+ if (state.hf.asyncStatus !== "completed") {
152
+ log("Processing not complete yet.", "w");
153
+ return;
154
+ }
155
+ }
156
+
157
+ videoEngage.src = state.hf.processedUrl || state.videoUrl;
158
+ videoEngage.play();
159
+ state.tracker.running = true;
160
+
161
+ // Seed Tracker
162
+ // We use the simpler method: direct map from detections
163
+ state.tracker.tracks = state.detections.map(d => ({
164
+ ...d,
165
+ vx: 0, vy: 0,
166
+ lastSeen: now(),
167
+ state: "TRACK"
168
+ }));
169
+
170
+ log("Engage sequences started.", "g");
171
+ });
172
+
173
+ btnPause.addEventListener("click", () => {
174
+ videoEngage.pause();
175
+ state.tracker.running = false;
176
+ });
177
+
178
+ btnReset.addEventListener("click", () => {
179
+ videoEngage.pause();
180
+ videoEngage.currentTime = 0;
181
+ state.tracker.tracks = []; // clear
182
+ state.tracker.running = false;
183
+ });
184
+
185
+ // Card Selection Event
186
+ document.addEventListener("track-selected", (e) => {
187
+ state.selectedId = e.detail.id;
188
+ renderFrameTrackList(); // update active class
189
+ renderFrameOverlay(); // update highlight
190
+ const det = state.detections.find(d => d.id === state.selectedId);
191
+ renderFeatures(det);
192
+ });
193
+ }
194
+
195
+ function pollJob(jobId) {
196
+ const iv = setInterval(async () => {
197
+ if (state.hf.asyncStatus === "completed" || state.hf.asyncStatus === "failed") {
198
+ clearInterval(iv);
199
+ return;
200
+ }
201
+
202
+ try {
203
+ const status = await checkJobStatus(jobId);
204
+ state.hf.asyncStatus = status.status;
205
+ setHfStatus(`Job ${jobId.slice(0, 8)}: ${status.status}`);
206
+
207
+ if (status.status === "completed") {
208
+ state.hf.processedUrl = `${state.hf.baseUrl}${status.video_url}`;
209
+ log("Processing complete. Video ready.", "g");
210
+ clearInterval(iv);
211
+ }
212
+ } catch (e) {
213
+ clearInterval(iv);
214
+ }
215
+ }, 2000);
216
+ }
217
+
218
+ // Main Animation Loop
219
+ function loop() {
220
+ const t = now();
221
+ const dt = (t - state.tracker.lastFrameTime) / 1000;
222
+ state.tracker.lastFrameTime = t;
223
+
224
+ if (state.tracker.running && !videoEngage.paused) {
225
+ // 1. Predict (Coast)
226
+ predictTracks(dt);
227
+
228
+ // 2. Measure (Update from Detection)
229
+ // Since we removed local COCO fallback, what drives updates?
230
+ // OPTION A: We rely on server stream (not implemented fully in this refactor).
231
+ // OPTION B: We rely purely on "coasting" (dead reckoning) if no new data comes in.
232
+ // For this demo refactor, we just predict.
233
+
234
+ // 3. Prune
235
+ // matchAndUpdateTracks([], dt); // Empty detections just triggers pruning/decay
236
+ }
237
+
238
+ // Render
239
+ renderFrameRadar();
240
+ renderFrameOverlay();
241
+ // Cards update less frequently? Or every frame for position?
242
+ // We update overlays every frame. Cards usually static unless sorted.
243
+
244
+ requestAnimationFrame(loop);
245
+ }
246
+
247
+ // Start
248
+ init();
frontend/js/ui/cards.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from '../core/state.js';
2
+ import { $ } from '../core/utils.js';
3
+
4
+ const frameTrackList = $("#frameTrackList");
5
+ const trackCount = $("#track-count");
6
+
7
+ export function renderFrameTrackList() {
8
+ if (!frameTrackList || !trackCount) return;
9
+ frameTrackList.innerHTML = "";
10
+
11
+ const dets = state.detections || [];
12
+ trackCount.textContent = dets.length;
13
+
14
+ if (dets.length === 0) {
15
+ frameTrackList.innerHTML = '<div style="font-style:italic; color:var(--text-dim); text-align:center; margin-top:20px;">No objects tracked.</div>';
16
+ return;
17
+ }
18
+
19
+ dets.forEach((det, i) => {
20
+ const id = det.id || `T${String(i + 1).padStart(2, '0')}`;
21
+
22
+ let rangeStr = "---";
23
+ let bearingStr = "---";
24
+
25
+ if (det.gpt_distance_m) {
26
+ rangeStr = `${det.gpt_distance_m}m (GPT)`;
27
+ }
28
+
29
+ if (det.gpt_direction) {
30
+ bearingStr = det.gpt_direction;
31
+ }
32
+
33
+ const card = document.createElement("div");
34
+ card.className = "track-card";
35
+ if (state.selectedId === id) card.classList.add("active");
36
+ card.id = `card-${id}`;
37
+
38
+ // Circular dependency risk if we import selectObject from main?
39
+ // We'll dispatch a custom event instead.
40
+ card.onclick = () => {
41
+ const ev = new CustomEvent("track-selected", { detail: { id } });
42
+ document.dispatchEvent(ev);
43
+ };
44
+
45
+ const desc = det.gpt_description
46
+ ? `<div class="track-card-body"><span class="gpt-text">${det.gpt_description}</span></div>`
47
+ : "";
48
+
49
+ card.innerHTML = `
50
+ <div class="track-card-header">
51
+ <span>${id} · ${det.label}</span>
52
+ <span class="badgemini">${(det.score * 100).toFixed(0)}%</span>
53
+ </div>
54
+ <div class="track-card-meta">
55
+ RANGE: ${rangeStr} | BEARING: ${bearingStr}
56
+ </div>
57
+ ${desc}
58
+ `;
59
+ frameTrackList.appendChild(card);
60
+ });
61
+ }
frontend/js/ui/features.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $ } from '../core/utils.js';
2
+
3
+ const featureTable = $("#featureTable");
4
+ const selId = $("#sel-id");
5
+
6
+ export function renderFeatures(det) {
7
+ if (!featureTable || !selId) return;
8
+
9
+ selId.textContent = det ? det.id : "—";
10
+ const tbody = featureTable.querySelector("tbody");
11
+ tbody.innerHTML = "";
12
+
13
+ if (!det) {
14
+ tbody.innerHTML = `<tr><td class="k">—</td><td class="mini">No target selected</td></tr>`;
15
+ return;
16
+ }
17
+
18
+ const feats = det.features || {};
19
+ const keys = Object.keys(feats);
20
+ const show = keys.slice(0, 12);
21
+
22
+ show.forEach(k => {
23
+ const tr = document.createElement("tr");
24
+ tr.innerHTML = `<td class="k">${k}</td><td>${String(feats[k])}</td>`;
25
+ tbody.appendChild(tr);
26
+ });
27
+
28
+ if (show.length < 10) {
29
+ for (let i = show.length; i < 10; i++) {
30
+ const tr = document.createElement("tr");
31
+ tr.innerHTML = `<td class="k">—</td><td class="mini">awaiting additional expert outputs</td>`;
32
+ tbody.appendChild(tr);
33
+ }
34
+ }
35
+ }
frontend/js/ui/logging.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { $ } from '../core/utils.js';
2
+
3
+ const sysDot = $("#sys-dot");
4
+ const sysStatus = $("#sys-status");
5
+ const consoleHook = $("#console-hook");
6
+
7
+ export function log(msg, type = "i") {
8
+ if (!consoleHook) return;
9
+ const div = document.createElement("div");
10
+ div.className = "log-line";
11
+ const ts = new Date().toISOString().split("T")[1].slice(0, 8);
12
+ let color = "#94a3b8"; // dim
13
+ if (type === "g") color = "var(--good)";
14
+ if (type === "w") color = "var(--warn)";
15
+ if (type === "e") color = "var(--bad)";
16
+ if (type === "t") color = "var(--theme)";
17
+
18
+ div.innerHTML = `<span class="ts">[${ts}]</span> <span style="color:${color}">${msg}</span>`;
19
+ consoleHook.appendChild(div);
20
+ consoleHook.scrollTop = consoleHook.scrollHeight;
21
+ }
22
+
23
+ export function setStatus(level, text) {
24
+ if (!sysDot || !sysStatus) return;
25
+ let color = "var(--text-dim)";
26
+ if (level === "good") color = "var(--good)";
27
+ if (level === "warn") color = "var(--warn)";
28
+ if (level === "bad") color = "var(--bad)";
29
+
30
+ sysDot.style.background = color;
31
+ sysDot.style.boxShadow = `0 0 10px ${color}`;
32
+ sysStatus.textContent = text;
33
+ sysStatus.style.color = color === "var(--text-dim)" ? "var(--text-main)" : color;
34
+ }
35
+
36
+ export function setHfStatus(msg) {
37
+ // Optional status line updates for HF backend events
38
+ if (msg.startsWith("error")) {
39
+ // console.error(msg); // Reduced noise
40
+ } else {
41
+ // console.log(`[HF] ${msg}`);
42
+ }
43
+ }
frontend/js/ui/overlays.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from '../core/state.js';
2
+ import { now, $ } from '../core/utils.js';
3
+ import { defaultAimpoint, aimpointByLabel } from '../core/physics.js';
4
+ import { log } from './logging.js';
5
+
6
+ const frameOverlay = $("#frameOverlay");
7
+
8
+ // Helper to draw rounded rect
9
+ function roundRect(ctx, x, y, w, h, r, fill, stroke) {
10
+ if (w < 2 * r) r = w / 2;
11
+ if (h < 2 * r) r = h / 2;
12
+ ctx.beginPath();
13
+ ctx.moveTo(x + r, y);
14
+ ctx.arcTo(x + w, y, x + w, y + h, r);
15
+ ctx.arcTo(x + w, y + h, x, y + h, r);
16
+ ctx.arcTo(x, y + h, x, y, r);
17
+ ctx.arcTo(x, y, x + w, y, r);
18
+ ctx.closePath();
19
+ if (fill) ctx.fill();
20
+ if (stroke) ctx.stroke();
21
+ }
22
+
23
+ function drawAimpoint(ctx, x, y, isSel) {
24
+ ctx.save();
25
+ ctx.shadowBlur = isSel ? 18 : 12;
26
+ ctx.shadowColor = "rgba(239,68,68,.45)";
27
+ ctx.strokeStyle = "rgba(239,68,68,.95)";
28
+ ctx.lineWidth = isSel ? 3 : 2;
29
+ ctx.beginPath();
30
+ ctx.arc(x, y, isSel ? 10 : 9, 0, Math.PI * 2);
31
+ ctx.stroke();
32
+
33
+ ctx.shadowBlur = 0;
34
+ ctx.strokeStyle = "rgba(255,255,255,.70)";
35
+ ctx.lineWidth = 1.5;
36
+ ctx.beginPath();
37
+ ctx.moveTo(x - 14, y); ctx.lineTo(x - 4, y);
38
+ ctx.moveTo(x + 4, y); ctx.lineTo(x + 14, y);
39
+ ctx.moveTo(x, y - 14); ctx.lineTo(x, y - 4);
40
+ ctx.moveTo(x, y + 4); ctx.lineTo(x, y + 14);
41
+ ctx.stroke();
42
+
43
+ ctx.fillStyle = "rgba(239,68,68,.95)";
44
+ ctx.beginPath();
45
+ ctx.arc(x, y, 2.5, 0, Math.PI * 2);
46
+ ctx.fill();
47
+ ctx.restore();
48
+ }
49
+
50
+ export function renderFrameOverlay() {
51
+ if (!frameOverlay) return;
52
+ const ctx = frameOverlay.getContext("2d");
53
+ const w = frameOverlay.width, h = frameOverlay.height;
54
+ ctx.clearRect(0, 0, w, h);
55
+
56
+ if (!state.detections.length) return;
57
+
58
+ // subtle scanning effect
59
+ const t = now() / 1000;
60
+ const scanX = (Math.sin(t * 0.65) * 0.5 + 0.5) * w;
61
+ ctx.fillStyle = "rgba(34,211,238,.06)";
62
+ ctx.fillRect(scanX - 8, 0, 16, h);
63
+
64
+ state.detections.forEach((d) => {
65
+ const isSel = d.id === state.selectedId;
66
+ const b = d.bbox;
67
+
68
+ // box
69
+ ctx.lineWidth = isSel ? 3 : 2;
70
+ // Simple heuristic for focus color
71
+ const label = (d.label || "").toLowerCase();
72
+ const isFocus = label.includes("drone") || label.includes("uav");
73
+
74
+ ctx.strokeStyle = isSel ? "rgba(34,211,238,.95)" : (isFocus ? "rgba(34,211,238,.70)" : "rgba(124,58,237,.55)");
75
+ ctx.shadowColor = isSel ? "rgba(34,211,238,.40)" : "rgba(124,58,237,.25)";
76
+ ctx.shadowBlur = isSel ? 18 : 10;
77
+ roundRect(ctx, b.x, b.y, b.w, b.h, 10, false, true);
78
+
79
+ // pseudo mask glow
80
+ ctx.shadowBlur = 0;
81
+ const g = ctx.createRadialGradient(b.x + b.w * 0.5, b.y + b.h * 0.5, 10, b.x + b.w * 0.5, b.y + b.h * 0.5, Math.max(b.w, b.h) * 0.75);
82
+ g.addColorStop(0, isSel ? "rgba(34,211,238,.16)" : "rgba(124,58,237,.10)");
83
+ g.addColorStop(1, "rgba(0,0,0,0)");
84
+ ctx.fillStyle = g;
85
+ ctx.fillRect(b.x, b.y, b.w, b.h);
86
+
87
+ // aimpoint marker
88
+ // If aim not set, calculate default
89
+ const aim = d.aim || defaultAimpoint(d.label);
90
+ const ax = b.x + b.w * aim.relx;
91
+ const ay = b.y + b.h * aim.rely;
92
+ drawAimpoint(ctx, ax, ay, isSel);
93
+ });
94
+
95
+ // Pointer events moved to controls or handled here?
96
+ // Usually simple to keep it here or init logic in main.
97
+ }
98
+
99
+ // Ensure interactions are bound in main setup, not here to keep this pure renderer
frontend/js/ui/radar.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from '../core/state.js';
2
+ import { clamp, now, $ } from '../core/utils.js';
3
+
4
+ const frameRadar = $("#frameRadar");
5
+
6
+ export function renderFrameRadar() {
7
+ if (!frameRadar) return;
8
+ const ctx = frameRadar.getContext("2d");
9
+ const rect = frameRadar.getBoundingClientRect();
10
+ const dpr = devicePixelRatio || 1;
11
+
12
+ // Resize if needed
13
+ const targetW = Math.max(1, Math.floor(rect.width * dpr));
14
+ const targetH = Math.max(1, Math.floor(rect.height * dpr));
15
+ if (frameRadar.width !== targetW || frameRadar.height !== targetH) {
16
+ frameRadar.width = targetW;
17
+ frameRadar.height = targetH;
18
+ }
19
+
20
+ const w = frameRadar.width, h = frameRadar.height;
21
+ const cx = w * 0.5, cy = h * 0.5;
22
+ const R = Math.min(w, h) * 0.45; // Max radius
23
+
24
+ ctx.clearRect(0, 0, w, h);
25
+
26
+ // --- 1. Background (Tactical Grid) ---
27
+ ctx.fillStyle = "#0a0f22"; // Matches --panel2
28
+ ctx.fillRect(0, 0, w, h);
29
+
30
+ // Grid Rings (Concentric)
31
+ ctx.strokeStyle = "rgba(34, 211, 238, 0.1)"; // Cyan faint
32
+ ctx.lineWidth = 1;
33
+ for (let i = 1; i <= 4; i++) {
34
+ ctx.beginPath();
35
+ ctx.arc(cx, cy, R * (i / 4), 0, Math.PI * 2);
36
+ ctx.stroke();
37
+ }
38
+
39
+ // Grid Spokes (Cardinals)
40
+ ctx.beginPath();
41
+ ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);
42
+ ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);
43
+ ctx.stroke();
44
+
45
+ // --- 2. Sweep Animation ---
46
+ const t = now() / 1500; // Slower, more deliberate sweep
47
+ const ang = (t * (Math.PI * 2)) % (Math.PI * 2);
48
+
49
+ const grad = ctx.createConicGradient(ang + Math.PI / 2, cx, cy); // Offset to start at 0
50
+ grad.addColorStop(0, "transparent");
51
+ grad.addColorStop(0.1, "transparent");
52
+ grad.addColorStop(0.8, "rgba(34, 211, 238, 0.0)");
53
+ grad.addColorStop(1, "rgba(34, 211, 238, 0.15)"); // Trailing edge
54
+
55
+ ctx.fillStyle = grad;
56
+ ctx.beginPath();
57
+ ctx.arc(cx, cy, R, 0, Math.PI * 2);
58
+ ctx.fill();
59
+
60
+ // Scan Line
61
+ ctx.strokeStyle = "rgba(34, 211, 238, 0.6)";
62
+ ctx.lineWidth = 1.5;
63
+ ctx.beginPath();
64
+ ctx.moveTo(cx, cy);
65
+ ctx.lineTo(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R);
66
+ ctx.stroke();
67
+
68
+ // --- 3. Ownship (Center) ---
69
+ ctx.fillStyle = "#22d3ee"; // Cyan
70
+ ctx.beginPath();
71
+ ctx.arc(cx, cy, 3, 0, Math.PI * 2);
72
+ ctx.fill();
73
+ // Ring around ownship
74
+ ctx.strokeStyle = "rgba(34, 211, 238, 0.5)";
75
+ ctx.lineWidth = 1;
76
+ ctx.beginPath();
77
+ ctx.arc(cx, cy, 6, 0, Math.PI * 2);
78
+ ctx.stroke();
79
+
80
+ // --- 4. Render Detections ---
81
+ const source = state.tracker.running ? state.tracker.tracks : state.detections;
82
+
83
+ if (source) {
84
+ source.forEach(det => {
85
+ // Determine Range (pixels)
86
+ let dist = 3000; // default unknown
87
+
88
+ if (det.gpt_distance_m) {
89
+ dist = det.gpt_distance_m;
90
+ }
91
+
92
+ // Linear scale: 0m -> 0px, 1500m -> R
93
+ const maxRangeM = 1500;
94
+ const rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R;
95
+
96
+ // Determine Bearing
97
+ // box center relative to frame center
98
+ const bx = det.bbox.x + det.bbox.w * 0.5;
99
+ const fw = state.frame.w || 1280; // normalized coords usually, but here bbox seems to be absolute pixel or normalized depending on source?
100
+ // NOTE: In state.detections, bbox is normalized (0..1) if coming from normBBox?
101
+ // Wait, state.detections has bbox in pixel coords?
102
+ // In original script: state.detections = dets.map ... bbox: normBBox(d.bbox, w, h) which clamps but returns pixel coords?
103
+ // Let's check normBBox implementation in utils.
104
+ // It clamps x to 0..w-1. So it IS pixel coords.
105
+
106
+ // Normalized x (-0.5 to 0.5)
107
+ const tx = (bx / fw) - 0.5;
108
+
109
+ // Map x-axis (-0.5 to 0.5) to angle.
110
+ // FOV assumption: ~60 degrees?
111
+ const fovRad = (60 * Math.PI) / 180;
112
+ const angle = (-Math.PI / 2) + (tx * fovRad);
113
+
114
+ // --- Draw Blip ---
115
+ const px = cx + Math.cos(angle) * rPx;
116
+ const py = cy + Math.sin(angle) * rPx;
117
+
118
+ // Check selection (handle both track ID and detection ID)
119
+ const isSelected = (state.selectedId === det.id) || (state.tracker.selectedTrackId === det.id);
120
+
121
+ // Glow for selected
122
+ if (isSelected) {
123
+ ctx.shadowBlur = 10;
124
+ ctx.shadowColor = "#f59e0b"; // Amber glow
125
+ } else {
126
+ ctx.shadowBlur = 0;
127
+ }
128
+
129
+ // Blip Color
130
+ let col = "#7c3aed"; // Default violet
131
+ const label = (det.label || "").toLowerCase();
132
+ if (label.includes('person')) col = "#ef4444"; // Red
133
+ if (label.includes('airplane') || label.includes('drone')) col = "#f59e0b"; // Amber
134
+ if (isSelected) col = "#ffffff"; // White for selected
135
+
136
+ ctx.fillStyle = col;
137
+ ctx.beginPath();
138
+ ctx.arc(px, py, isSelected ? 5 : 3.5, 0, Math.PI * 2);
139
+ ctx.fill();
140
+
141
+ // Selected UI overlays
142
+ if (isSelected) {
143
+ ctx.fillStyle = "#fff";
144
+ ctx.font = "bold 11px monospace";
145
+ ctx.fillText(det.id, px + 8, py + 3);
146
+
147
+ // Connected Line to center
148
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.4)";
149
+ ctx.lineWidth = 1;
150
+ ctx.setLineDash([2, 2]);
151
+ ctx.beginPath();
152
+ ctx.moveTo(cx, cy);
153
+ ctx.lineTo(px, py);
154
+ ctx.stroke();
155
+ ctx.setLineDash([]);
156
+
157
+ // Distance Label on Line
158
+ const mx = (cx + px) * 0.5;
159
+ const my = (cy + py) * 0.5;
160
+ const distStr = `${Math.round(dist)}m`;
161
+
162
+ ctx.font = "10px monospace";
163
+ const tm = ctx.measureText(distStr);
164
+ const tw = tm.width;
165
+ const th = 10;
166
+
167
+ // Label Background
168
+ ctx.fillStyle = "rgba(10, 15, 34, 0.85)";
169
+ ctx.fillRect(mx - tw / 2 - 3, my - th / 2 - 2, tw + 6, th + 4);
170
+
171
+ // Label Text
172
+ ctx.fillStyle = "#22d3ee"; // Cyan
173
+ ctx.textAlign = "center";
174
+ ctx.textBaseline = "middle";
175
+ ctx.fillText(distStr, mx, my);
176
+
177
+ // Reset text alignment
178
+ ctx.textAlign = "start";
179
+ ctx.textBaseline = "alphabetic";
180
+ }
181
+ });
182
+ }
183
+ }
frontend/js/ui/trade.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { state } from '../core/state.js';
2
+ import { $ } from '../core/utils.js';
3
+
4
+ const mPlan = $("#m-plan");
5
+ const mPlanSub = $("#m-plan-sub");
6
+ const mMaxP = $("#m-maxp");
7
+ const mMaxPSub = $("#m-maxp-sub");
8
+ const mReqP = $("#m-reqp");
9
+ const mMargin = $("#m-margin");
10
+
11
+ export function renderTrade() {
12
+ // This function updates the top summary metrics based on the current state
13
+ if (!mPlan) return; // UI not ready
14
+
15
+ // Logic mostly handled in recomputeHEL, this is just refresh
16
+ // We can move the specific DOM updates here if we want strict separation
17
+ // For now, let's assume recomputeHEL drives state and this just reflects it?
18
+ // Actually, recomputeHEL in the original code did the DOM updates directly.
19
+ // Let's keep it simple: Trade rendering is minimal.
20
+ }
21
+
22
+ export function updateHeadlines(sys, bestTarget) {
23
+ if (!mMaxP) return;
24
+
25
+ mMaxP.textContent = sys.maxP ? `${sys.maxP} kW` : "—";
26
+ mReqP.textContent = sys.reqP ? `${sys.reqP} kW` : "—";
27
+ const margin = sys.margin || 0;
28
+ mMargin.textContent = `${margin > 0 ? "+" : ""}${margin} kW`;
29
+ mMargin.style.color = margin >= 0 ? "rgba(34,197,94,.95)" : "rgba(239,68,68,.95)";
30
+ mMaxPSub.textContent = "Calculated by external HEL engine";
31
+
32
+ if (bestTarget && bestTarget.pkill > 0) {
33
+ mPlan.textContent = `${bestTarget.id} → Engage`;
34
+ mPlanSub.textContent = "Highest P(kill) target";
35
+ } else {
36
+ mPlan.textContent = "—";
37
+ mPlanSub.textContent = "No viable targets";
38
+ }
39
+ }
frontend/style.css ADDED
@@ -0,0 +1,927 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 540px 1fr;
78
+ /* Fixed sidebar width */
79
+ gap: 12px;
80
+ padding: 12px;
81
+ min-height: 0;
82
+ }
83
+
84
+ aside,
85
+ main {
86
+ background: rgba(255, 255, 255, .02);
87
+ border: 1px solid var(--stroke);
88
+ border-radius: 16px;
89
+ box-shadow: var(--shadow);
90
+ overflow: hidden;
91
+ display: flex;
92
+ flex-direction: column;
93
+ min-height: 0;
94
+ }
95
+
96
+ footer {
97
+ padding: 10px 14px;
98
+ border-top: 1px solid var(--stroke);
99
+ color: var(--muted);
100
+ font-size: 11px;
101
+ display: flex;
102
+ justify-content: space-between;
103
+ align-items: center;
104
+ gap: 10px;
105
+ background: linear-gradient(0deg, rgba(255, 255, 255, .03), transparent);
106
+ }
107
+
108
+ footer .mono {
109
+ font-family: var(--mono);
110
+ color: rgba(255, 255, 255, .76);
111
+ }
112
+
113
+ /* =========================================
114
+ Brand & Status
115
+ ========================================= */
116
+
117
+ .brand {
118
+ display: flex;
119
+ gap: 12px;
120
+ align-items: center;
121
+ min-width: 420px;
122
+ }
123
+
124
+ .logo {
125
+ width: 40px;
126
+ height: 40px;
127
+ border-radius: 14px;
128
+ background:
129
+ radial-gradient(circle at 30% 30%, rgba(34, 211, 238, .9), rgba(124, 58, 237, .9) 55%, rgba(0, 0, 0, .1) 70%),
130
+ linear-gradient(135deg, rgba(255, 255, 255, .10), transparent 60%);
131
+ box-shadow: 0 16px 46px rgba(124, 58, 237, .25);
132
+ border: 1px solid rgba(255, 255, 255, .16);
133
+ position: relative;
134
+ overflow: hidden;
135
+ }
136
+
137
+ .logo:after {
138
+ content: "";
139
+ position: absolute;
140
+ inset: -40px;
141
+ background: conic-gradient(from 180deg, transparent, rgba(255, 255, 255, .10), transparent);
142
+ animation: spin 10s linear infinite;
143
+ }
144
+
145
+ @keyframes spin {
146
+ to {
147
+ transform: rotate(360deg);
148
+ }
149
+ }
150
+
151
+ .brand h1 {
152
+ font-size: 14px;
153
+ margin: 0;
154
+ letter-spacing: .16em;
155
+ text-transform: uppercase;
156
+ }
157
+
158
+ .brand .sub {
159
+ font-size: 12px;
160
+ color: var(--muted);
161
+ margin-top: 2px;
162
+ line-height: 1.2;
163
+ }
164
+
165
+ .status-row {
166
+ display: flex;
167
+ gap: 10px;
168
+ align-items: center;
169
+ flex-wrap: wrap;
170
+ justify-content: flex-end;
171
+ }
172
+
173
+ /* =========================================
174
+ Components: Cards & Panels
175
+ ========================================= */
176
+
177
+ .card {
178
+ padding: 12px 12px 10px;
179
+ border-bottom: 1px solid var(--stroke);
180
+ position: relative;
181
+ }
182
+
183
+ .card:last-child {
184
+ border-bottom: none;
185
+ }
186
+
187
+ .card h2 {
188
+ margin: 0;
189
+ font-size: 12px;
190
+ letter-spacing: .14em;
191
+ text-transform: uppercase;
192
+ color: rgba(255, 255, 255, .78);
193
+ }
194
+
195
+ .card small {
196
+ color: var(--muted);
197
+ }
198
+
199
+ .card .hint {
200
+ color: var(--faint);
201
+ font-size: 11px;
202
+ line-height: 1.35;
203
+ margin-top: 6px;
204
+ }
205
+
206
+ .panel {
207
+ background: linear-gradient(180deg, rgba(255, 255, 255, .03), rgba(255, 255, 255, .015));
208
+ border: 1px solid var(--stroke);
209
+ border-radius: 16px;
210
+ padding: 10px;
211
+ box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
212
+ overflow: hidden;
213
+ position: relative;
214
+ }
215
+
216
+ .panel h3 {
217
+ margin: 0 0 8px;
218
+ font-size: 12px;
219
+ letter-spacing: .14em;
220
+ text-transform: uppercase;
221
+ color: rgba(255, 255, 255, .78);
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: space-between;
225
+ gap: 8px;
226
+ }
227
+
228
+ .panel h3 .rightnote {
229
+ font-size: 11px;
230
+ color: var(--muted);
231
+ font-family: var(--mono);
232
+ letter-spacing: 0;
233
+ text-transform: none;
234
+ }
235
+
236
+ .collapse-btn {
237
+ background: rgba(255, 255, 255, .05);
238
+ border: 1px solid rgba(255, 255, 255, .12);
239
+ border-radius: 8px;
240
+ padding: 4px 8px;
241
+ color: var(--muted);
242
+ cursor: pointer;
243
+ font-size: 11px;
244
+ font-family: var(--mono);
245
+ transition: all 0.2s ease;
246
+ text-transform: none;
247
+ letter-spacing: 0;
248
+ }
249
+
250
+ .collapse-btn:hover {
251
+ background: rgba(255, 255, 255, .08);
252
+ color: var(--text);
253
+ border-color: rgba(255, 255, 255, .18);
254
+ }
255
+
256
+ /* =========================================
257
+ Components: Inputs & Controls
258
+ ========================================= */
259
+
260
+ .grid2 {
261
+ display: grid;
262
+ grid-template-columns: 1fr 1fr;
263
+ gap: 8px;
264
+ margin-top: 10px;
265
+ }
266
+
267
+ .row {
268
+ display: flex;
269
+ gap: 8px;
270
+ align-items: center;
271
+ justify-content: space-between;
272
+ margin-top: 8px;
273
+ }
274
+
275
+ label {
276
+ font-size: 11px;
277
+ color: var(--muted);
278
+ }
279
+
280
+ input[type="range"] {
281
+ width: 100%;
282
+ }
283
+
284
+ select,
285
+ textarea,
286
+ input[type="text"],
287
+ input[type="number"] {
288
+ width: 100%;
289
+ background: rgba(255, 255, 255, .04);
290
+ border: 1px solid var(--stroke2);
291
+ border-radius: 10px;
292
+ padding: 8px 10px;
293
+ color: var(--text);
294
+ outline: none;
295
+ font-size: 12px;
296
+ }
297
+
298
+ select:focus,
299
+ textarea:focus,
300
+ input[type="text"]:focus,
301
+ input[type="number"]:focus {
302
+ border-color: rgba(124, 58, 237, .55);
303
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, .16);
304
+ }
305
+
306
+ .btn {
307
+ user-select: none;
308
+ cursor: pointer;
309
+ border: none;
310
+ border-radius: 12px;
311
+ padding: 10px 12px;
312
+ font-weight: 700;
313
+ font-size: 12px;
314
+ letter-spacing: .04em;
315
+ color: rgba(255, 255, 255, .92);
316
+ background: linear-gradient(135deg, rgba(124, 58, 237, .95), rgba(34, 211, 238, .45));
317
+ box-shadow: 0 18px 40px rgba(124, 58, 237, .24);
318
+ }
319
+
320
+ .btn:hover {
321
+ filter: brightness(1.06);
322
+ }
323
+
324
+ .btn:active {
325
+ transform: translateY(1px);
326
+ }
327
+
328
+ .btn.secondary {
329
+ background: rgba(255, 255, 255, .06);
330
+ border: 1px solid var(--stroke2);
331
+ box-shadow: none;
332
+ font-weight: 600;
333
+ }
334
+
335
+ .btn.secondary:hover {
336
+ background: rgba(255, 255, 255, .08);
337
+ }
338
+
339
+ .btn.danger {
340
+ background: linear-gradient(135deg, rgba(239, 68, 68, .95), rgba(251, 113, 133, .55));
341
+ box-shadow: 0 18px 40px rgba(239, 68, 68, .18);
342
+ }
343
+
344
+ .btnrow {
345
+ display: flex;
346
+ gap: 8px;
347
+ margin-top: 10px;
348
+ }
349
+
350
+ .btnrow .btn {
351
+ flex: 1;
352
+ }
353
+
354
+ .pill {
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 10px;
358
+ padding: 8px 12px;
359
+ border-radius: 999px;
360
+ border: 1px solid var(--stroke2);
361
+ background: rgba(255, 255, 255, .04);
362
+ box-shadow: 0 10px 26px rgba(0, 0, 0, .35);
363
+ font-size: 12px;
364
+ color: var(--muted);
365
+ white-space: nowrap;
366
+ }
367
+
368
+ .dot {
369
+ width: 8px;
370
+ height: 8px;
371
+ border-radius: 50%;
372
+ background: var(--good);
373
+ box-shadow: 0 0 16px rgba(34, 197, 94, .6);
374
+ }
375
+
376
+ .dot.warn {
377
+ background: var(--warn);
378
+ box-shadow: 0 0 16px rgba(245, 158, 11, .55);
379
+ }
380
+
381
+ .dot.bad {
382
+ background: var(--bad);
383
+ box-shadow: 0 0 16px rgba(239, 68, 68, .55);
384
+ }
385
+
386
+ .kbd {
387
+ font-family: var(--mono);
388
+ font-size: 11px;
389
+ padding: 2px 6px;
390
+ border: 1px solid var(--stroke2);
391
+ border-bottom-color: rgba(255, 255, 255, .24);
392
+ background: rgba(0, 0, 0, .35);
393
+ border-radius: 7px;
394
+ color: rgba(255, 255, 255, .78);
395
+ }
396
+
397
+ .badge {
398
+ display: inline-flex;
399
+ align-items: center;
400
+ gap: 6px;
401
+ padding: 4px 8px;
402
+ border-radius: 999px;
403
+ border: 1px solid var(--stroke2);
404
+ background: rgba(0, 0, 0, .25);
405
+ font-family: var(--mono);
406
+ }
407
+
408
+ /* =========================================
409
+ Navigation: Tabs
410
+ ========================================= */
411
+
412
+ .tabs {
413
+ display: flex;
414
+ gap: 8px;
415
+ padding: 10px 12px;
416
+ border-bottom: 1px solid var(--stroke);
417
+ background: linear-gradient(180deg, rgba(255, 255, 255, .035), transparent);
418
+ flex-wrap: wrap;
419
+ }
420
+
421
+ .tabbtn {
422
+ cursor: pointer;
423
+ border: none;
424
+ border-radius: 999px;
425
+ padding: 8px 12px;
426
+ font-size: 12px;
427
+ color: rgba(255, 255, 255, .75);
428
+ background: rgba(255, 255, 255, .04);
429
+ border: 1px solid var(--stroke2);
430
+ }
431
+
432
+ .tabbtn.active {
433
+ color: rgba(255, 255, 255, .92);
434
+ background: linear-gradient(135deg, rgba(124, 58, 237, .35), rgba(34, 211, 238, .10));
435
+ border-color: rgba(124, 58, 237, .45);
436
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, .14);
437
+ }
438
+
439
+ .tab {
440
+ display: none;
441
+ flex: 1;
442
+ min-height: 0;
443
+ overflow: auto;
444
+ padding: 12px;
445
+ }
446
+
447
+ .tab.active {
448
+ display: block;
449
+ }
450
+
451
+ /* =========================================
452
+ Visualization: Views & Canvas
453
+ ========================================= */
454
+
455
+ .viewbox {
456
+ position: relative;
457
+ border-radius: 14px;
458
+ overflow: hidden;
459
+ background: radial-gradient(700px 380px at 30% 30%, rgba(124, 58, 237, .12), rgba(0, 0, 0, .0) 60%),
460
+ linear-gradient(180deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .15));
461
+ border: 1px solid rgba(255, 255, 255, .08);
462
+ min-height: 360px;
463
+ }
464
+
465
+ .viewbox canvas,
466
+ .viewbox video {
467
+ width: 100%;
468
+ height: 100%;
469
+ display: block;
470
+ }
471
+
472
+ /* Always show the engage video feed */
473
+ #videoEngage {
474
+ display: block;
475
+ opacity: 1;
476
+ }
477
+
478
+ .viewbox .overlay {
479
+ position: absolute;
480
+ inset: 0;
481
+ pointer-events: none;
482
+ }
483
+
484
+ /* Make engage overlay visible as main display (not just overlay) */
485
+ #engageOverlay {
486
+ display: none;
487
+ pointer-events: none;
488
+ }
489
+
490
+ .viewbox .watermark {
491
+ position: absolute;
492
+ left: 10px;
493
+ bottom: 10px;
494
+ font-family: var(--mono);
495
+ font-size: 11px;
496
+ color: rgba(255, 255, 255, .55);
497
+ background: rgba(0, 0, 0, .35);
498
+ border: 1px solid rgba(255, 255, 255, .14);
499
+ padding: 6px 8px;
500
+ border-radius: 10px;
501
+ }
502
+
503
+ .viewbox .empty {
504
+ position: absolute;
505
+ inset: 0;
506
+ display: flex;
507
+ flex-direction: column;
508
+ align-items: center;
509
+ justify-content: center;
510
+ gap: 10px;
511
+ color: rgba(255, 255, 255, .72);
512
+ text-align: center;
513
+ padding: 22px;
514
+ }
515
+
516
+ .viewbox .empty .big {
517
+ font-size: 14px;
518
+ letter-spacing: .12em;
519
+ text-transform: uppercase;
520
+ }
521
+
522
+ .viewbox .empty .small {
523
+ color: var(--muted);
524
+ font-size: 12px;
525
+ max-width: 520px;
526
+ line-height: 1.4;
527
+ }
528
+
529
+ /* =========================================
530
+ Lists & Tables
531
+ ========================================= */
532
+
533
+ .list {
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 8px;
537
+ min-height: 160px;
538
+ max-height: 320px;
539
+ overflow: auto;
540
+ padding-right: 4px;
541
+ }
542
+
543
+ .obj {
544
+ padding: 10px;
545
+ border-radius: 14px;
546
+ border: 1px solid var(--stroke2);
547
+ background: rgba(255, 255, 255, .03);
548
+ cursor: pointer;
549
+ }
550
+
551
+ .obj:hover {
552
+ background: rgba(255, 255, 255, .05);
553
+ }
554
+
555
+ .obj.active {
556
+ border-color: rgba(34, 211, 238, .45);
557
+ box-shadow: 0 0 0 3px rgba(34, 211, 238, .14);
558
+ background: linear-gradient(135deg, rgba(34, 211, 238, .10), rgba(124, 58, 237, .08));
559
+ }
560
+
561
+ .obj .top {
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: space-between;
565
+ gap: 10px;
566
+ }
567
+
568
+ .obj .id {
569
+ font-family: var(--mono);
570
+ font-size: 12px;
571
+ color: rgba(255, 255, 255, .90);
572
+ }
573
+
574
+ .obj .cls {
575
+ font-size: 12px;
576
+ color: rgba(255, 255, 255, .80);
577
+ }
578
+
579
+ .obj .meta {
580
+ margin-top: 6px;
581
+ display: flex;
582
+ gap: 10px;
583
+ flex-wrap: wrap;
584
+ font-size: 11px;
585
+ color: var(--muted);
586
+ }
587
+
588
+ .table {
589
+ width: 100%;
590
+ border-collapse: separate;
591
+ border-spacing: 0;
592
+ overflow: hidden;
593
+ border-radius: 14px;
594
+ border: 1px solid rgba(255, 255, 255, .10);
595
+ }
596
+
597
+ .table th,
598
+ .table td {
599
+ padding: 8px 10px;
600
+ font-size: 12px;
601
+ border-bottom: 1px solid rgba(255, 255, 255, .08);
602
+ vertical-align: top;
603
+ }
604
+
605
+ .table th {
606
+ background: rgba(255, 255, 255, .04);
607
+ color: rgba(255, 255, 255, .78);
608
+ letter-spacing: .12em;
609
+ text-transform: uppercase;
610
+ font-size: 11px;
611
+ }
612
+
613
+ .table tr:last-child td {
614
+ border-bottom: none;
615
+ }
616
+
617
+ .k {
618
+ font-family: var(--mono);
619
+ color: rgba(255, 255, 255, .84);
620
+ }
621
+
622
+ .mini {
623
+ font-size: 11px;
624
+ color: var(--muted);
625
+ line-height: 1.35;
626
+ }
627
+
628
+ /* =========================================
629
+ Metrics & Logs
630
+ ========================================= */
631
+
632
+ .metricgrid {
633
+ display: grid;
634
+ grid-template-columns: 1fr 1fr;
635
+ gap: 8px;
636
+ }
637
+
638
+ .metric {
639
+ border: 1px solid rgba(255, 255, 255, .10);
640
+ background: rgba(255, 255, 255, .03);
641
+ border-radius: 14px;
642
+ padding: 10px;
643
+ }
644
+
645
+ .metric .label {
646
+ font-size: 11px;
647
+ color: var(--muted);
648
+ letter-spacing: .12em;
649
+ text-transform: uppercase;
650
+ }
651
+
652
+ .metric .value {
653
+ margin-top: 6px;
654
+ font-family: var(--mono);
655
+ font-size: 16px;
656
+ color: rgba(255, 255, 255, .92);
657
+ }
658
+
659
+ .metric .sub {
660
+ margin-top: 4px;
661
+ font-size: 11px;
662
+ color: var(--faint);
663
+ line-height: 1.35;
664
+ }
665
+
666
+ .log {
667
+ font-family: var(--mono);
668
+ font-size: 11px;
669
+ color: rgba(255, 255, 255, .78);
670
+ line-height: 1.45;
671
+ background: rgba(0, 0, 0, .35);
672
+ border: 1px solid rgba(255, 255, 255, .12);
673
+ border-radius: 14px;
674
+ padding: 10px;
675
+ height: 210px;
676
+ overflow: auto;
677
+ white-space: pre-wrap;
678
+ }
679
+
680
+ .log .t {
681
+ color: rgba(34, 211, 238, .95);
682
+ }
683
+
684
+ .log .w {
685
+ color: rgba(245, 158, 11, .95);
686
+ }
687
+
688
+ .log .e {
689
+ color: rgba(239, 68, 68, .95);
690
+ }
691
+
692
+ .log .g {
693
+ color: rgba(34, 197, 94, .95);
694
+ }
695
+
696
+ /* =========================================
697
+ Tab Specific: Intel + Frame
698
+ ========================================= */
699
+
700
+ .frame-grid {
701
+ display: grid;
702
+ grid-template-columns: 1.6fr .9fr;
703
+ grid-template-rows: auto auto 1fr;
704
+ gap: 12px;
705
+ min-height: 0;
706
+ }
707
+
708
+ .intel {
709
+ margin-top: 10px;
710
+ display: flex;
711
+ flex-direction: column;
712
+ gap: 8px;
713
+ }
714
+
715
+ .intel-top {
716
+ display: flex;
717
+ align-items: center;
718
+ justify-content: space-between;
719
+ gap: 8px;
720
+ }
721
+
722
+ .thumbrow {
723
+ display: flex;
724
+ gap: 8px;
725
+ }
726
+
727
+ .thumbrow img {
728
+ flex: 1;
729
+ height: 86px;
730
+ object-fit: cover;
731
+ border-radius: 12px;
732
+ border: 1px solid rgba(255, 255, 255, .12);
733
+ background: rgba(0, 0, 0, .25);
734
+ }
735
+
736
+ .intelbox {
737
+ font-size: 12px;
738
+ line-height: 1.45;
739
+ color: rgba(255, 255, 255, .84);
740
+ background: rgba(0, 0, 0, .35);
741
+ border: 1px solid rgba(255, 255, 255, .12);
742
+ border-radius: 14px;
743
+ padding: 10px;
744
+ min-height: 72px;
745
+ }
746
+
747
+ /* =========================================
748
+ Tab Specific: Engage
749
+ ========================================= */
750
+
751
+ .engage-grid {
752
+ display: grid;
753
+ grid-template-columns: 1.6fr .9fr;
754
+ gap: 12px;
755
+ min-height: 0;
756
+ transition: grid-template-columns 0.3s ease;
757
+ }
758
+
759
+ .engage-grid.sidebar-collapsed {
760
+ grid-template-columns: 1fr 0fr;
761
+ }
762
+
763
+ .engage-grid.sidebar-collapsed .engage-right {
764
+ display: none;
765
+ }
766
+
767
+ .engage-right {
768
+ display: flex;
769
+ flex-direction: column;
770
+ gap: 12px;
771
+ min-height: 0;
772
+ }
773
+
774
+ .radar {
775
+ height: 540px;
776
+ display: flex;
777
+ flex-direction: column;
778
+ }
779
+
780
+ .radar canvas {
781
+ flex: 1;
782
+ width: 100%;
783
+ height: 100%;
784
+ display: block;
785
+ }
786
+
787
+ .strip {
788
+ display: flex;
789
+ gap: 8px;
790
+ flex-wrap: wrap;
791
+ align-items: center;
792
+ font-size: 12px;
793
+ color: var(--muted);
794
+ }
795
+
796
+ .strip .chip {
797
+ padding: 6px 10px;
798
+ border-radius: 999px;
799
+ border: 1px solid rgba(255, 255, 255, .12);
800
+ background: rgba(255, 255, 255, .03);
801
+ font-family: var(--mono);
802
+ color: rgba(255, 255, 255, .78);
803
+ }
804
+
805
+ /* Sidebar Checkbox Row */
806
+ .checkbox-row {
807
+ grid-column: span 2;
808
+ margin-top: 8px;
809
+ border-top: 1px solid var(--stroke2);
810
+ padding-top: 8px;
811
+ display: flex;
812
+ align-items: center;
813
+ gap: 8px;
814
+ cursor: pointer;
815
+ }
816
+
817
+ .checkbox-row input[type="checkbox"] {
818
+ width: auto;
819
+ margin: 0;
820
+ }
821
+
822
+ .bar {
823
+ height: 10px;
824
+ border-radius: 999px;
825
+ background: rgba(255, 255, 255, .08);
826
+ border: 1px solid rgba(255, 255, 255, .12);
827
+ overflow: hidden;
828
+ }
829
+
830
+ .bar>div {
831
+ height: 100%;
832
+ width: 0%;
833
+ background: linear-gradient(90deg, rgba(34, 211, 238, .95), rgba(124, 58, 237, .95));
834
+ transition: width .18s ease;
835
+ }
836
+
837
+ /* =========================================
838
+ Tab Specific: Trade Space
839
+ ========================================= */
840
+
841
+ .trade-grid {
842
+ display: grid;
843
+ grid-template-columns: 1.35fr .65fr;
844
+ gap: 12px;
845
+ min-height: 0;
846
+ }
847
+
848
+ .plot {
849
+ height: 420px;
850
+ }
851
+
852
+ /* =========================================
853
+ Utilities
854
+ ========================================= */
855
+
856
+ ::-webkit-scrollbar {
857
+ width: 10px;
858
+ height: 10px;
859
+ }
860
+
861
+ ::-webkit-scrollbar-thumb {
862
+ background: rgba(255, 255, 255, .10);
863
+ border-radius: 999px;
864
+ border: 2px solid rgba(0, 0, 0, .25);
865
+ }
866
+
867
+ ::-webkit-scrollbar-thumb:hover {
868
+ background: rgba(255, 255, 255, .16);
869
+ }
870
+
871
+ /* Track Cards */
872
+ .track-card {
873
+ background: rgba(255, 255, 255, 0.03);
874
+ border: 1px solid var(--border-color);
875
+ border-radius: 4px;
876
+ padding: 8px;
877
+ margin-bottom: 8px;
878
+ cursor: pointer;
879
+ transition: all 0.2s;
880
+ }
881
+
882
+ .track-card:hover {
883
+ background: rgba(255, 255, 255, 0.08);
884
+ }
885
+
886
+ .track-card.active {
887
+ border-color: var(--accent);
888
+ background: rgba(34, 211, 238, 0.1);
889
+ }
890
+
891
+ .track-card-header {
892
+ display: flex;
893
+ justify-content: space-between;
894
+ align-items: center;
895
+ font-weight: 600;
896
+ margin-bottom: 4px;
897
+ font-size: 13px;
898
+ color: var(--text-color);
899
+ }
900
+
901
+ .track-card-meta {
902
+ font-size: 11px;
903
+ color: var(--text-dim);
904
+ margin-bottom: 4px;
905
+ }
906
+
907
+ .track-card-body {
908
+ font-size: 11px;
909
+ line-height: 1.4;
910
+ color: #ccc;
911
+ background: rgba(0, 0, 0, 0.2);
912
+ padding: 6px;
913
+ border-radius: 4px;
914
+ }
915
+
916
+ .gpt-badge {
917
+ color: gold;
918
+ font-size: 10px;
919
+ border: 1px solid gold;
920
+ border-radius: 3px;
921
+ padding: 1px 4px;
922
+ margin-left: 6px;
923
+ }
924
+
925
+ .gpt-text {
926
+ color: #e0e0e0;
927
+ }