diff --git "a/LaserPerception/LaserPerception.js" "b/LaserPerception/LaserPerception.js" deleted file mode 100644--- "a/LaserPerception/LaserPerception.js" +++ /dev/null @@ -1,3867 +0,0 @@ -/* ========================= - Weapon-Grade Demo Engine - - Tab 1: first-frame perception + reasoning - - Tab 2: closed-loop tracking + dynamic dwell update - - Tab 3: trade-space console - ========================= */ - -(() => { - const API_CONFIG = window.API_CONFIG || {}; - const BACKEND_BASE = (() => { - const raw = (API_CONFIG.BACKEND_BASE || API_CONFIG.BASE_URL || "").trim(); - if (raw) return raw.replace(/\/$/, ""); - const origin = (window.location && window.location.origin) || ""; - if (origin && origin !== "null") return origin; - return ""; - })(); - const $ = (sel, root = document) => root.querySelector(sel); - const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); - const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); - const lerp = (a, b, t) => a + (b - a) * t; - const now = () => performance.now(); - const ENABLE_KILL = false; - - const state = { - videoUrl: null, - videoFile: null, - videoLoaded: false, - useProcessedFeed: false, - useDepthFeed: false, // Flag for depth view (Tab 2 video) - useFrameDepthView: false, // Flag for first frame depth view (Tab 1) - hasReasoned: false, - isReasoning: false, // Flag to prevent concurrent Reason executions - - hf: { - baseUrl: BACKEND_BASE, - detector: "auto", - asyncJobId: null, // Current job ID from /detect/async - asyncPollInterval: null, // Polling timer handle - firstFrameUrl: null, // First frame preview URL - firstFrameDetections: null, // First-frame detections from backend - statusUrl: null, // Status polling URL - videoUrl: null, // Final video URL - asyncStatus: "idle", // "idle"|"processing"|"completed"|"failed" - asyncProgress: null, // Progress data from status endpoint - queries: [], // Mission objective used as query - processedUrl: null, - processedBlob: null, - depthVideoUrl: null, // Depth video URL - depthFirstFrameUrl: null, // First frame depth URL - depthBlob: null, // Depth video blob - depthFirstFrameBlob: null, // Depth first frame blob - summary: null, - busy: false, - lastError: null - }, - - detector: { - mode: "coco", - kind: "object", - loaded: false, - model: null, - loading: false, - cocoBlocked: false, - hfTrackingWarned: false - }, - - tracker: { - mode: "iou", - tracks: [], - nextId: 1, - lastDetTime: 0, - running: false, - selectedTrackId: null, - beamOn: false, - lastFrameTime: 0 - }, - - frame: { - w: 1280, - h: 720, - bitmap: null - }, - - detections: [], // from Tab 1 - selectedId: null, - - intelBusy: false, - - ui: { - cursorMode: "on", - agentCursor: { x: 0.65, y: 0.28, vx: 0, vy: 0, visible: false, target: null, mode: "idle", t0: 0 } - } - }; - - // Config: Update track reasoning every 30 frames - const REASON_INTERVAL = 30; - - // ========= Elements ========= - const sysDot = $("#sys-dot"); - const sysStatus = $("#sys-status"); - const sysLog = $("#sysLog"); - const telemetry = $("#telemetry"); - - const videoFile = $("#videoFile"); - const btnEject = $("#btnEject"); - - const detectorSelect = $("#detectorSelect"); - const trackerSelect = $("#trackerSelect"); - - function getDetectorSelection() { - const opt = detectorSelect?.options?.[detectorSelect.selectedIndex]; - return { - value: detectorSelect?.value || "coco", - kind: opt?.dataset?.kind || "object", - label: (opt?.textContent || "").trim() - }; - } - - const helPower = $("#helPower"); - const helAperture = $("#helAperture"); - const helM2 = $("#helM2"); - const helJitter = $("#helJitter"); - const helDuty = $("#helDuty"); - const helMode = $("#helMode"); - - const atmVis = $("#atmVis"); - const atmCn2 = $("#atmCn2"); - const seaSpray = $("#seaSpray"); - const aoQ = $("#aoQ"); - const rangeBase = $("#rangeBase"); - const detHz = $("#detHz"); - - const policyMode = $("#policyMode"); - const assessWindow = $("#assessWindow"); - const cursorMode = $("#cursorMode"); - - const btnReason = $("#btnReason"); - const btnCancelReason = $("#btnCancelReason"); - const btnRecompute = $("#btnRecompute"); - const btnClear = $("#btnClear"); - - const frameCanvas = $("#frameCanvas"); - const frameOverlay = $("#frameOverlay"); - const frameRadar = $("#frameRadar"); - const frameEmpty = $("#frameEmpty"); - const frameNote = $("#frameNote"); - - // const objList = $("#objList"); // Removed - // const objList = $("#objList"); // Removed - const objCount = $("#objCount"); - const featureTable = $("#featureTable"); - const selId = $("#selId"); - const checkEnableGPT = $("#enableGPTToggle"); - - const trackCount = $("#trackCount"); - const frameTrackList = $("#frameTrackList"); - // Removed old summary references - - - const videoHidden = $("#videoHidden"); - - const videoEngage = $("#videoEngage"); - const engageOverlay = $("#engageOverlay"); - const engageEmpty = $("#engageEmpty"); - const engageNote = $("#engageNote"); - - // Mission-driven (HF Space) backend controls - const missionText = $("#missionText"); - const hfBackendStatus = $("#hfBackendStatus"); - - const intelSummaryBox = $("#intelSummaryBox"); - const intelStamp = $("#intelStamp"); - const intelDot = $("#intelDot"); - const btnIntelRefresh = $("#btnIntelRefresh"); - const intelThumbs = [$("#intelThumb0"), $("#intelThumb1"), $("#intelThumb2")]; - const missionClassesEl = $("#missionClasses"); - const missionIdEl = $("#missionId"); - const chipFeed = $("#chipFeed"); - - const btnEngage = $("#btnEngage"); - - // Debug hook for console inspection - window.__LP_STATE__ = state; - const btnPause = $("#btnPause"); - const btnReset = $("#btnReset"); - const btnToggleSidebar = $("#btnToggleSidebar"); - - const chipPolicy = $("#chipPolicy"); - const chipTracks = $("#chipTracks"); - const chipBeam = $("#chipBeam"); - const chipHz = $("#chipHz"); - const chipDepth = $("#chipDepth"); - const chipFrameDepth = $("#chipFrameDepth"); - - const dwellText = $("#dwellText"); - const dwellBar = $("#dwellBar"); - - const radarCanvas = $("#radarCanvas"); - const trackList = $("#trackList"); - const liveStamp = $("#liveStamp"); - - const tradeCanvas = $("#tradeCanvas"); - const tradeTarget = $("#tradeTarget"); - const rMin = $("#rMin"); - const rMax = $("#rMax"); - const showPk = $("#showPk"); - const btnReplot = $("#btnReplot"); - const btnSnap = $("#btnSnap"); - - // ========= UI: knobs display ========= - function syncKnobDisplays() { - $("#helPowerVal").textContent = helPower.value; - $("#helApertureVal").textContent = (+helAperture.value).toFixed(2); - $("#helM2Val").textContent = (+helM2.value).toFixed(1); - $("#helJitterVal").textContent = (+helJitter.value).toFixed(1); - $("#helDutyVal").textContent = helDuty.value; - - $("#atmVisVal").textContent = atmVis.value; - $("#atmCn2Val").textContent = atmCn2.value; - $("#seaSprayVal").textContent = seaSpray.value; - $("#aoQVal").textContent = aoQ.value; - - $("#rangeBaseVal").textContent = rangeBase.value; - $("#detHzVal").textContent = detHz.value; - $("#assessWindowVal").textContent = (+assessWindow.value).toFixed(1); - - chipPolicy.textContent = `POLICY:${policyMode.value.toUpperCase()}`; - chipHz.textContent = `DET:${detHz.value}Hz`; - - telemetry.textContent = `HEL=${helPower.value}kW · VIS=${atmVis.value}km · Cn²=${atmCn2.value}/10 · AO=${aoQ.value}/10 · DET=${detHz.value}Hz`; - } - $$("input,select").forEach(el => el.addEventListener("input", () => { - syncKnobDisplays(); - if (state.hasReasoned) { - // keep it responsive: recompute power/dwell numerics even without rerunning detection - recomputeHEL(); // async but we don't await here for UI responsiveness - renderFrameOverlay(); - renderTrade(); - } - })); - syncKnobDisplays(); - renderMissionContext(); - setHfStatus("idle"); - - const detInit = getDetectorSelection(); - state.detector.mode = detInit.value; - state.detector.kind = detInit.kind; - state.hf.detector = detInit.value; - - // Toggle RAW vs HF feed - chipFeed.addEventListener("click", async () => { - if (!state.videoLoaded) return; - if (!state.hf.processedUrl) { - log("HF processed feed not ready yet. Run Reason (HF mode) and wait for backend.", "w"); - return; - } - await setEngageFeed(!state.useProcessedFeed); - log(`Engage feed set to: ${state.useProcessedFeed ? "HF" : "RAW"}`, "t"); - }); - - // Toggle depth view - chipDepth.addEventListener("click", async () => { - if (!state.videoLoaded) return; - if (!state.hf.depthVideoUrl) { - log("Depth video not ready yet. Run Reason and wait for depth processing.", "w"); - return; - } - await toggleDepthView(); - log(`View set to: ${state.useDepthFeed ? "DEPTH" : "DEFAULT"}`, "t"); - }); - - // Toggle first frame depth view (Tab 1) - if (chipFrameDepth) { - chipFrameDepth.addEventListener("click", () => { - if (!state.videoLoaded) return; - if (!state.hf.depthFirstFrameUrl) { - log("First frame depth not ready yet. Run Reason and wait for depth processing.", "w"); - return; - } - toggleFirstFrameDepthView(); - log(`First frame view set to: ${state.useFrameDepthView ? "DEPTH" : "DEFAULT"}`, "t"); - }); - } - - // Refresh intel summary (unbiased) - if (btnIntelRefresh) { - btnIntelRefresh.addEventListener("click", async () => { - if (!state.videoLoaded) return; - log("Refreshing mission intel summary (unbiased)…", "t"); - await computeIntelSummary(); - }); - } - - // ========= Logging ========= - function log(msg, level = "t") { - const ts = new Date().toLocaleTimeString(); - const prefix = level === "e" ? "[ERR]" : (level === "w" ? "[WARN]" : (level === "g" ? "[OK]" : "[SYS]")); - const line = `${ts} ${prefix} ${msg}\n`; - const span = document.createElement("span"); - span.className = level; - span.textContent = line; - sysLog.appendChild(span); - sysLog.scrollTop = sysLog.scrollHeight; - } - - function setStatus(kind, text) { - sysStatus.textContent = text; - sysDot.className = "dot" + (kind === "warn" ? " warn" : (kind === "bad" ? " bad" : "")); - } - - // ========= Mission Intel Summary (unbiased, no location) ========= - function setIntelStatus(kind, text) { - if (!intelStamp || !intelDot) return; - intelStamp.innerHTML = text; - intelDot.className = "dot" + (kind === "warn" ? " warn" : (kind === "bad" ? " bad" : "")); - intelDot.style.width = "7px"; - intelDot.style.height = "7px"; - intelDot.style.boxShadow = "none"; - } - - function setIntelThumb(i, dataUrl) { - const img = intelThumbs?.[i]; - if (!img) return; - img.src = dataUrl || ""; - } - - function resetIntelUI() { - if (!intelSummaryBox) return; - intelSummaryBox.innerHTML = 'Upload a video, then click Reason to generate an unbiased scene summary.'; - setIntelStatus("warn", "Idle"); - setIntelThumb(0, ""); - setIntelThumb(1, ""); - setIntelThumb(2, ""); - } - - function pluralize(label, n) { - if (n === 1) return label; - if (label.endsWith("s")) return label; - return label + "s"; - } - // [Deleted] inferSceneDescriptor - - async function computeIntelSummary() { - if (!intelSummaryBox) return; - if (!state.videoLoaded) { resetIntelUI(); return; } - if (state.intelBusy) return; - - state.intelBusy = true; - setIntelStatus("warn", "Generating…"); - intelSummaryBox.textContent = "Sampling frames and running analysis…"; - - try { - const dur = (videoHidden?.duration || videoEngage?.duration || 0); - const times = [0, dur ? dur * 0.33 : 1, dur ? dur * 0.66 : 2]; - const frames = []; - - // Sample frames - for (let i = 0; i < times.length; i++) { - await seekTo(videoHidden, times[i]); - const bmp = await frameToBitmap(videoHidden); - // Draw to temp canvas to get dataURL - const c = document.createElement("canvas"); - c.width = 640; c.height = 360; // downscale - const ctx = c.getContext("2d"); - ctx.drawImage(bmp, 0, 0, c.width, c.height); - const dataUrl = c.toDataURL("image/jpeg", 0.6); - frames.push(dataUrl); - - // update thumb - try { setIntelThumb(i, dataUrl); } catch (_) { } - } - - // Call external hook - const summary = await externalIntel(frames); - - intelSummaryBox.textContent = summary; - setIntelStatus("good", `Updated · ${new Date().toLocaleTimeString()}`); - } catch (err) { - setIntelStatus("bad", "Summary unavailable"); - intelSummaryBox.textContent = `Unable to generate summary: ${err.message}`; - console.error(err); - } finally { - state.intelBusy = false; - } - } - - - // ========= Tabs ========= - $$(".tabbtn").forEach(btn => { - btn.addEventListener("click", () => { - $$(".tabbtn").forEach(b => b.classList.remove("active")); - btn.classList.add("active"); - const tab = btn.dataset.tab; - $$(".tab").forEach(t => t.classList.remove("active")); - $(`#tab-${tab}`).classList.add("active"); - if (tab === "trade") renderTrade(); - if (tab === "engage") { - resizeOverlays(); - renderRadar(); - renderTrackCards(); - } - }); - }); - - // ========= Video load / unload ========= - async function unloadVideo(options = {}) { - const preserveInput = !!options.preserveInput; - // Stop polling if running - if (state.hf.asyncPollInterval) { - clearInterval(state.hf.asyncPollInterval); - state.hf.asyncPollInterval = null; - } - - if (state.videoUrl && state.videoUrl.startsWith("blob:")) { - URL.revokeObjectURL(state.videoUrl); - } - if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.processedUrl); } catch (_) { } - } - if (state.hf.depthVideoUrl && state.hf.depthVideoUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.depthVideoUrl); } catch (_) { } - } - if (state.hf.depthFirstFrameUrl && state.hf.depthFirstFrameUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.depthFirstFrameUrl); } catch (_) { } - } - state.videoUrl = null; - state.videoFile = null; - state.videoLoaded = false; - state.useProcessedFeed = false; - state.useDepthFeed = false; - state.useFrameDepthView = false; - - state.hf.missionId = null; - state.hf.plan = null; - state.hf.processedUrl = null; - state.hf.processedBlob = null; - state.hf.depthVideoUrl = null; - state.hf.depthBlob = null; - state.hf.depthFirstFrameUrl = null; - state.hf.depthFirstFrameBlob = null; - state.hf.summary = null; - state.hf.busy = false; - state.hf.lastError = null; - state.hf.asyncJobId = null; - state.hf.asyncStatus = "idle"; - setHfStatus("idle"); - renderMissionContext(); - resetIntelUI(); - state.hasReasoned = false; - state.isReasoning = false; // Reset reasoning lock - - // Reset Reason button state - btnReason.disabled = false; - btnReason.style.opacity = "1"; - btnReason.style.cursor = "pointer"; - btnCancelReason.style.display = "none"; - btnEngage.disabled = true; - - state.detections = []; - state.selectedId = null; - - state.tracker.tracks = []; - state.tracker.nextId = 1; - state.tracker.running = false; - state.tracker.selectedTrackId = null; - state.tracker.beamOn = false; - - videoHidden.removeAttribute("src"); - videoEngage.removeAttribute("src"); - videoHidden.load(); - videoEngage.load(); - if (!preserveInput) { - videoFile.value = ""; - } - - if (!preserveInput) { - $("#videoMeta").textContent = "No file"; - } - frameEmpty.style.display = "flex"; - engageEmpty.style.display = "flex"; - frameNote.textContent = "Awaiting video"; - engageNote.textContent = "Awaiting video"; - - clearCanvas(frameCanvas); - clearCanvas(frameOverlay); - clearCanvas(engageOverlay); - renderRadar(); - - renderFrameTrackList(); - // renderSummary(); - renderFeatures(null); - renderTrade(); - - setStatus("warn", "STANDBY · No video loaded"); - log("Video unloaded. Demo reset.", "w"); - } - - btnEject.addEventListener("click", async () => { - await unloadVideo(); - }); - - videoFile.addEventListener("change", async (e) => { - const file = e.target.files && e.target.files[0]; - if (!file) return; - const pendingFile = file; - - await unloadVideo({ preserveInput: true }); - state.videoFile = pendingFile; - - const nullOrigin = (window.location && window.location.origin) === "null"; - if (nullOrigin) { - state.videoUrl = await readFileAsDataUrl(pendingFile); - } else { - state.videoUrl = URL.createObjectURL(pendingFile); - } - // STOP any existing async polling - stopAsyncPolling(); - - // reset HF backend state for this new upload - if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.processedUrl); } catch (_) { } - } - if (state.hf.depthVideoUrl && state.hf.depthVideoUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.depthVideoUrl); } catch (_) { } - } - if (state.hf.depthFirstFrameUrl && state.hf.depthFirstFrameUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.depthFirstFrameUrl); } catch (_) { } - } - state.hf.processedUrl = null; - state.hf.processedBlob = null; - state.hf.depthVideoUrl = null; - state.hf.depthBlob = null; - state.hf.depthFirstFrameUrl = null; - state.hf.depthFirstFrameBlob = null; - state.hf.asyncJobId = null; - state.hf.firstFrameUrl = null; - state.hf.firstFrameDetections = null; - state.hf.statusUrl = null; - state.hf.videoUrl = null; - state.hf.asyncStatus = "idle"; - state.hf.asyncProgress = null; - state.hf.queries = []; - state.hf.summary = null; - state.hf.lastError = null; - state.hf.busy = false; - state.useProcessedFeed = false; - state.useDepthFeed = false; - state.useFrameDepthView = false; - setHfStatus("idle"); - renderMissionContext(); - videoHidden.src = state.videoUrl; - videoEngage.removeAttribute("src"); - videoEngage.load(); - // Initialize with no engage feed until processed video is ready - videoEngage.setAttribute("data-processed", "false"); - btnEngage.disabled = true; - - setStatus("warn", "LOADING · Parsing video metadata"); - log(`Video selected: ${pendingFile.name} (${Math.round(pendingFile.size / 1024 / 1024)} MB)`, "t"); - - await Promise.all([ - waitVideoReady(videoHidden), - waitVideoReady(videoHidden) - ]); - - const dur = videoHidden.duration || 0; - const w = videoHidden.videoWidth || 1280; - const h = videoHidden.videoHeight || 720; - - state.videoLoaded = true; - state.frame.w = w; - state.frame.h = h; - - $("#videoMeta").textContent = `${pendingFile.name} · ${dur.toFixed(1)}s · ${w}×${h}`; - frameEmpty.style.display = "none"; - engageEmpty.style.display = "none"; - frameNote.textContent = `${w}×${h} · First frame only`; - engageNote.textContent = `${dur.toFixed(1)}s · paused`; - - setStatus("warn", "READY · Video loaded (run Reason)"); - log("Video loaded. Ready for first-frame reasoning.", "g"); - - resizeOverlays(); - await captureFirstFrame(); - drawFirstFrame(); - renderFrameOverlay(); - renderRadar(); - renderTrade(); - }); - - function displayAsyncFirstFrame() { - if (!state.hf.firstFrameUrl) return; - - log(`Fetching HF first frame: ${state.hf.firstFrameUrl}`, "t"); - - // Display first frame with detections overlaid (segmentation masks or bounding boxes) - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = `${state.hf.firstFrameUrl}?t=${Date.now()}`; // Cache bust - - img.onload = () => { - frameCanvas.width = img.width; - frameCanvas.height = img.height; - frameOverlay.width = img.width; - frameOverlay.height = img.height; - - const ctx = frameCanvas.getContext("2d"); - ctx.clearRect(0, 0, img.width, img.height); - ctx.drawImage(img, 0, 0); - - frameEmpty.style.display = "none"; - log(`✓ HF first frame displayed (${img.width}×${img.height})`, "g"); - }; - - img.onerror = (err) => { - console.error("Failed to load first frame:", err); - log("✗ HF first frame load failed - check CORS or URL", "e"); - }; - } - - async function waitVideoReady(v) { - return new Promise((resolve) => { - const onReady = () => { v.removeEventListener("loadedmetadata", onReady); resolve(); }; - if (v.readyState >= 1 && v.videoWidth) { resolve(); return; } - v.addEventListener("loadedmetadata", onReady); - v.load(); - }); - } - - function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("Failed to read file")); - reader.readAsDataURL(file); - }); - } - - async function captureFirstFrame() { - if (!state.videoLoaded) return; - await seekTo(videoHidden, 0.0); - const bmp = await frameToBitmap(videoHidden); - state.frame.bitmap = bmp; - log("Captured first frame for Tab 1 reasoning.", "t"); - } - - async function seekTo(v, timeSec) { - return new Promise((resolve, reject) => { - const onSeeked = () => { v.removeEventListener("seeked", onSeeked); resolve(); }; - const onError = () => { v.removeEventListener("error", onError); reject(new Error("Video seek error")); }; - v.addEventListener("seeked", onSeeked); - v.addEventListener("error", onError); - try { - v.currentTime = clamp(timeSec, 0, Math.max(0, v.duration - 0.05)); - } catch (err) { - v.removeEventListener("seeked", onSeeked); - v.removeEventListener("error", onError); - reject(err); - } - }); - } - - async function frameToBitmap(videoEl) { - const w = videoEl.videoWidth || 1280; - const h = videoEl.videoHeight || 720; - const c = document.createElement("canvas"); - c.width = w; c.height = h; - const ctx = c.getContext("2d"); - ctx.drawImage(videoEl, 0, 0, w, h); - if ("createImageBitmap" in window) { - return await createImageBitmap(c); - } - return c; // fallback - } - - function clearCanvas(canvas) { - const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, canvas.width, canvas.height); - } - - // ========= Detector loading ========= - - // ========= Mission-driven HF Space backend ========= - const ALL_CLASSES_PROMPT = "No mission objective provided. Run full-class object detection across all supported classes. Detect and label every object you can with bounding boxes; do not filter by mission."; - const DEFAULT_QUERY_CLASSES = [ - "person", - "car", - "truck", - "motorcycle", - "bicycle", - "bus", - "train", - "airplane" - ]; - function missionPromptOrAll() { - const t = (missionText?.value || "").trim(); - return t || ALL_CLASSES_PROMPT; - } - - const HF_SPACE_DETECTORS = new Set([ - "hf_yolov8", - "detr_resnet50", - "grounding_dino", - "sam3", - "drone_yolo", - - ]); - - // Backend currently requires latitude/longitude form fields. We send neutral defaults (no UI, no location in outputs). - const DEFAULT_LAT = "0"; - const DEFAULT_LON = "0"; - - function isHfMode(mode) { - return !["coco", "external"].includes(mode); - } - - function isHfSpaceDetector(det) { - return HF_SPACE_DETECTORS.has(det); - } - - function isSpaceUrl(value) { - return /^https?:\/\/huggingface\.co\/spaces\//.test(String(value || "")); - } - - function isHfInferenceModel(value) { - const v = String(value || ""); - return v.includes("/") && !isSpaceUrl(v); - } - - function setHfStatus(msg) { - if (!hfBackendStatus) return; - const statusPrefix = state.hf.asyncStatus !== "idle" - ? `[${state.hf.asyncStatus.toUpperCase()}] ` - : ""; - const normalized = String(msg || "").toLowerCase(); - let display = msg; - if (normalized.includes("ready")) { - display = "MISSION PACKAGE READY"; - } else if (normalized.includes("idle")) { - display = "STANDBY"; - } else if (normalized.includes("completed")) { - display = "PROCESS COMPLETE"; - } else if (normalized.includes("processing")) { - display = "ACTIVE SCAN"; - } else if (normalized.includes("cancelled")) { - display = "STAND-DOWN"; - } else if (normalized.includes("error")) { - display = "FAULT CONDITION"; - } - hfBackendStatus.textContent = `HF Backend: ${statusPrefix}${display}`; - - // Color coding - if (msg.includes("error")) { - hfBackendStatus.style.color = "var(--bad)"; - } else if (msg.includes("ready") || msg.includes("completed")) { - hfBackendStatus.style.color = "var(--good)"; - } else { - hfBackendStatus.style.color = "var(--warn)"; - } - } - - function renderMissionContext() { - const queries = state.hf.queries || []; - if (missionClassesEl) missionClassesEl.textContent = queries.length ? queries.join(", ") : "—"; - if (missionIdEl) missionIdEl.textContent = `Mission: ${state.hf.missionId || "—"}`; - if (chipFeed) { - chipFeed.textContent = state.useProcessedFeed ? "FEED:HF" : "FEED:RAW"; - } - updateDepthChip(); - } - - function normalizeToken(s) { - return String(s || "") - .toLowerCase() - .replace(/[_\-]+/g, " ") - .replace(/[^a-z0-9\s]/g, "") - .trim(); - } - - function missionSynonyms(tokens) { - const out = new Set(); - tokens.forEach(t => { - const v = normalizeToken(t); - if (!v) return; - out.add(v); - // broad synonyms / fallbacks for common mission terms - if (v.includes("drone") || v.includes("uav") || v.includes("quad") || v.includes("small uav")) { - ["airplane", "bird", "kite"].forEach(x => out.add(x)); - } - if (v.includes("aircraft") || v.includes("fixed wing") || v.includes("jet")) { - ["airplane", "bird"].forEach(x => out.add(x)); - } - if (v.includes("boat") || v.includes("ship") || v.includes("vessel") || v.includes("usv")) { - ["boat"].forEach(x => out.add(x)); - } - if (v.includes("person") || v.includes("diver") || v.includes("swimmer")) { - ["person"].forEach(x => out.add(x)); - } - if (v.includes("vehicle") || v.includes("truck") || v.includes("car")) { - ["car", "truck"].forEach(x => out.add(x)); - } - }); - return out; - } - - function filterPredsByMission(preds) { - // Mission filtering is now handled by the backend - // Local detectors (COCO) will show all results - return preds; - } - - function isMissionFocusLabel(label) { - // Mission focus detection is now handled by the backend - // All detections from HF backend are considered mission-relevant - return false; - } - - // ========= HF Async Detection Pipeline ========= - - async function hfDetectAsync() { - const sel = getDetectorSelection(); - const detector = sel.value; - const kind = sel.kind; - const videoFile = state.videoFile; - - // Reset State & UI for new run - state.detections = []; - state.selectedId = null; - state.tracker.tracks = []; // Clear tracking state too - - // Clear cached backend results so they don't reappear - state.hf.firstFrameDetections = null; - - // Explicitly clear UI using standard renderers - renderFrameTrackList(); - renderFrameOverlay(); - // Force a clear of the radar canvas (renderFrameRadar loop will pick up empty state next frame) - if (frameRadar) { - const ctx = frameRadar.getContext("2d"); - ctx.clearRect(0, 0, frameRadar.width, frameRadar.height); - } - - // Clear counts - if (trackCount) trackCount.textContent = "0"; - if (objCount) objCount.textContent = "0"; - - // Show loading state in list manually if needed, or let renderFrameTrackList handle it (it shows "No objects tracked") - // But we want "Computing..." - if (frameTrackList) frameTrackList.innerHTML = '
Computing...
'; - - renderFeatures(null); // Clear feature panel - - if (!videoFile) { - throw new Error("No video loaded"); - } - - // Determine mode based on kind - let mode; - if (kind === "segmentation") { - mode = "segmentation"; - } else if (kind === "drone") { - mode = "drone_detection"; - } else { - mode = "object_detection"; - } - - // Use mission objective directly as detector input - const missionObjective = (missionText?.value || "").trim(); - let queries = ""; - - if (missionObjective) { - // Use mission objective text directly - let backend interpret it - queries = missionObjective; - state.hf.queries = [missionObjective]; - log(`Using mission objective: "${queries}"`); - } else { - if (mode === "drone_detection") { - // Drone mode defaults on backend; omit queries entirely. - queries = ""; - state.hf.queries = []; - log("No mission objective specified - using drone defaults"); - } else { - // No mission objective - use predefined classes - queries = DEFAULT_QUERY_CLASSES.join(", "); - state.hf.queries = DEFAULT_QUERY_CLASSES.slice(); - log("No mission objective specified - using default classes"); - } - } - - // Build FormData - const form = new FormData(); - form.append("video", videoFile); - form.append("mode", mode); - if (queries) { - form.append("queries", queries); - } - - // Add detector for object_detection mode - if (mode === "object_detection" && detector) { - form.append("detector", detector); - } - if (mode === "segmentation") { - form.append("segmenter", "sam3"); - } - // drone_detection uses drone_yolo automatically - - // Add depth_estimator parameter for depth processing - const enableDepthToggle = document.getElementById("enableDepthToggle"); - const useLegacyDepth = enableDepthToggle && enableDepthToggle.checked; - const useGPT = checkEnableGPT && checkEnableGPT.checked; - - form.append("depth_estimator", useLegacyDepth ? "depth" : ""); - form.append("enable_depth", useLegacyDepth ? "true" : "false"); - form.append("enable_gpt", useGPT ? "true" : "false"); - - // Submit async job - setHfStatus(`submitting ${mode} job...`); - log(`Submitting ${mode} to ${state.hf.baseUrl || "(same-origin)"} (detector=${detector || "n/a"})`, "t"); - const resp = await fetch(`${state.hf.baseUrl}/detect/async`, { - method: "POST", - body: form - }); - - if (!resp.ok) { - const err = await resp.json().catch(() => ({ detail: resp.statusText })); - throw new Error(err.detail || "Async detection submission failed"); - } - - const data = await resp.json(); - - // Store job info - state.hf.asyncJobId = data.job_id; - state.hf.firstFrameUrl = `${state.hf.baseUrl}${data.first_frame_url}`; - state.hf.firstFrameDetections = Array.isArray(data.first_frame_detections) - ? data.first_frame_detections - : null; - state.hf.statusUrl = `${state.hf.baseUrl}${data.status_url}`; - state.hf.videoUrl = `${state.hf.baseUrl}${data.video_url}`; - state.hf.asyncStatus = data.status; - - // Store depth URLs if provided - if (data.depth_video_url) { - state.hf.depthVideoUrl = `${state.hf.baseUrl}${data.depth_video_url}`; - log("Depth video URL received", "t"); - } - if (data.first_frame_depth_url) { - state.hf.depthFirstFrameUrl = `${state.hf.baseUrl}${data.first_frame_depth_url}`; - log("First frame depth URL received (will fetch when ready)", "t"); - } - - // Start Streaming if available - if (data.stream_url) { - log("Activating live stream...", "t"); - const streamUrl = `${state.hf.baseUrl}${data.stream_url}`; - setStreamingMode(streamUrl); - - // NOTE: Auto-switch removed to allow viewing First Frame on Tab 1 - log("Live view available in 'Engage' tab.", "g"); - setStatus("warn", "Live processing... View in Engage tab"); - - // Trigger resize/render (background setup) - resizeOverlays(); - renderRadar(); - renderTrackCards(); - } - - // Display first frame immediately (if object detection, segmentation, or drone) - if ((mode === "object_detection" || mode === "segmentation" || mode === "drone_detection") && state.hf.firstFrameUrl) { - const count = Array.isArray(data.first_frame_detections) ? data.first_frame_detections.length : null; - if (count != null) { - log(`First frame: ${count} detections`); - } else { - log("First frame ready (no detections payload)", "t"); - } - displayAsyncFirstFrame(); - - // Populate state.detections with backend results so Radar and Cards work - if (state.hf.firstFrameDetections) { - state.detections = state.hf.firstFrameDetections.map((d, i) => { - const id = `T${String(i + 1).padStart(2, '0')}`; - const [x1, y1, x2, y2] = d.bbox || [0, 0, 0, 0]; - const w = x2 - x1; - const h = y2 - y1; - const ap = defaultAimpoint(d.label); // Ensure defaultAimpoint is accessible - - return { - id, - label: d.label, - score: d.score, - bbox: { x: x1, y: y1, w: w, h: h }, - aim: { ...ap }, - features: null, - baseRange_m: d.gpt_distance_m || null, // GPT is sole source of distance - baseAreaFrac: null, - baseDwell_s: null, - reqP_kW: null, - maxP_kW: null, - pkill: null, - // GPT properties - sole source of distance estimation - gpt_distance_m: d.gpt_distance_m, - gpt_direction: d.gpt_direction, - gpt_description: d.gpt_description, - // Depth visualization only (not for distance) - depth_rel: d.depth_rel - }; - }); - // Update UI components - log(`Populating UI with ${state.detections.length} tracked objects`, "t"); - renderFrameTrackList(); - renderFrameRadar(); - renderFeatures(null); - renderTrade(); - renderFrameOverlay(); - } - } - - log(`Backend job ID: ${data.job_id} (polling every 3s)`, "t"); - setHfStatus(`job ${data.job_id.substring(0, 8)}: processing...`); - - // Start polling - await pollAsyncJob(); - } - - async function cancelBackendJob(jobId, source = "user") { - if (!jobId) return; - if ((state.hf.baseUrl || "").includes("hf.space")) { - log("Cancel request suppressed for HF Space (replica job store can return 404).", "w"); - return { status: "skipped", message: "Cancel disabled for HF Space" }; - } - - // Check if job is already in a terminal state - if (state.hf.asyncStatus === "completed" || state.hf.asyncStatus === "failed") { - log(`Backend job ${jobId.substring(0, 8)}: already ${state.hf.asyncStatus}, skipping cancel`, "t"); - return { status: state.hf.asyncStatus, message: `Job already ${state.hf.asyncStatus}` }; - } - - log(`Sending DELETE to /detect/job/${jobId.substring(0, 8)}... (${source})`, "t"); - - try { - const response = await fetch(`${state.hf.baseUrl}/detect/job/${jobId}`, { - method: "DELETE" - }); - - if (response.ok) { - const result = await response.json(); - log(`✓ Backend job ${jobId.substring(0, 8)}: ${result.message || "cancelled"} (status: ${result.status})`, "g"); - return result; - } else if (response.status === 404) { - const detail = await response.json().catch(() => ({ detail: "Job not found" })); - log(`⚠ Backend job ${jobId.substring(0, 8)}: ${detail.detail || "not found or already cleaned up"}`, "w"); - return { status: "not_found", message: detail.detail }; - } else { - const errorText = await response.text().catch(() => "Unknown error"); - log(`✗ Backend job ${jobId.substring(0, 8)}: cancel failed (${response.status}) - ${errorText}`, "e"); - return { status: "error", message: errorText }; - } - } catch (err) { - log(`✗ Backend job ${jobId.substring(0, 8)}: cancel error - ${err.message}`, "e"); - return { status: "error", message: err.message }; - } - } - - function setStreamingMode(url) { - // Ensure stream image element exists - let streamView = $("#streamView"); - if (!streamView) { - streamView = document.createElement("img"); - streamView.id = "streamView"; - streamView.style.width = "100%"; - streamView.style.height = "100%"; - streamView.style.objectFit = "contain"; - streamView.style.position = "absolute"; - streamView.style.top = "0"; - streamView.style.left = "0"; - streamView.style.zIndex = "10"; // Above video - streamView.style.backgroundColor = "#000"; - // Insert into the wrapper - // videoEngage is likely inside a container or just in the DOM - // We'll insert it as a sibling or wrapper child - if (videoEngage && videoEngage.parentNode) { - videoEngage.parentNode.appendChild(streamView); - // Ensure container is relative - if (getComputedStyle(videoEngage.parentNode).position === "static") { - videoEngage.parentNode.style.position = "relative"; - } - } - } - - if (streamView) { - streamView.src = url; - streamView.style.display = "block"; - if (videoEngage) videoEngage.style.display = "none"; - - // Also hide empty state - if (engageEmpty) engageEmpty.style.display = "none"; - } - } - - function stopStreamingMode() { - const streamView = $("#streamView"); - if (streamView) { - streamView.src = ""; // Stop connection - streamView.style.display = "none"; - } - if (videoEngage) videoEngage.style.display = "block"; - - // If no video loaded yet, might want to show empty? - // But usually we stop streaming when video IS loaded. - } - - function cancelReasoning() { - // Stop HF polling if running - if (state.hf.asyncPollInterval) { - clearInterval(state.hf.asyncPollInterval); - state.hf.asyncPollInterval = null; - log("HF polling stopped.", "w"); - } - - stopStreamingMode(); - - // Cancel backend job if it exists - const jobId = state.hf.asyncJobId; - if (jobId) { - cancelBackendJob(jobId, "cancel button"); - } - - // Reset state - state.isReasoning = false; - state.hf.busy = false; - state.hf.asyncJobId = null; - state.hf.asyncStatus = "cancelled"; - - // Re-enable Reason button - btnReason.disabled = false; - btnReason.style.opacity = "1"; - btnReason.style.cursor = "pointer"; - - // Hide Cancel button - btnCancelReason.style.display = "none"; - - setStatus("warn", "CANCELLED · Reasoning stopped"); - setHfStatus("cancelled (stopped by user)"); - log("Reasoning cancelled by user.", "w"); - } - - async function pollAsyncJob() { - const pollInterval = 3000; // 3 seconds - const maxAttempts = 200; // 10 minutes max (3s × 200) - let attempts = 0; - let fetchingVideo = false; - - return new Promise((resolve, reject) => { - state.hf.asyncPollInterval = setInterval(async () => { - attempts++; - - try { - const resp = await fetch(state.hf.statusUrl, { cache: "no-store" }); - - if (!resp.ok) { - if (resp.status === 404) { - clearInterval(state.hf.asyncPollInterval); - reject(new Error("Job expired or not found")); - return; - } - throw new Error(`Status check failed: ${resp.statusText}`); - } - - const status = await resp.json(); - state.hf.asyncStatus = status.status; - state.hf.asyncProgress = status; - - if (status.status === "completed") { - if (fetchingVideo) return; - fetchingVideo = true; - const completedJobId = state.hf.asyncJobId; - log(`✓ Backend job ${completedJobId.substring(0, 8)}: completed successfully`, "g"); - setHfStatus("job completed, fetching video..."); - try { - await fetchProcessedVideo(); - await fetchDepthVideo(); - await fetchDepthFirstFrame(); - clearInterval(state.hf.asyncPollInterval); - // Clear job ID to prevent cancel attempts after completion - state.hf.asyncJobId = null; - setHfStatus("ready"); - stopStreamingMode(); - resolve(); - } catch (err) { - if (err && err.code === "VIDEO_PENDING") { - setHfStatus("job completed, finalizing video..."); - fetchingVideo = false; - return; - } - clearInterval(state.hf.asyncPollInterval); - state.hf.asyncJobId = null; // Clear on error too - stopStreamingMode(); - reject(err); - } - } else if (status.status === "failed") { - clearInterval(state.hf.asyncPollInterval); - const errMsg = status.error || "Processing failed"; - log(`✗ Backend job ${state.hf.asyncJobId.substring(0, 8)}: failed - ${errMsg}`, "e"); - // Clear job ID to prevent cancel attempts after failure - state.hf.asyncJobId = null; - setHfStatus(`error: ${errMsg}`); - stopStreamingMode(); - reject(new Error(errMsg)); - } else { - // Still processing - setHfStatus(`job ${state.hf.asyncJobId.substring(0, 8)}: ${status.status}... (${attempts})`); - } - - if (attempts >= maxAttempts) { - clearInterval(state.hf.asyncPollInterval); - reject(new Error("Polling timeout (10 minutes)")); - } - } catch (err) { - clearInterval(state.hf.asyncPollInterval); - reject(err); - } - }, pollInterval); - }); - } - - async function fetchProcessedVideo() { - const resp = await fetch(state.hf.videoUrl, { cache: "no-store" }); - - if (!resp.ok) { - if (resp.status === 202) { - const err = new Error("Video still processing"); - err.code = "VIDEO_PENDING"; - throw err; - } - throw new Error(`Failed to fetch video: ${resp.statusText}`); - } - - const nullOrigin = (window.location && window.location.origin) === "null"; - if (nullOrigin) { - // Avoid blob:null URLs when opened via file:// - state.hf.processedBlob = null; - state.hf.processedUrl = `${state.hf.videoUrl}?t=${Date.now()}`; - btnEngage.disabled = false; - log("Processed video ready (streaming URL)"); - return; - } - - const blob = await resp.blob(); - - // Revoke old URL if exists - if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) { - URL.revokeObjectURL(state.hf.processedUrl); - } - - state.hf.processedBlob = blob; - state.hf.processedUrl = URL.createObjectURL(blob); - - btnEngage.disabled = false; - log(`Processed video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB)`); - } - - async function fetchDepthVideo() { - if (!state.hf.depthVideoUrl) { - log("No depth video URL available", "w"); - return; - } - - try { - const resp = await fetch(state.hf.depthVideoUrl, { cache: "no-store" }); - - if (!resp.ok) { - if (resp.status === 202) { - log("Depth video still processing", "w"); - return; - } - throw new Error(`Failed to fetch depth video: ${resp.statusText}`); - } - - const nullOrigin = (window.location && window.location.origin) === "null"; - if (nullOrigin) { - state.hf.depthBlob = null; - state.hf.depthVideoUrl = `${state.hf.depthVideoUrl}?t=${Date.now()}`; - log("Depth video ready (streaming URL)"); - return; - } - - const blob = await resp.blob(); - - // Store the original URL before creating blob - const originalUrl = state.hf.depthVideoUrl; - - state.hf.depthBlob = blob; - const blobUrl = URL.createObjectURL(blob); - state.hf.depthVideoUrl = blobUrl; - - log(`Depth video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB) - Click VIEW chip to toggle`, "g"); - updateDepthChip(); - } catch (err) { - log(`Error fetching depth video: ${err.message}`, "e"); - } - } - - async function fetchDepthFirstFrame() { - if (!state.hf.depthFirstFrameUrl) { - log("No depth first frame URL available", "w"); - return; - } - - try { - const resp = await fetch(state.hf.depthFirstFrameUrl, { cache: "no-store" }); - - if (!resp.ok) { - if (resp.status === 202) { - log("Depth first frame still processing", "w"); - return; - } - throw new Error(`Failed to fetch depth first frame: ${resp.statusText}`); - } - - // Fetch as blob and create blob URL - const blob = await resp.blob(); - - // Store the blob and create a blob URL - state.hf.depthFirstFrameBlob = blob; - const blobUrl = URL.createObjectURL(blob); - - // Replace the server URL with the blob URL - const originalUrl = state.hf.depthFirstFrameUrl; - state.hf.depthFirstFrameUrl = blobUrl; - - log(`✓ Depth first frame ready (${(blob.size / 1024).toFixed(1)} KB) - Click VIEW chip on Tab 1 to toggle`, "g"); - updateFirstFrameDepthChip(); - } catch (err) { - log(`Error fetching depth first frame: ${err.message}`, "e"); - } - } - - function stopAsyncPolling() { - if (state.hf.asyncPollInterval) { - clearInterval(state.hf.asyncPollInterval); - state.hf.asyncPollInterval = null; - } - } - - - async function setEngageFeed(useProcessed) { - state.useProcessedFeed = !!useProcessed; - renderMissionContext(); - - if (!state.videoLoaded) return; - - const desired = (state.useProcessedFeed && state.hf.processedUrl) ? state.hf.processedUrl : null; - if (!desired) return; - - const wasPlaying = !videoEngage.paused; - const t = videoEngage.currentTime || 0; - - try { videoEngage.pause(); } catch (_) { } - - if (videoEngage.src !== desired) { - videoEngage.src = desired; - // Mark video element to show/hide based on whether it's processed content - videoEngage.setAttribute('data-processed', state.useProcessedFeed ? 'true' : 'false'); - log(`Video feed switched to: ${state.useProcessedFeed ? 'HF processed' : 'raw'} (data-processed=${state.useProcessedFeed})`, "t"); - videoEngage.load(); - await waitVideoReady(videoEngage); - try { videoEngage.currentTime = Math.min(t, (videoEngage.duration || t)); } catch (_) { } - } - - resizeOverlays(); - if (wasPlaying) { - try { await videoEngage.play(); } catch (_) { } - } - } - - async function toggleDepthView() { - state.useDepthFeed = !state.useDepthFeed; - updateDepthChip(); - - if (!state.videoLoaded) return; - - const wasPlaying = !videoEngage.paused; - const t = videoEngage.currentTime || 0; - - try { videoEngage.pause(); } catch (_) { } - - let desiredSrc; - if (state.useDepthFeed && state.hf.depthVideoUrl) { - desiredSrc = state.hf.depthVideoUrl; - } else if (state.useProcessedFeed && state.hf.processedUrl) { - desiredSrc = state.hf.processedUrl; - } else { - desiredSrc = state.videoUrl; - } - - if (videoEngage.src !== desiredSrc) { - videoEngage.src = desiredSrc; - videoEngage.setAttribute('data-depth', state.useDepthFeed ? 'true' : 'false'); - log(`Video view switched to: ${state.useDepthFeed ? 'depth' : 'default'}`, "t"); - videoEngage.load(); - await waitVideoReady(videoEngage); - try { videoEngage.currentTime = Math.min(t, (videoEngage.duration || t)); } catch (_) { } - } - - resizeOverlays(); - if (wasPlaying) { - try { await videoEngage.play(); } catch (_) { } - } - } - - function updateDepthChip() { - if (chipDepth) { - chipDepth.textContent = state.useDepthFeed ? "VIEW:DEPTH" : "VIEW:DEFAULT"; - } - } - - function toggleFirstFrameDepthView() { - state.useFrameDepthView = !state.useFrameDepthView; - updateFirstFrameDepthChip(); - displayFirstFrameWithDepth(); - } - - function updateFirstFrameDepthChip() { - if (chipFrameDepth) { - chipFrameDepth.textContent = state.useFrameDepthView ? "VIEW:DEPTH" : "VIEW:DEFAULT"; - } - } - - function displayFirstFrameWithDepth() { - // Determine which URL to use based on state - let frameUrl; - if (state.useFrameDepthView && state.hf.depthFirstFrameUrl) { - // Check if we have a blob URL (starts with 'blob:') - if (state.hf.depthFirstFrameUrl.startsWith('blob:')) { - frameUrl = state.hf.depthFirstFrameUrl; - } else { - log("Depth first frame not ready yet. Please wait for processing to complete.", "w"); - state.useFrameDepthView = false; // Revert to default view - updateFirstFrameDepthChip(); - frameUrl = state.hf.firstFrameUrl; - } - } else if (state.hf.firstFrameUrl) { - frameUrl = state.hf.firstFrameUrl; - } else { - log("No first frame URL available", "w"); - return; - } - - if (!frameUrl) { - log("No valid frame URL to display", "w"); - return; - } - - log(`Displaying ${state.useFrameDepthView ? 'depth' : 'default'} first frame`, "t"); - - // Load and display the frame - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = frameUrl; - - img.onload = () => { - frameCanvas.width = img.width; - frameCanvas.height = img.height; - frameOverlay.width = img.width; - frameOverlay.height = img.height; - - const ctx = frameCanvas.getContext("2d"); - ctx.clearRect(0, 0, img.width, img.height); - ctx.drawImage(img, 0, 0); - - frameEmpty.style.display = "none"; - log(`✓ ${state.useFrameDepthView ? 'Depth' : 'Default'} first frame displayed (${img.width}×${img.height})`, "g"); - }; - - img.onerror = (err) => { - console.error(`Failed to load ${state.useFrameDepthView ? 'depth' : 'default'} first frame:`, err); - log(`✗ ${state.useFrameDepthView ? 'Depth' : 'Default'} first frame load failed - reverting to default view`, "e"); - - // If depth frame fails, revert to default - if (state.useFrameDepthView) { - state.useFrameDepthView = false; - updateFirstFrameDepthChip(); - displayFirstFrameWithDepth(); // Retry with default view - } - }; - } - - async function startHfPipeline() { - if (state.hf.busy) { - log("HF pipeline already running"); - return; - } - - const { kind } = getDetectorSelection(); - - // Only process if using HF detectors (not local/external) - if (["local", "external"].includes(kind)) { - log("Skipping HF pipeline (not using HF detector)"); - return; - } - - state.hf.busy = true; - state.hf.lastError = null; - - // Background processing (non-blocking) - (async () => { - try { - // Run async detection (mission text will be used directly as queries if no manual labels provided) - await hfDetectAsync(); - - // Auto-switch to processed feed when ready - if (state.hf.processedUrl && !state.useProcessedFeed) { - log("Auto-switching to HF processed video feed (segmentation/detection overlays)", "g"); - await setEngageFeed(true); - } - - } catch (err) { - console.error("HF pipeline error:", err); - state.hf.lastError = err.message; - setHfStatus(`error: ${err.message}`); - log(`⚠ HF error: ${err.message}`); - } finally { - state.hf.busy = false; - } - })(); - } - - - detectorSelect.addEventListener("change", () => { - const sel = getDetectorSelection(); - state.detector.mode = sel.value; - state.detector.kind = sel.kind; - state.hf.detector = sel.value; - // local detector is still used for aimpoint math + tracking; HF Space provides mission-driven video detection. - ensureCocoDetector(); - renderMissionContext(); - log(`Detector mode set to: ${state.detector.mode}`, "t"); - }); - - trackerSelect.addEventListener("change", () => { - state.tracker.mode = trackerSelect.value; - log(`Tracker mode set to: ${state.tracker.mode}`, "t"); - }); - - async function ensureCocoDetector() { - if (state.detector.loaded || state.detector.loading) return; - if (state.detector.mode !== "coco") return; - - state.detector.loading = true; - setStatus("warn", "LOADING · Detector model"); - log("Loading COCO-SSD detector (browser model).", "t"); - - try { - await loadScriptOnce("tfjs", "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.16.0/dist/tf.min.js"); - await loadScriptOnce("coco-ssd", "https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@2.2.2/dist/coco-ssd.min.js"); - if (!window.cocoSsd || !window.tf) throw new Error("TF.js or cocoSsd not available."); - state.detector.model = await window.cocoSsd.load(); - state.detector.loaded = true; - log("COCO-SSD detector loaded.", "g"); - setStatus(state.videoLoaded ? "warn" : "warn", state.videoLoaded ? "READY · Video loaded (run Reason)" : "STANDBY · No video loaded"); - } catch (err) { - log(`Detector load failed: ${err.message}. Switch to External detector or use HF models.`, "w"); - setStatus("warn", "READY · Detector not loaded (use External or HF)"); - state.detector.loaded = false; - state.detector.model = null; - } finally { - state.detector.loading = false; - } - } - - const loadedScripts = new Map(); - function loadScriptOnce(key, src) { - return new Promise((resolve, reject) => { - if (loadedScripts.get(key) === "loaded") { resolve(); return; } - if (loadedScripts.get(key) === "loading") { - const iv = setInterval(() => { - if (loadedScripts.get(key) === "loaded") { clearInterval(iv); resolve(); } - if (loadedScripts.get(key) === "failed") { clearInterval(iv); reject(new Error("Script failed earlier")); } - }, 50); - return; - } - - loadedScripts.set(key, "loading"); - const s = document.createElement("script"); - s.src = src; - s.async = true; - s.onload = () => { loadedScripts.set(key, "loaded"); resolve(); }; - s.onerror = () => { loadedScripts.set(key, "failed"); reject(new Error(`Failed to load ${src}`)); }; - document.head.appendChild(s); - }); - } - - // Start loading detector opportunistically if selected. - ensureCocoDetector(); - - // ========= Core physics-lite model ========= - function getKnobs() { - const PkW = +helPower.value; - const aperture = +helAperture.value; - const M2 = +helM2.value; - const jitter_urad = +helJitter.value; - const duty = (+helDuty.value) / 100; - const mode = helMode.value; - - const vis_km = +atmVis.value; - const cn2 = +atmCn2.value; - const spray = +seaSpray.value; - const ao = +aoQ.value; - - const baseRange = +rangeBase.value; - - return { PkW, aperture, M2, jitter_urad, duty, mode, vis_km, cn2, spray, ao, baseRange }; - } - - // ========= External Hooks (API Integration Points) ========= - - /** - * Hook: Object Detection - * @param {Object} input { canvas, width, height } - * @returns {Promise} [{ bbox:[x,y,w,h], class:"label", score:0.95 }, ...] - */ - async function externalDetect(input) { - // TODO: Call your object detection endpoint here - console.log("externalDetect called", input); - return []; - } - - /** - * Hook: Feature Extraction - * @param {Array} detections Array of detection objects - * @param {Object} frameInfo { width, height } - * @returns {Promise} Map of { "id": { reflectivity:0.5, ... } } - */ - async function externalFeatures(detections, frameInfo) { - // TODO: Call your feature extraction endpoint here - console.log("externalFeatures called for", detections.length, "objects"); - return {}; - } - - /** - * Hook: HEL synthesis - * @param {Array} detections Array of detection objects - * @param {Object} knobs HEL/atmosphere knobs - * @returns {Promise} { targets: {id: {...}}, system: {...} } - */ - async function externalHEL(detections, knobs) { - // TODO: Call your HEL model/service here - console.log("externalHEL called for", detections.length, "objects", knobs); - // Return minimal structure to keep UI running - return { - targets: {}, - system: { maxP_kW: 0, reqP_kW: 0, margin_kW: 0, medianRange_m: 0 } - }; - } - - /** - * Hook: External Tracker - * @param {HTMLVideoElement} videoEl - * @returns {Promise} [{ bbox:[x,y,w,h], class:"label", score:0.95 }, ...] - */ - async function externalTrack(videoEl) { - // TODO: Call your external tracker / vision system here - console.log("externalTrack called"); - return []; - } - - /** - * Hook: Mission Intel Summary - * @param {Array} frames Array of dataURLs or canvases - * @returns {Promise} Summary text - */ - async function externalIntel(frames) { - // TODO: Call your VLM / Intel summary endpoint here - console.log("externalIntel called with", frames.length, "frames"); - return "Video processed. No external intel provider connected."; - } - - // ========= Core Physics & Logic Adapters ========= - function getKnobs() { - const PkW = +helPower.value; - const aperture = +helAperture.value; - const M2 = +helM2.value; - const jitter_urad = +helJitter.value; - const duty = (+helDuty.value) / 100; - const mode = helMode.value; - const vis_km = +atmVis.value; - const cn2 = +atmCn2.value; - const spray = +seaSpray.value; - const ao = +aoQ.value; - const baseRange = +rangeBase.value; - return { PkW, aperture, M2, jitter_urad, duty, mode, vis_km, cn2, spray, ao, baseRange }; - } - - // ========= Safe Stubs for Client-Side Visualization (Tab 2 / Tab 3) ========= - // These functions were removed to allow backend control, but are mocked here - // to prevent UI crashes in the Engagement/Trade tabs until you wire them up. - - function maxPowerAtTarget(range_m) { - // Placeholder: return 0 or simple fallback - return { Ptar: 0, Pout: 0, trans: 0, turb: 0, beam: 0 }; - } - - function requiredPowerFromFeatures(feat) { return 10; } // Safe default - - function requiredDwell(range_m, reqP, maxP, baseDwell) { return 1.0; } // Safe default - - function pkillFromMargin(margin_kW, dwell_s, reqDwell_s) { return 0; } - - - - // ========= Aimpoint rules (default) ========= - function defaultAimpoint(label) { - const l = (label || "object").toLowerCase(); - if (l.includes("airplane") || l.includes("drone") || l.includes("uav") || l.includes("kite") || l.includes("bird")) { - return { relx: 0.62, rely: 0.55, label: "engine" }; - } - if (l.includes("helicopter")) { - return { relx: 0.50, rely: 0.45, label: "rotor_hub" }; - } - if (l.includes("boat") || l.includes("ship")) { - return { relx: 0.60, rely: 0.55, label: "bridge/engine" }; - } - if (l.includes("truck") || l.includes("car")) { - return { relx: 0.55, rely: 0.62, label: "engine_block" }; - } - return { relx: 0.50, rely: 0.55, label: "center_mass" }; - } - - // ========= Feature generation (hookable) ========= - // (Merged into externalFeatures above) - - - // [Deleted] synthFeatures, hashString, mulberry32, pick - - - // ========= Detector hook ========= - // ========= Detector hook ========= - // (This block is merged into externalDetect above, removing old declaration) - - - function canvasToBlob(canvas, quality = 0.88) { - return new Promise((resolve, reject) => { - if (!canvas.toBlob) { reject(new Error("Canvas.toBlob not supported")); return; } - canvas.toBlob(blob => { - if (!blob) { reject(new Error("Canvas toBlob failed")); return; } - resolve(blob); - }, "image/jpeg", quality); - }); - } - - async function callHfObjectDetection(modelId, canvas) { - const proxyBase = (API_CONFIG.PROXY_URL || "").trim(); - if (proxyBase) { - const blob = await canvasToBlob(canvas); - const form = new FormData(); - form.append("model", modelId); - form.append("image", blob, "frame.jpg"); - const resp = await fetch(`${proxyBase.replace(/\/$/, "")}/detect`, { - method: "POST", - body: form - }); - if (!resp.ok) { - let detail = `Proxy inference failed (${resp.status})`; - try { - const err = await resp.json(); - detail = err.detail || err.error || detail; - } catch (_) { } - throw new Error(detail); - } - const payload = await resp.json(); - if (!Array.isArray(payload)) throw new Error("Unexpected proxy response format."); - return payload; - } - - const token = API_CONFIG.HF_TOKEN; - if (!token) throw new Error("HF token missing (config.js)."); - - const blob = await canvasToBlob(canvas); - const base = (API_CONFIG.HF_INFERENCE_BASE || "https://router.huggingface.co/hf-inference/models").replace(/\/$/, ""); - const resp = await fetch(`${base}/${modelId}`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: blob - }); - if (!resp.ok) { - let detail = `HF inference failed (${resp.status})`; - try { - const err = await resp.json(); - detail = err.error || err.detail || detail; - } catch (_) { } - throw new Error(detail); - } - const payload = await resp.json(); - if (!Array.isArray(payload)) throw new Error("Unexpected HF response format."); - return payload.map(p => { - const b = p.box || p.bbox || p.bounding_box || {}; - const xmin = b.xmin ?? b.x ?? 0; - const ymin = b.ymin ?? b.y ?? 0; - const xmax = b.xmax ?? (b.x + (b.w || 0)) ?? 0; - const ymax = b.ymax ?? (b.y + (b.h || 0)) ?? 0; - return { - bbox: [xmin, ymin, Math.max(1, xmax - xmin), Math.max(1, ymax - ymin)], - class: p.label || p.class || "object", - score: p.score ?? p.confidence ?? 0 - }; - }); - } - - async function detectWithCoco(inputForModel, applyMissionFilter) { - await ensureCocoDetector(); - if (!state.detector.model) { - log("Detector model not available in this browser. Switch to External detector or use HF models.", "w"); - return []; - } - let preds = await state.detector.model.detect(inputForModel); - if (applyMissionFilter) preds = filterPredsByMission(preds); - - const filtered = preds - .filter(p => p.score >= 0.45) - .slice(0, 14) - .map(p => ({ bbox: p.bbox, class: p.class, score: p.score })); - - if (!filtered.length) { - log("Detector returned no confident objects for this frame.", "w"); - } - return filtered; - } - - async function waitForBackendDetections(timeoutMs = 2000) { - const start = Date.now(); - while ((Date.now() - start) < timeoutMs) { - if (Array.isArray(state.hf.firstFrameDetections)) { - return state.hf.firstFrameDetections; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - return null; - } - - async function runDetectOnFrame() { - const w = state.frame.w, h = state.frame.h; - const inputForModel = frameCanvas; // canvas contains the first frame - const sel = getDetectorSelection(); - const mode = sel.value; - const kind = sel.kind; - - if (mode === "coco") { - return await detectWithCoco(inputForModel, false); - } - - if (mode === "external") { - try { - const res = await externalDetect({ canvas: frameCanvas, width: w, height: h }); - if (Array.isArray(res)) return res; - log("External detector returned invalid response.", "w"); - return []; - } catch (err) { - log(`External detector failed: ${err.message}`, "w"); - return []; - } - } - - if (kind === "segmentation") { - // For segmentation, we don't have instant local inference - // User needs to process full video via HF async endpoint - log("Segmentation requires full video processing via HF backend"); - return []; - } - - if (kind === "drone") { - const backendDets = await waitForBackendDetections(); - if (Array.isArray(backendDets) && backendDets.length) { - return backendDets.map(d => { - const bbox = Array.isArray(d.bbox) ? d.bbox : [0, 0, 1, 1]; - const x1 = bbox[0] || 0; - const y1 = bbox[1] || 0; - const x2 = bbox[2] || 0; - const y2 = bbox[3] || 0; - const depthRel = Number.isFinite(d.depth_rel) ? d.depth_rel : null; - return { - bbox: [x1, y1, Math.max(1, x2 - x1), Math.max(1, y2 - y1)], - class: d.label || "drone", - score: d.score ?? 0, - depth_rel: depthRel // Visualization only, GPT handles distance - }; - }); - } - // Same for drone detection - log("Drone detection requires full video processing via HF backend"); - return []; - } - - if (kind === "object") { - // HF object detection models - if (["hf_yolov8", "detr_resnet50", "grounding_dino"].includes(mode)) { - const backendDets = await waitForBackendDetections(); - if (Array.isArray(backendDets) && backendDets.length) { - return backendDets.map(d => { - const bbox = Array.isArray(d.bbox) ? d.bbox : [0, 0, 1, 1]; - const x1 = bbox[0] || 0; - const y1 = bbox[1] || 0; - const x2 = bbox[2] || 0; - const y2 = bbox[3] || 0; - const depthRel = Number.isFinite(d.depth_rel) ? d.depth_rel : null; - return { - bbox: [x1, y1, Math.max(1, x2 - x1), Math.max(1, y2 - y1)], - class: d.label || "object", - score: d.score ?? 0, - depth_rel: depthRel // Visualization only, GPT handles distance - }; - }); - } - // For first-frame detection, we can show a placeholder or skip - // The actual detections come from the async endpoint - log(`${mode} requires backend async processing`); - return []; - } else { - // Fallback to COCO if unknown - return await detectWithCoco(inputForModel, false); - } - } - - return []; - } - - - // ========= Render first frame ======== - function drawFirstFrame() { - const ctx = frameCanvas.getContext("2d"); - const w = state.frame.w, h = state.frame.h; - frameCanvas.width = w; frameCanvas.height = h; - frameOverlay.width = w; frameOverlay.height = h; - - ctx.clearRect(0, 0, w, h); - - // Check if we have HF processed first frame (segmentation or object detection with overlays) - if (state.hf.firstFrameUrl) { - // HF backend will draw the processed frame via displayAsyncFirstFrame() - // Don't draw dark background - let the processed image show through - log("Waiting for HF processed first frame to display...", "t"); - return; - } - - // For local detection: show dark background, no original frame - ctx.fillStyle = "#0b1026"; - ctx.fillRect(0, 0, w, h); - - if (!state.frame.bitmap) { - ctx.fillStyle = "rgba(255,255,255,.65)"; - ctx.font = "16px " + getComputedStyle(document.body).fontFamily; - ctx.fillText("No frame available", 18, 28); - return; - } - - // Original frame bitmap is NOT drawn for local detection - only processed results will be displayed - // ctx.drawImage(state.frame.bitmap, 0, 0, w, h); - } - - // ========= Agent cursor (optional, purely visual) ========= - function ensureAgentCursorOverlay() { - if ($("#agentCursor")) return; - const el = document.createElement("div"); - el.id = "agentCursor"; - el.style.position = "fixed"; - el.style.zIndex = "9999"; - el.style.width = "14px"; - el.style.height = "14px"; - el.style.borderRadius = "999px"; - el.style.pointerEvents = "none"; - el.style.background = "radial-gradient(circle at 30% 30%, rgba(34,211,238,.95), rgba(124,58,237,.65))"; - el.style.boxShadow = "0 0 18px rgba(34,211,238,.55), 0 0 46px rgba(124,58,237,.25)"; - el.style.border = "1px solid rgba(255,255,255,.25)"; - el.style.opacity = "0"; - document.body.appendChild(el); - } - - function setCursorVisible(v) { - ensureAgentCursorOverlay(); - const el = $("#agentCursor"); - el.style.opacity = v ? "1" : "0"; - state.ui.agentCursor.visible = v; - } - - function moveCursorToRect(rect, mode = "glide") { - state.ui.agentCursor.target = rect; - state.ui.agentCursor.mode = mode; - state.ui.agentCursor.t0 = now(); - setCursorVisible(state.ui.cursorMode === "on"); - } - - function tickAgentCursor() { - const el = $("#agentCursor"); - if (!el || state.ui.cursorMode !== "on" || !state.ui.agentCursor.visible) return; - const c = state.ui.agentCursor; - if (!c.target) return; - - const tx = c.target.left + c.target.width * 0.72; - const ty = c.target.top + c.target.height * 0.50; - - // smooth spring - const ease = 0.12; - const dx = tx - (c.x * window.innerWidth); - const dy = ty - (c.y * window.innerHeight); - c.vx = (c.vx + dx * 0.0018) * 0.85; - c.vy = (c.vy + dy * 0.0018) * 0.85; - - const px = (c.x * window.innerWidth) + c.vx * 18; - const py = (c.y * window.innerHeight) + c.vy * 18; - c.x = clamp(px / window.innerWidth, 0.02, 0.98); - c.y = clamp(py / window.innerHeight, 0.02, 0.98); - - el.style.transform = `translate(${c.x * window.innerWidth}px, ${c.y * window.innerHeight}px)`; - - // hide after settle - const settle = Math.hypot(dx, dy); - if (settle < 6 && (now() - c.t0) > 650) { - // keep visible but soften - el.style.opacity = "0.75"; - } - } - - cursorMode.addEventListener("change", () => { - state.ui.cursorMode = cursorMode.value; - if (state.ui.cursorMode === "off") setCursorVisible(false); - }); - - // ========= Reason pipeline (Tab 1) ========= - btnReason.addEventListener("click", async () => { - if (!state.videoLoaded) { - log("No video loaded. Upload a video first.", "w"); - setStatus("warn", "READY · Upload a video"); - return; - } - - // Prevent concurrent executions - if (state.isReasoning) { - log("Reason already in progress. Please wait for it to complete.", "w"); - return; - } - - // Lock the Reason process - state.isReasoning = true; - btnReason.disabled = true; - btnReason.style.opacity = "0.5"; - btnReason.style.cursor = "not-allowed"; - - // Show Cancel button - btnCancelReason.style.display = "inline-block"; - - // Reset previous processed video output before new run - if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) { - try { URL.revokeObjectURL(state.hf.processedUrl); } catch (_) { } - } - state.hf.processedUrl = null; - state.hf.processedBlob = null; - state.useProcessedFeed = false; - btnEngage.disabled = true; - videoEngage.removeAttribute("src"); - videoEngage.load(); - - // Clear previous detections before running new detection - state.detections = []; - state.selectedId = null; - renderFrameTrackList(); - renderFrameOverlay(); - // renderSummary(); // Removed - renderFeatures(null); - renderTrade(); - - setStatus("warn", "REASONING · Running perception pipeline"); - // Start mission-driven HF backend (planning → video detection) in parallel. - startHfPipeline(); - log("Reason started: detection → features → HEL synthesis.", "t"); - - // a little agent cursor flair - if (state.ui.cursorMode === "on") { - moveCursorToRect(btnReason.getBoundingClientRect()); - setTimeout(() => moveCursorToRect(frameCanvas.getBoundingClientRect()), 260); - setTimeout(() => moveCursorToRect(frameTrackList.getBoundingClientRect()), 560); - // setTimeout(() => moveCursorToRect(summaryTable.getBoundingClientRect()), 880); - } - - try { - // Mission objective is optional: - // - If blank: run unbiased detection across all classes immediately (no server wait). - // - If provided: still show immediate first-frame results, while HF computes mission focus in the background. - const missionPromptRaw = (missionText?.value || "").trim(); - if (!missionPromptRaw) { - state.hf.plan = null; - state.hf.missionId = null; - renderMissionContext(); - setHfStatus("processing (all objects, background)…"); - } else { - // Mission objective will be used directly by the detector - setHfStatus("processing (mission-focused, background)…"); - } - - - await captureFirstFrame(); - drawFirstFrame(); - - const dets = await runDetectOnFrame(); - - state.detections = dets.map((d, i) => { - const id = `T${String(i + 1).padStart(2, "0")}`; - const ap = defaultAimpoint(d.class); - return { - id, - label: d.class, - score: d.score, - bbox: normBBox(d.bbox, state.frame.w, state.frame.h), - aim: { ...ap }, // rel inside bbox - features: null, - baseRange_m: null, - baseAreaFrac: null, - baseDwell_s: null, - reqP_kW: null, - maxP_kW: null, - pkill: null, - // Depth visualization only - depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null, - // Bind GPT reasoning fields from backend - gpt_distance_m: d.gpt_distance_m || null, - gpt_direction: d.gpt_direction || null, - gpt_description: d.gpt_description || null - }; - }); - - // range estimate calibration - // [Deleted] calibrateRanges(); - - // feature generation - const featureMap = await externalFeatures(state.detections, { width: state.frame.w, height: state.frame.h }); - if (featureMap) { - state.detections.forEach(d => { - const f = featureMap[d.id] || featureMap[d.label] || null; - if (f) d.features = f; - }); - log("Features populated from external hook.", "g"); - } else { - // Fallback if no external features: empty object - state.detections.forEach(d => d.features = {}); - log("No external features provided.", "t"); - } - - // If external features provide aimpoint label, align aimpoint marker - state.detections.forEach(d => { - if (d.features && d.features.aimpoint_label) { - const apLabel = String(d.features.aimpoint_label); - d.aim.label = apLabel; - // keep rel location but slightly adjust by label type - const ap = aimpointByLabel(apLabel); - d.aim.relx = ap.relx; - d.aim.rely = ap.rely; - } - }); - - // compute HEL synthesis (now async) - await recomputeHEL(); - - // pick default selection - state.selectedId = state.detections[0]?.id || null; - renderFrameTrackList(); - renderFrameOverlay(); - // renderSummary(); // Removed - renderFeatures(getSelected()); - renderTrade(); - - state.hasReasoned = true; - setStatus("good", "READY · Reason complete (you can Engage)"); - log("Reason complete.", "g"); - - // Pre-seed tracks for Tab 2 so radar shows targets immediately - seedTracksFromTab1(); - renderRadar(); - - // Generate intel summary (async) - computeIntelSummary(); - } catch (err) { - setStatus("bad", "ERROR · Reason failed"); - log(`Reason failed: ${err.message}`, "e"); - console.error(err); - } finally { - // Always unlock the Reason process - state.isReasoning = false; - btnReason.disabled = false; - btnReason.style.opacity = "1"; - btnReason.style.cursor = "pointer"; - - // Hide Cancel button - btnCancelReason.style.display = "none"; - } - }); - - // Cancel button handler - btnCancelReason.addEventListener("click", () => { - cancelReasoning(); - }); - - btnRecompute.addEventListener("click", () => { - if (!state.hasReasoned) return; - recomputeHEL(); - // renderSummary(); - renderFrameOverlay(); - renderTrade(); - log("Recomputed HEL metrics using current knobs (no new detection).", "t"); - }); - - btnClear.addEventListener("click", () => { - state.detections = []; - state.selectedId = null; - state.hasReasoned = false; - state.isReasoning = false; // Reset reasoning lock - btnReason.disabled = false; // Re-enable button if it was locked - btnReason.style.opacity = "1"; - btnReason.style.cursor = "pointer"; - btnCancelReason.style.display = "none"; // Hide Cancel button - renderFrameTrackList(); - renderFrameOverlay(); - // renderSummary(); - renderFeatures(null); - renderTrade(); - log("Cleared Tab 1 outputs.", "w"); - setStatus("warn", state.videoLoaded ? "READY · Video loaded (run Reason)" : "STANDBY · No video loaded"); - }); - - function aimpointByLabel(label) { - const l = String(label || "").toLowerCase(); - if (l.includes("engine") || l.includes("fuel")) return { relx: 0.64, rely: 0.58, label: label }; - if (l.includes("wing")) return { relx: 0.42, rely: 0.52, label: label }; - if (l.includes("nose") || l.includes("sensor")) return { relx: 0.28, rely: 0.48, label: label }; - if (l.includes("rotor")) return { relx: 0.52, rely: 0.42, label: label }; - return { relx: 0.50, rely: 0.55, label: label || "center_mass" }; - } - - function normBBox(bbox, w, h) { - const [x, y, bw, bh] = bbox; - return { - x: clamp(x, 0, w - 1), - y: clamp(y, 0, h - 1), - w: clamp(bw, 1, w), - h: clamp(bh, 1, h) - }; - } - - // [Deleted] calibrateRanges - - - async function recomputeHEL() { - if (!state.detections.length) return; - const knobs = getKnobs(); - // summaryStamp.textContent = "Computing..."; - - try { - const result = await externalHEL(state.detections, knobs); - const metrics = result.targets || {}; - const sys = result.system || {}; - - state.detections.forEach(d => { - const r = metrics[d.id] || {}; - d.maxP_kW = r.maxP || 0; - d.reqP_kW = r.reqP || 0; - d.baseDwell_s = r.dwell || 0; - d.pkill = r.pkill || 0; - }); - - // Update system headline stats - mMaxP.textContent = sys.maxP ? `${sys.maxP} kW` : "—"; - mReqP.textContent = sys.reqP ? `${sys.reqP} kW` : "—"; - const margin = sys.margin || 0; - mMargin.textContent = `${margin > 0 ? "+" : ""}${margin} kW`; - mMargin.style.color = margin >= 0 ? "rgba(34,197,94,.95)" : "rgba(239,68,68,.95)"; - - mMaxPSub.textContent = "Calculated by external HEL engine"; - - // Simple ranking for plan - const ranked = state.detections.slice().sort((a, b) => (b.pkill || 0) - (a.pkill || 0)); - if (ranked.length && ranked[0].pkill > 0) { - mPlan.textContent = `${ranked[0].id} → Engage`; - mPlanSub.textContent = "Highest P(kill) target"; - } else { - mPlan.textContent = "—"; - mPlanSub.textContent = "No viable targets"; - } - - } catch (err) { - console.error("HEL recompute failed", err); - } - - // summaryStamp.textContent = new Date().toLocaleTimeString(); - // renderSummary(); - refreshTradeTargets(); - } - - function getSelected() { - return state.detections.find(d => d.id === state.selectedId) || null; - } - - // ========= Rendering: Object list, features, summary table ========= - // ========= Track Cards & Interaction ========= - function selectObject(id) { - state.selectedId = id; - - // Highlight Card - $$(".track-card").forEach(el => el.classList.remove("active")); - const card = document.getElementById("card-" + id); - if (card) { - // card.scrollIntoView({ behavior: "smooth", block: "nearest" }); - card.classList.add("active"); - } - - // Highlight BBox (via Overlay) - renderFrameOverlay(); - // Highlight Radar uses state.selectedId, loops automatically - } - - function renderFrameTrackList() { - if (!frameTrackList || !trackCount) return; - frameTrackList.innerHTML = ""; - - const dets = state.detections || []; - trackCount.textContent = dets.length; - - if (dets.length === 0) { - frameTrackList.innerHTML = '
No objects tracked.
'; - return; - } - - dets.forEach((det, i) => { - // ID: T01, T02... - // Ensure ID exists - const id = det.id || `T${String(i + 1).padStart(2, '0')}`; - - // Resolve Range/Bearing - let rangeStr = "---"; - let bearingStr = "---"; - - if (det.gpt_distance_m) { - rangeStr = `${det.gpt_distance_m}m (GPT)`; - } - // No depth_est_m fallback - GPT is the sole source of distance - - if (det.gpt_direction) { - bearingStr = det.gpt_direction; - } - - const card = document.createElement("div"); - card.className = "track-card"; - if (state.selectedId === id) card.classList.add("active"); - card.id = `card-${id}`; - card.onclick = () => selectObject(id); - - const desc = det.gpt_description - ? `
${det.gpt_description}
` - : ""; // No description, hide body - - const gptBadge = (det.gpt_distance_m || det.gpt_description) - ? `GPT` - : ""; - - card.innerHTML = ` -
- ${id} · ${det.label} - ${(det.score * 100).toFixed(0)}% -
-
- RANGE: ${rangeStr} | BEARING: ${bearingStr} -
- ${desc} - `; - frameTrackList.appendChild(card); - }); - } - - function renderFeatures(det) { - selId.textContent = det ? det.id : "—"; - const tbody = featureTable.querySelector("tbody"); - tbody.innerHTML = ""; - if (!det) { - tbody.innerHTML = `—No target selected`; - return; - } - const feats = det.features || {}; - const keys = Object.keys(feats); - const show = keys.slice(0, 12); - - show.forEach(k => { - const tr = document.createElement("tr"); - tr.innerHTML = `${escapeHtml(k)}${escapeHtml(String(feats[k]))}`; - tbody.appendChild(tr); - }); - - if (show.length < 10) { - for (let i = show.length; i < 10; i++) { - const tr = document.createElement("tr"); - tr.innerHTML = `—awaiting additional expert outputs`; - tbody.appendChild(tr); - } - } - } - - // renderSummary removed - - function escapeHtml(s) { - return s.replace(/[&<>"']/g, m => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m])); - } - - // ========= Frame overlay rendering ========= - function renderFrameOverlay() { - const ctx = frameOverlay.getContext("2d"); - const w = frameOverlay.width, h = frameOverlay.height; - ctx.clearRect(0, 0, w, h); - - if (!state.detections.length) return; - - // subtle scanning effect - const t = now() / 1000; - const scanX = (Math.sin(t * 0.65) * 0.5 + 0.5) * w; - ctx.fillStyle = "rgba(34,211,238,.06)"; - ctx.fillRect(scanX - 8, 0, 16, h); - - state.detections.forEach((d, idx) => { - const isSel = d.id === state.selectedId; - - const b = d.bbox; - const pad = 2; - - // box - ctx.lineWidth = isSel ? 3 : 2; - const isFocus = isMissionFocusLabel(d.label); - ctx.strokeStyle = isSel ? "rgba(34,211,238,.95)" : (isFocus ? "rgba(34,211,238,.70)" : "rgba(124,58,237,.55)"); - ctx.shadowColor = isSel ? "rgba(34,211,238,.40)" : "rgba(124,58,237,.25)"; - ctx.shadowBlur = isSel ? 18 : 10; - roundRect(ctx, b.x, b.y, b.w, b.h, 10, false, true); - - // pseudo mask glow (for segmentation-like effect) - ctx.shadowBlur = 0; - 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); - g.addColorStop(0, isSel ? "rgba(34,211,238,.16)" : "rgba(124,58,237,.10)"); - g.addColorStop(1, "rgba(0,0,0,0)"); - ctx.fillStyle = g; - ctx.fillRect(b.x, b.y, b.w, b.h); - - // aimpoint marker (red circle + crosshair) - const ax = b.x + b.w * d.aim.relx; - const ay = b.y + b.h * d.aim.rely; - drawAimpoint(ctx, ax, ay, isSel); - - // no text overlay on first-frame view - }); - - // click-to-select on canvas (manual aimpoint override can be added later) - frameOverlay.style.pointerEvents = "auto"; - frameOverlay.onclick = (ev) => { - const rect = frameOverlay.getBoundingClientRect(); - const sx = frameOverlay.width / rect.width; - const sy = frameOverlay.height / rect.height; - const x = (ev.clientX - rect.left) * sx; - const y = (ev.clientY - rect.top) * sy; - - const hit = state.detections - .map(d => ({ d, inside: x >= d.bbox.x && x <= d.bbox.x + d.bbox.w && y >= d.bbox.y && y <= d.bbox.y + d.bbox.h })) - .filter(o => o.inside) - .sort((a, b) => (a.d.bbox.w * a.d.bbox.h) - (b.d.bbox.w * b.d.bbox.h))[0]; - - if (hit) { - selectObject(hit.d.id); - renderFeatures(hit.d); - renderTrade(); - } - }; - } - - function roundRect(ctx, x, y, w, h, r, fill, stroke) { - if (w < 2 * r) r = w / 2; - if (h < 2 * r) r = h / 2; - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); - ctx.closePath(); - if (fill) ctx.fill(); - if (stroke) ctx.stroke(); - } - - function drawAimpoint(ctx, x, y, isSel) { - ctx.save(); - ctx.shadowBlur = isSel ? 18 : 12; - ctx.shadowColor = "rgba(239,68,68,.45)"; - ctx.strokeStyle = "rgba(239,68,68,.95)"; - ctx.lineWidth = isSel ? 3 : 2; - ctx.beginPath(); - ctx.arc(x, y, isSel ? 10 : 9, 0, Math.PI * 2); - ctx.stroke(); - - ctx.shadowBlur = 0; - ctx.strokeStyle = "rgba(255,255,255,.70)"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(x - 14, y); ctx.lineTo(x - 4, y); - ctx.moveTo(x + 4, y); ctx.lineTo(x + 14, y); - ctx.moveTo(x, y - 14); ctx.lineTo(x, y - 4); - ctx.moveTo(x, y + 4); ctx.lineTo(x, y + 14); - ctx.stroke(); - - ctx.fillStyle = "rgba(239,68,68,.95)"; - ctx.beginPath(); - ctx.arc(x, y, 2.5, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - // ========= Engage tab: tracking + dynamic dwell ========= - btnEngage.addEventListener("click", async () => { - if (!state.videoLoaded) { log("No video loaded for Engage.", "w"); return; } - if (!state.hf.processedUrl) { log("Processed video not ready yet. Wait for completion.", "w"); return; } - if (!state.hasReasoned) { log("Run Reason first to initialize baseline dwell and aimpoints.", "w"); return; } - - if (videoEngage.paused) { - try { - await videoEngage.play(); - } catch (err) { - log("Video play failed (browser policy). Click inside the page then try Engage again.", "w"); - return; - } - } - - state.tracker.running = true; - state.tracker.beamOn = true; - state.tracker.lastDetTime = 0; - state.tracker.lastFrameTime = now(); - state.tracker.frameCount = 0; - engageNote.textContent = "Running"; - chipBeam.textContent = "BEAM:ON"; - log("Engage started: tracking enabled, dwell accumulation active.", "g"); - - // Initialize tracks: - // - Prefer Tab 1 detections if available (same first-frame context) - // - Otherwise, seed from the current video frame (actual detector output) - if (!state.tracker.tracks.length) { - if (state.detections && state.detections.length) { - seedTracksFromTab1(); - } else { - const dets = await detectOnVideoFrame(); - if (dets && dets.length) { - seedTracksFromDetections(dets); - log(`Seeded ${state.tracker.tracks.length} tracks from video-frame detections.`, "t"); - } else { - log("No detections available to seed tracks yet. Tracks will appear as detections arrive.", "w"); - } - } - } - - resizeOverlays(); - startLoop(); - }); - - btnPause.addEventListener("click", () => { - if (!state.videoLoaded) return; - if (!videoEngage.paused) { - videoEngage.pause(); - log("Video paused.", "t"); - } - state.tracker.beamOn = false; - chipBeam.textContent = "BEAM:OFF"; - }); - - btnReset.addEventListener("click", async () => { - if (!state.videoLoaded) return; - videoEngage.pause(); - await seekTo(videoEngage, 0); - state.tracker.tracks.forEach(t => { t.dwellAccum = 0; t.killed = false; t.state = "TRACK"; }); - state.tracker.selectedTrackId = null; - state.tracker.beamOn = false; - state.tracker.running = false; - dwellBar.style.width = "0%"; - dwellText.textContent = "—"; - engageNote.textContent = "paused"; - chipBeam.textContent = "BEAM:OFF"; - log("Engage reset: video rewound, dwell cleared.", "w"); - renderRadar(); - renderTrackCards(); - renderEngageOverlay(); - }); - - // Toggle sidebar (radar + live tracks) for fullscreen video - btnToggleSidebar.addEventListener("click", () => { - const engageGrid = $(".engage-grid"); - const isCollapsed = engageGrid.classList.contains("sidebar-collapsed"); - - if (isCollapsed) { - engageGrid.classList.remove("sidebar-collapsed"); - btnToggleSidebar.textContent = "◀ Hide Sidebar"; - log("Sidebar expanded.", "t"); - } else { - engageGrid.classList.add("sidebar-collapsed"); - btnToggleSidebar.textContent = "▶ Show Sidebar"; - log("Sidebar collapsed - video fullscreen.", "t"); - } - }); - - function seedTracksFromTab1() { - state.tracker.tracks = state.detections.map(d => { - const t = { - id: d.id, - label: d.label, - bbox: { ...d.bbox }, - score: d.score, - aimRel: { relx: d.aim.relx, rely: d.aim.rely, label: d.aim.label }, - baseAreaFrac: d.baseAreaFrac || ((d.bbox.w * d.bbox.h) / (state.frame.w * state.frame.h)), - baseRange_m: d.baseRange_m || +rangeBase.value, - baseDwell_s: d.baseDwell_s || 4.0, - reqP_kW: d.reqP_kW || 35, - // Depth visualization (keep for depth view toggle) - depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null, - // GPT properties - the sole source of distance estimation - gpt_distance_m: d.gpt_distance_m || null, - gpt_direction: d.gpt_direction || null, - gpt_description: d.gpt_description || null, - // Track state - lastSeen: now(), - vx: 0, vy: 0, - dwellAccum: 0, - killed: false, - state: "TRACK", // TRACK -> SETTLE -> FIRE -> ASSESS -> KILL - assessT: 0 - }; - return t; - }); - state.tracker.nextId = state.detections.length + 1; - log(`Seeded ${state.tracker.tracks.length} tracks from Tab 1 detections.`, "t"); - } - - function seedTracksFromDetections(dets) { - const w = videoEngage.videoWidth || state.frame.w; - const h = videoEngage.videoHeight || state.frame.h; - - state.tracker.tracks = dets.slice(0, 12).map((d, i) => { - const id = `T${String(i + 1).padStart(2, "0")}`; - const ap = defaultAimpoint(d.class); - const bb = normBBox(d.bbox, w, h); - return { - id, - label: d.class, - bbox: { ...bb }, - score: d.score, - aimRel: { relx: ap.relx, rely: ap.rely, label: ap.label }, - baseAreaFrac: (bb.w * bb.h) / (w * h), - baseRange_m: +rangeBase.value, - baseDwell_s: 5.0, - reqP_kW: 40, - // Depth visualization only, GPT handles distance - depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null, - // GPT properties - gpt_distance_m: d.gpt_distance_m || null, - gpt_direction: d.gpt_direction || null, - gpt_description: d.gpt_description || null, - // Track state - lastSeen: now(), - vx: 0, vy: 0, - dwellAccum: 0, - killed: false, - state: "TRACK", - assessT: 0 - }; - }); - state.tracker.nextId = state.tracker.tracks.length + 1; - } - - function iou(a, b) { - const ax2 = a.x + a.w, ay2 = a.y + a.h; - const bx2 = b.x + b.w, by2 = b.y + b.h; - const ix1 = Math.max(a.x, b.x), iy1 = Math.max(a.y, b.y); - const ix2 = Math.min(ax2, bx2), iy2 = Math.min(ay2, by2); - const iw = Math.max(0, ix2 - ix1), ih = Math.max(0, iy2 - iy1); - const inter = iw * ih; - const ua = a.w * a.h + b.w * b.h - inter; - return ua <= 0 ? 0 : inter / ua; - } - - async function externalTrack(videoEl) { - // Hook for user tracking: should return predictions similar to detector output - if (typeof window.__HEL_TRACK__ === "function") { - return await window.__HEL_TRACK__(videoEl); - } - throw new Error("External tracker hook is not installed."); - } - - async function detectOnVideoFrame() { - const mode = state.detector.mode; - if (mode === "external") { - try { return await externalTrack(videoEngage); } - catch (e) { log(`External tracker failed: ${e.message}`, "w"); return []; } - } - if (state.detector.cocoBlocked) { - return []; - } - if (isHfMode(mode)) { - // In HF mode, we DO NOT fall back to local COCO for tracking. - // Why? Because local COCO will overwrite high-quality labels (e.g. "drone") - // with generic ones ("airplane"), breaking the user experience. - // Instead, we rely on the tracker's predictive coasting (predictTracks) - // or wait for sparse updates if we implement backend streaming later. - - // For now, return empty to prevent label pollution. - // The tracker will maintain existing tracks via coasting/prediction - // until they time out or we get a new "Reason" update. - return []; - } - if (mode === "coco") { - await ensureCocoDetector(); - if (state.detector.model) { - try { - let preds = await state.detector.model.detect(videoEngage); - return preds - .filter(p => p.score >= 0.45) - .slice(0, 18) - .map(p => ({ bbox: p.bbox, class: p.class, score: p.score })); - } catch (err) { - if (err && err.name === "SecurityError") { - state.detector.cocoBlocked = true; - log("Local COCO tracking blocked by tainted video. Use External tracker or RAW feed.", "w"); - return []; - } - throw err; - } - } - return []; - } - return []; - } - - - function matchAndUpdateTracks(dets, dtSec) { - // Convert detections to bbox in video coordinates - const w = videoEngage.videoWidth || state.frame.w; - const h = videoEngage.videoHeight || state.frame.h; - - const detObjs = dets.map(d => ({ - bbox: normBBox(d.bbox, w, h), - label: d.class, - score: d.score, - depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null // Visualization only - })); - - // mark all tracks as unmatched - const tracks = state.tracker.tracks; - - const used = new Set(); - for (const tr of tracks) { - let best = null; - let bestI = 0.0; - let bestIdx = -1; - for (let i = 0; i < detObjs.length; i++) { - if (used.has(i)) continue; - const IoU = iou(tr.bbox, detObjs[i].bbox); - if (IoU > bestI) { - bestI = IoU; - best = detObjs[i]; - bestIdx = i; - } - } - // Strict matching threshold - if (best && bestI >= 0.25) { - used.add(bestIdx); - - // Velocity with Exponential Moving Average (EMA) for smoothing - const cx0 = tr.bbox.x + tr.bbox.w * 0.5; - const cy0 = tr.bbox.y + tr.bbox.h * 0.5; - const cx1 = best.bbox.x + best.bbox.w * 0.5; - const cy1 = best.bbox.y + best.bbox.h * 0.5; - - const rawVx = (cx1 - cx0) / Math.max(1e-3, dtSec); - const rawVy = (cy1 - cy0) / Math.max(1e-3, dtSec); - - // Alpha of 0.3 means 30% new value, 70% history - tr.vx = tr.vx * 0.7 + rawVx * 0.3; - tr.vy = tr.vy * 0.7 + rawVy * 0.3; - - // smooth bbox update - tr.bbox.x = lerp(tr.bbox.x, best.bbox.x, 0.7); - tr.bbox.y = lerp(tr.bbox.y, best.bbox.y, 0.7); - tr.bbox.w = lerp(tr.bbox.w, best.bbox.w, 0.6); - tr.bbox.h = lerp(tr.bbox.h, best.bbox.h, 0.6); - - // Logic: Only update label if the new detection is highly confident - // AND the current track doesn't have a "premium" label (like 'drone'). - // This prevents COCO's 'airplane' from overwriting a custom 'drone' label. - const protectedLabels = ["drone", "uav", "missile"]; - const isProtected = protectedLabels.some(l => (tr.label || "").toLowerCase().includes(l)); - - if (!isProtected || (best.label && protectedLabels.some(l => best.label.toLowerCase().includes(l)))) { - tr.label = best.label || tr.label; - } - - tr.score = best.score || tr.score; - // Update depth visualization (not for distance) - if (Number.isFinite(best.depth_rel)) { - tr.depth_rel = best.depth_rel; - } - tr.lastSeen = now(); - } else { - // Decay velocity if not seen to prevent "coasting" into infinity - tr.vx *= 0.9; - tr.vy *= 0.9; - } - } - - // add unmatched detections as new tracks (optional) - // Limit total tracks to prevent memory leaks/ui lag - if (tracks.length < 50) { - for (let i = 0; i < detObjs.length; i++) { - if (used.has(i)) continue; - // create new track only if big enough (avoid clutter) - const a = detObjs[i].bbox.w * detObjs[i].bbox.h; - if (a < (w * h) * 0.0025) continue; - - const newId = `T${String(state.tracker.nextId++).padStart(2, "0")}`; - const ap = defaultAimpoint(detObjs[i].label); - tracks.push({ - id: newId, - label: detObjs[i].label, - bbox: { ...detObjs[i].bbox }, - score: detObjs[i].score, - aimRel: { relx: ap.relx, rely: ap.rely, label: ap.label }, - baseAreaFrac: (detObjs[i].bbox.w * detObjs[i].bbox.h) / (w * h), - baseRange_m: +rangeBase.value, - baseDwell_s: 5.5, - reqP_kW: 42, - // Depth visualization only, GPT handles distance - depth_rel: detObjs[i].depth_rel, - // GPT properties (will be populated by updateTracksWithGPT) - gpt_distance_m: null, - gpt_direction: null, - gpt_description: null, - // Track state - lastSeen: now(), - vx: 0, vy: 0, - dwellAccum: 0, - killed: false, - state: "TRACK", - assessT: 0 - }); - log(`New track created: ${newId} (${detObjs[i].label})`, "t"); - } - } - - // prune old tracks if they disappear - const tNow = now(); - state.tracker.tracks = tracks.filter(tr => (tNow - tr.lastSeen) < 1500 || tr.killed); - } - - function predictTracks(dtSec) { - const w = videoEngage.videoWidth || state.frame.w; - const h = videoEngage.videoHeight || state.frame.h; - state.tracker.tracks.forEach(tr => { - if (tr.killed) return; - tr.bbox.x = clamp(tr.bbox.x + tr.vx * dtSec * 0.12, 0, w - 1); - tr.bbox.y = clamp(tr.bbox.y + tr.vy * dtSec * 0.12, 0, h - 1); - }); - } - - function hasValidDepth(item) { - // Only used for depth VIEW toggle, not distance - return item && Number.isFinite(item.depth_rel); - } - - function getDisplayRange(item, fallbackRange) { - // GPT is the ONLY source of distance - if (item && item.gpt_distance_m) { - return { range: item.gpt_distance_m, source: "GPT" }; - } - return { range: fallbackRange, source: "area" }; - } - - function getDisplayRel(item) { - if (item && Number.isFinite(item.depth_rel)) { - return item.depth_rel; - } - return null; - } - - function rangeFromArea(track) { - // [DELETED] "calculated" depth removed per user request. - // Fallback only if GPT hasn't returned yet. - return 1000; - } - - async function updateTracksWithGPT() { - const activeTracks = state.tracker.tracks.filter(t => !t.killed); - if (!activeTracks.length) return; - - // Take a snapshot of the current video frame - const c = document.createElement("canvas"); - c.width = videoEngage.videoWidth || state.frame.w; - c.height = videoEngage.videoHeight || state.frame.h; - const ctx = c.getContext("2d"); - ctx.drawImage(videoEngage, 0, 0, c.width, c.height); - - const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.85)); - - // Prepare tracks payload - // Backend expects: [{"id":..., "bbox":[x,y,w,h], "label":...}] - const tracksPayload = activeTracks.map(t => ({ - id: t.id, - bbox: [Math.round(t.bbox.x), Math.round(t.bbox.y), Math.round(t.bbox.w), Math.round(t.bbox.h)], - label: t.label - })); - - const fd = new FormData(); - fd.append("frame", blob, "scan.jpg"); - fd.append("tracks", JSON.stringify(tracksPayload)); - - log(`Requesting GPT reasoning for ${activeTracks.length} tracks...`, "t"); - - try { - const res = await fetch(`${state.hf.baseUrl}/reason/track`, { - method: "POST", - body: fd - }); - if (res.ok) { - const data = await res.json(); // { "T01": { "distance_m": 450, "description": "..." }, ... } - let updatedCount = 0; - - // Merge into state - Object.keys(data).forEach(tid => { - const info = data[tid]; - const track = state.tracker.tracks.find(t => t.id === tid); - if (track) { - if (info.distance_m) track.gpt_distance_m = info.distance_m; - if (info.description) track.gpt_description = info.description; - updatedCount++; - } - }); - log(`GPT updated ${updatedCount} tracks.`, "g"); - renderTrackCards(); // Force refresh UI - } else { - console.warn("GPT reason failed", res.status); - } - } catch (e) { - console.error("GPT reason error", e); - } - } - - function getTrackDisplayRange(track) { - // GPT is the ONLY source of distance estimation - if (track.gpt_distance_m) { - return { range: track.gpt_distance_m, source: "GPT" }; - } - // No fallback - return null if GPT hasn't provided distance yet - return { range: null, source: null }; - } - - function dwellFromRange(track, range_m) { - const mp = maxPowerAtTarget(range_m); - const baseReq = track.reqP_kW || 40; - const baseD = track.baseDwell_s || 5; - - // Use Tab1 baseline as reference; scale by range and power ratio. - const dwell = requiredDwell(range_m, baseReq, mp.Ptar, baseD); - return dwell; - } - - function chooseTargetAuto() { - // choose highest (maxP-reqP)/dwell among visible tracks - let best = null; - state.tracker.tracks.forEach(tr => { - if (tr.killed) return; - const range = getTrackDisplayRange(tr).range || 1000; - const mp = maxPowerAtTarget(range); - const margin = mp.Ptar - (tr.reqP_kW || 0); - const dwell = dwellFromRange(tr, range); - const score = margin / Math.max(0.8, dwell); - if (!best || score > best.score) best = { id: tr.id, score, margin, dwell }; - }); - return best ? best.id : null; - } - - function updateEngagementState(dtSec) { - const assessS = +assessWindow.value; - let targetId = state.tracker.selectedTrackId; - - if (policyMode.value === "auto") { - targetId = chooseTargetAuto(); - state.tracker.selectedTrackId = targetId; - } - - if (!state.tracker.beamOn || !targetId) return; - - const tr = state.tracker.tracks.find(t => t.id === targetId); - if (!tr || tr.killed) return; - - if (!tr || tr.killed) return; - - const disp = getTrackDisplayRange(tr); - const range = disp.range || 1000; - const reqD = dwellFromRange(tr, range); - - // state machine: TRACK -> SETTLE -> FIRE -> ASSESS -> KILL - if (tr.state === "TRACK") { - tr.state = "SETTLE"; - tr.assessT = 0; - } - - if (tr.state === "SETTLE") { - tr.assessT += dtSec; - if (tr.assessT >= 0.25) { tr.state = "FIRE"; tr.assessT = 0; } - } else if (tr.state === "FIRE") { - tr.dwellAccum += dtSec; - if (tr.dwellAccum >= reqD) { - tr.state = "ASSESS"; - tr.assessT = 0; - } - } else if (tr.state === "ASSESS") { - tr.assessT += dtSec; - if (tr.assessT >= assessS) { - if (ENABLE_KILL) { - tr.killed = true; - tr.state = "KILL"; - state.tracker.beamOn = false; // stop beam after kill to make it dramatic - chipBeam.textContent = "BEAM:OFF"; - log(`Target ${tr.id} assessed neutralized.`, "g"); - } else { - tr.state = "ASSESS"; - tr.assessT = 0; - } - } - } - - // update dwell bar UI - const pct = clamp(tr.dwellAccum / Math.max(0.001, reqD), 0, 1) * 100; - const displayRange = getTrackDisplayRange(tr); - const rangeLabel = Number.isFinite(displayRange.range) - ? `${Math.round(displayRange.range)}m (${displayRange.source})` - : "—"; - dwellBar.style.width = `${pct.toFixed(0)}%`; - dwellText.textContent = `${tr.id} · ${tr.state} · ${(tr.dwellAccum).toFixed(1)}s / ${reqD.toFixed(1)}s · R=${rangeLabel}`; - } - - function pickTrackAt(x, y) { - const hits = state.tracker.tracks - .filter(t => !t.killed) - .filter(t => x >= t.bbox.x && x <= t.bbox.x + t.bbox.w && y >= t.bbox.y && y <= t.bbox.y + t.bbox.h) - .sort((a, b) => (a.bbox.w * a.bbox.h) - (b.bbox.w * b.bbox.h)); - return hits[0] || null; - } - - // Main loop - let rafId = null; - async function startLoop() { - if (rafId) cancelAnimationFrame(rafId); - async function tick() { - rafId = requestAnimationFrame(tick); - tickAgentCursor(); - - if (!state.tracker.running) return; - - const tNow = now(); - const dtSec = (tNow - state.tracker.lastFrameTime) / 1000; - state.tracker.lastFrameTime = tNow; - - // detection schedule - let hz = +detHz.value; - // Throttle to ~5Hz (approx every 12 frames) for HF fallback mode to save resources - if (isHfMode(state.detector.mode)) { - hz = Math.min(hz, 5); - } - const period = 1000 / Math.max(1, hz); - if ((tNow - state.tracker.lastDetTime) >= period) { - state.tracker.lastDetTime = tNow; - const dets = await detectOnVideoFrame(); - matchAndUpdateTracks(dets, Math.max(0.016, dtSec)); - } else { - predictTracks(Math.max(0.016, dtSec)); - } - - updateEngagementState(Math.max(0.016, dtSec)); - renderEngageOverlay(); - renderRadar(); - renderTrackCards(); - - chipTracks.textContent = `TRACKS:${state.tracker.tracks.filter(t => !t.killed).length}`; - liveStamp.textContent = new Date().toLocaleTimeString(); - - // GPT Update Loop - state.tracker.frameCount++; - if (state.tracker.frameCount % REASON_INTERVAL === 0) { - updateTracksWithGPT().catch(e => console.error(e)); - } - } - tick(); - - } - - function renderEngageOverlay() { - if (engageOverlay.style.display === "none") { - return; - } - const ctx = engageOverlay.getContext("2d"); - const w = engageOverlay.width, h = engageOverlay.height; - ctx.clearRect(0, 0, w, h); - - if (!state.videoLoaded) return; - - // Draw dark background instead of video frame (only processed overlays shown) - ctx.fillStyle = "#0b1026"; - ctx.fillRect(0, 0, w, h); - - // draw track boxes and labels - const tNow = now(); - state.tracker.tracks.forEach(tr => { - const isSel = tr.id === state.tracker.selectedTrackId; - const killed = tr.killed; - - const b = tr.bbox; - const ax = b.x + b.w * tr.aimRel.relx; - const ay = b.y + b.h * tr.aimRel.rely; - - const displayRange = getTrackDisplayRange(tr); - const range = displayRange.range || 1000; - const reqD = dwellFromRange(tr, range); - - const mp = maxPowerAtTarget(range); - const margin = mp.Ptar - (tr.reqP_kW || 0); - - const color = killed ? "rgba(34,197,94,.55)" : (isSel ? "rgba(34,211,238,.95)" : "rgba(124,58,237,.65)"); - - // box - ctx.lineWidth = isSel ? 3 : 2; - ctx.strokeStyle = color; - ctx.shadowBlur = isSel ? 16 : 10; - ctx.shadowColor = color; - roundRect(ctx, b.x, b.y, b.w, b.h, 10, false, true); - ctx.shadowBlur = 0; - - // aimpoint - if (!killed) { - drawAimpoint(ctx, ax, ay, isSel); - } else { - // killed marker (no text overlay) - } - - // dwell ring - if (!killed) { - const pct = clamp(tr.dwellAccum / Math.max(0.001, reqD), 0, 1); - ctx.beginPath(); - ctx.strokeStyle = "rgba(34,197,94,.85)"; - ctx.lineWidth = 3; - ctx.arc(ax, ay, 16, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * pct); - ctx.stroke(); - } - - // no text overlay on engage view - - // engagement strip indicator near bbox bottom - const st = tr.state || "TRACK"; - const stColor = st === "FIRE" ? "rgba(239,68,68,.92)" : (st === "ASSESS" ? "rgba(245,158,11,.92)" : (st === "KILL" ? "rgba(34,197,94,.92)" : "rgba(34,211,238,.92)")); - ctx.fillStyle = stColor; - ctx.globalAlpha = 0.85; - ctx.fillRect(b.x, b.y + b.h + 4, clamp(b.w * 0.55, 70, b.w), 5); - ctx.globalAlpha = 1; - - // no state text overlay on engage view - - // beam line to selected aimpoint - if (state.tracker.beamOn && isSel && !killed) { - ctx.strokeStyle = "rgba(239,68,68,.45)"; - ctx.lineWidth = 2; - ctx.setLineDash([6, 6]); - ctx.beginPath(); - ctx.moveTo(w * 0.5, h * 0.98); - ctx.lineTo(ax, ay); - ctx.stroke(); - ctx.setLineDash([]); - } - }); - } - - function renderTrackCards() { - trackList.innerHTML = ""; - const alive = state.tracker.tracks.filter(t => !t.killed); - if (!alive.length) { - const div = document.createElement("div"); - div.className = "mini"; - div.style.padding = "8px"; - div.textContent = "No live tracks. Run Engage or adjust detector."; - trackList.appendChild(div); - return; - } - - alive.forEach(tr => { - const displayRange = getTrackDisplayRange(tr); - const range = displayRange.range || 1000; - const rangeTxt = Number.isFinite(displayRange.range) - ? `${Math.round(displayRange.range)}m (${displayRange.source})` - : "—"; - const relVal = getDisplayRel(tr); - const relTxt = relVal != null ? relVal.toFixed(2) : "—"; - const reqD = dwellFromRange(tr, range); - const mp = maxPowerAtTarget(range); - const margin = mp.Ptar - (tr.reqP_kW || 0); - const pk = pkillFromMargin(margin, tr.dwellAccum, reqD); - - const div = document.createElement("div"); - div.className = "obj" + (tr.id === state.tracker.selectedTrackId ? " active" : ""); - div.innerHTML = ` -
-
-
${tr.id}
-
${escapeHtml(tr.label)}
-
-
${margin >= 0 ? "+" : ""}${margin.toFixed(1)}kW
-
-
- R:${rangeTxt} - REL:${relTxt} - DW:${reqD.toFixed(1)}s - Pk:${Math.round(pk * 100)}% - AP:${escapeHtml(tr.aimRel.label)} - STATE:${tr.state} -
- `; - div.addEventListener("click", () => { - if (policyMode.value !== "manual") return; - state.tracker.selectedTrackId = tr.id; - state.tracker.beamOn = true; - chipBeam.textContent = "BEAM:ON"; - renderTrackCards(); - }); - trackList.appendChild(div); - }); - } - - // ========= Tab 1 Radar ========= - function renderFrameRadar() { - if (!frameRadar) return; - const ctx = frameRadar.getContext("2d"); - const rect = frameRadar.getBoundingClientRect(); - const dpr = devicePixelRatio || 1; - - // Resize if needed - const targetW = Math.max(1, Math.floor(rect.width * dpr)); - const targetH = Math.max(1, Math.floor(rect.height * dpr)); - if (frameRadar.width !== targetW || frameRadar.height !== targetH) { - frameRadar.width = targetW; - frameRadar.height = targetH; - } - - const w = frameRadar.width, h = frameRadar.height; - const cx = w * 0.5, cy = h * 0.5; - const R = Math.min(w, h) * 0.45; // Max radius - - ctx.clearRect(0, 0, w, h); - - // --- 1. Background (Tactical Grid) --- - ctx.fillStyle = "#0a0f22"; // Matches --panel2 - ctx.fillRect(0, 0, w, h); - - // Grid Rings (Concentric) - ctx.strokeStyle = "rgba(34, 211, 238, 0.1)"; // Cyan faint - ctx.lineWidth = 1; - for (let i = 1; i <= 4; i++) { - ctx.beginPath(); - ctx.arc(cx, cy, R * (i / 4), 0, Math.PI * 2); - ctx.stroke(); - } - - // Grid Spokes (Cross + Diagonals) - ctx.beginPath(); - // Cardinals - ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); - ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); - // Diagonals (optional, maybe too busy? let's stick to cleaning cardinals) - ctx.stroke(); - - // --- 2. Sweep Animation --- - const t = now() / 1500; // Slower, more deliberate sweep - const ang = (t * (Math.PI * 2)) % (Math.PI * 2); - - const grad = ctx.createConicGradient(ang + Math.PI / 2, cx, cy); // Offset to start at 0 - grad.addColorStop(0, "transparent"); - grad.addColorStop(0.1, "transparent"); - grad.addColorStop(0.8, "rgba(34, 211, 238, 0.0)"); - grad.addColorStop(1, "rgba(34, 211, 238, 0.15)"); // Trailing edge - - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(cx, cy, R, 0, Math.PI * 2); - ctx.fill(); - - // Scan Line - ctx.strokeStyle = "rgba(34, 211, 238, 0.6)"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.lineTo(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R); - ctx.stroke(); - - // --- 3. Ownship (Center) --- - ctx.fillStyle = "#22d3ee"; // Cyan - ctx.beginPath(); - ctx.arc(cx, cy, 3, 0, Math.PI * 2); - ctx.fill(); - // Ring around ownship - ctx.strokeStyle = "rgba(34, 211, 238, 0.5)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.stroke(); - - // --- 4. Render Detections --- - if (state.detections) { - state.detections.forEach(det => { - // Determine Range (pixels) - // Map logical range (meters) to graphical range (0..R) - let rangeVal = 3000; // default max scale in meters - let dist = 1000; // default unknown - - if (det.gpt_distance_m) { - dist = det.gpt_distance_m; - } else { - // No GPT yet - show at far distance (unknown) - dist = 3000; - } - - // Log scale or Linear? Linear is easier for users to map. - // Let's use linear: 0m -> 0px, 1500m -> R - const maxRangeM = 1500; - const rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R; - - // Determine Bearing - // box center relative to frame center - const bx = det.bbox.x + det.bbox.w * 0.5; - const fw = state.frame.w || 1280; - const tx = (bx / fw) - 0.5; // -0.5 (left) to 0.5 (right) - // Map x-axis (-0.5 to 0.5) to angle. - // FOV assumption: ~60 degrees? - const fovRad = (60 * Math.PI) / 180; - // Actually canvas 0 is Right (0 rad). - // We want Up (-PI/2) to be center. - // So center (tx=0) should be -PI/2. - // Left (tx=-0.5) => -PI/2 - fov/2. - // Right (tx=0.5) => -PI/2 + fov/2. - const angle = (-Math.PI / 2) + (tx * fovRad); - - // --- Draw Blip --- - const px = cx + Math.cos(angle) * rPx; - const py = cy + Math.sin(angle) * rPx; - - const isSelected = (state.selectedId === det.id); - - // Glow for selected - if (isSelected) { - ctx.shadowBlur = 10; - ctx.shadowColor = "#f59e0b"; // Amber glow - } else { - ctx.shadowBlur = 0; - } - - // Blip Color - // If it has GPT data, maybe special color? Or just distinct per class? - let col = "#7c3aed"; // Default violet - if (det.label === 'person') col = "#ef4444"; // Red - if (det.label === 'airplane') col = "#f59e0b"; // Amber - if (isSelected) col = "#ffffff"; // White for selected - - ctx.fillStyle = col; - ctx.beginPath(); - ctx.arc(px, py, isSelected ? 5 : 3.5, 0, Math.PI * 2); - ctx.fill(); - - // Blip Label (if selected or hovered - just show ID) - // Just Show ID for all? Might clutter. Show for selected. - if (isSelected) { - ctx.fillStyle = "#fff"; - ctx.font = "bold 11px monospace"; - ctx.fillText(det.id, px + 8, py + 3); - - // Connected Line to center - ctx.strokeStyle = "rgba(255, 255, 255, 0.4)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); // Optional: dashed line for "targeting" feel - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.lineTo(px, py); - ctx.stroke(); - ctx.setLineDash([]); // Reset - - // Distance Label on Line - const mx = (cx + px) * 0.5; - const my = (cy + py) * 0.5; - const distStr = `${Math.round(dist)}m`; - - ctx.font = "10px monospace"; - const tm = ctx.measureText(distStr); - const tw = tm.width; - const th = 10; - - // Label Background - ctx.fillStyle = "rgba(10, 15, 34, 0.85)"; - ctx.fillRect(mx - tw / 2 - 3, my - th / 2 - 2, tw + 6, th + 4); - - // Label Text - ctx.fillStyle = "#22d3ee"; // Cyan - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(distStr, mx, my); - - // Reset text alignment - ctx.textAlign = "start"; - ctx.textBaseline = "alphabetic"; - } - - ctx.shadowBlur = 0; // reset - }); - } - requestAnimationFrame(renderFrameRadar); - } - // Start loop immediately - requestAnimationFrame(renderFrameRadar); - - // ========= Radar rendering (Tab 2) - Aligned with Tab 1 Scale/FOV ========= - function renderRadar() { - const ctx = radarCanvas.getContext("2d"); - const rect = radarCanvas.getBoundingClientRect(); - const dpr = devicePixelRatio || 1; - const targetW = Math.max(1, Math.floor(rect.width * dpr)); - const targetH = Math.max(1, Math.floor(rect.height * dpr)); - if (radarCanvas.width !== targetW || radarCanvas.height !== targetH) { - radarCanvas.width = targetW; - radarCanvas.height = targetH; - } - const w = radarCanvas.width, h = radarCanvas.height; - ctx.clearRect(0, 0, w, h); - - // Background (Matches Tab 1) - ctx.fillStyle = "#0a0f22"; - ctx.fillRect(0, 0, w, h); - - const cx = w * 0.5, cy = h * 0.5; - const R = Math.min(w, h) * 0.45; // Match Tab 1 Radius factor - - // Rings (Matches Tab 1 style) - ctx.strokeStyle = "rgba(34, 211, 238, 0.1)"; - ctx.lineWidth = 1; - for (let i = 1; i <= 4; i++) { - ctx.beginPath(); - ctx.arc(cx, cy, R * (i / 4), 0, Math.PI * 2); - ctx.stroke(); - } - // Cross - ctx.beginPath(); - ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); - ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); - ctx.stroke(); - - // Sweep Animation - const t = now() / 1500; // Match Tab 1 speed (slower) - const ang = (t * (Math.PI * 2)) % (Math.PI * 2); - - // Gradient Sweep - const grad = ctx.createConicGradient(ang + Math.PI / 2, cx, cy); - grad.addColorStop(0, "transparent"); - grad.addColorStop(0.1, "transparent"); - grad.addColorStop(0.8, "rgba(34, 211, 238, 0.0)"); - grad.addColorStop(1, "rgba(34, 211, 238, 0.15)"); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(cx, cy, R, 0, Math.PI * 2); - ctx.fill(); - - // Scan Line - ctx.strokeStyle = "rgba(34, 211, 238, 0.6)"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.lineTo(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R); - ctx.stroke(); - - // Ownship (Center) - ctx.fillStyle = "#22d3ee"; - ctx.beginPath(); - ctx.arc(cx, cy, 3, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "rgba(34, 211, 238, 0.5)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.stroke(); - - // Render Tracks (Tab 2 Source, Tab 1 Logic) - const tracks = state.tracker.tracks; - tracks.forEach(tr => { - // Range Logic (Matches Tab 1) - const displayRange = getTrackDisplayRange(tr); - - let dist = 3000; - if (Number.isFinite(displayRange.range)) dist = displayRange.range; - // else remain at default "unknown" distance (far out) until GPT returns - - // Scale: 0 -> 1500m (Matches Tab 1) - const maxRangeM = 1500; - const rPx = (clamp(dist, 0, maxRangeM) / maxRangeM) * R; - - // Bearing Logic (Matches Tab 1 FOV=60) - // We need normalized X center (-0.5 to 0.5) - // tracks store pixel coordinates on current frame scale - const vw = videoEngage.videoWidth || state.frame.w || 1280; - const bx = tr.bbox.x + tr.bbox.w * 0.5; - const tx = (bx / vw) - 0.5; // -0.5 (left) to 0.5 (right) - - const fovRad = (60 * Math.PI) / 180; - const angle = (-Math.PI / 2) + (tx * fovRad); - - const px = cx + Math.cos(angle) * rPx; - const py = cy + Math.sin(angle) * rPx; - - // Styling based on State - const isSelected = (state.tracker.selectedTrackId === tr.id); - const killed = tr.killed; - - const col = killed ? "rgba(148,163,184,.65)" : - (tr.state === "FIRE" ? "rgba(239,68,68,.9)" : - (tr.state === "ASSESS" ? "rgba(245,158,11,.9)" : - (isSelected ? "#f59e0b" : "rgba(34, 211, 238, 0.9)"))); // Cyan default - - if (isSelected) { - ctx.shadowBlur = 10; - ctx.shadowColor = col; - } else { - ctx.shadowBlur = 0; - } - - ctx.fillStyle = col; - ctx.beginPath(); - ctx.arc(px, py, 5, 0, Math.PI * 2); - ctx.fill(); - - // Label - if (!killed && (isSelected || tracks.length < 5)) { - ctx.fillStyle = "rgba(255,255,255,.75)"; - ctx.font = "11px " + getComputedStyle(document.body).fontFamily; - ctx.fillText(tr.id, px + 8, py + 4); - } - }); - - // Legend - ctx.shadowBlur = 0; - ctx.fillStyle = "rgba(255,255,255,.55)"; - ctx.font = "11px " + getComputedStyle(document.body).fontFamily; - ctx.fillText("LIVE TRACKING: 60° FOV, 1500m SCALE", 10, 18); - } - - // ========= Resizing overlays to match video viewports ========= - function resizeOverlays() { - // Engage overlay matches displayed video size - const rect = videoEngage.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) { - const w = Math.round(rect.width * devicePixelRatio); - const h = Math.round(rect.height * devicePixelRatio); - engageOverlay.width = w; - engageOverlay.height = h; - engageOverlay.style.width = rect.width + "px"; - engageOverlay.style.height = rect.height + "px"; - - // scale track bboxes if video intrinsic differs from overlay (we keep bboxes in intrinsic coords) - // rendering code assumes overlay coords = intrinsic coords. Therefore we remap by setting ctx transform each render. - // Instead, we store bboxes in intrinsic and draw by transform. - // We will implement by setting ctx.setTransform in renderEngageOverlay. - } - - // Frame overlay uses intrinsic, but we still keep canvas scaled by CSS. No action needed. - } - window.addEventListener("resize", resizeOverlays); - - // Adjust engage overlay transform for drawing in intrinsic coordinates - const _renderEngageOverlay = renderEngageOverlay; - renderEngageOverlay = function () { - const ctx = engageOverlay.getContext("2d"); - const rect = videoEngage.getBoundingClientRect(); - const vw = videoEngage.videoWidth || state.frame.w; - const vh = videoEngage.videoHeight || state.frame.h; - const pxW = engageOverlay.width; - const pxH = engageOverlay.height; - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, pxW, pxH); - - if (!rect.width || !rect.height) return; - const sx = pxW / vw; - const sy = pxH / vh; - ctx.setTransform(sx, 0, 0, sy, 0, 0); - - _renderEngageOverlay(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - }; - - // Also adjust click picking because overlay is scaled - engageOverlay.addEventListener("click", (ev) => { - if (!state.videoLoaded) return; - if (policyMode.value !== "manual") return; - - const rect = engageOverlay.getBoundingClientRect(); - const vw = videoEngage.videoWidth || state.frame.w; - const vh = videoEngage.videoHeight || state.frame.h; - const pxW = engageOverlay.width; - const pxH = engageOverlay.height; - - const xPx = (ev.clientX - rect.left) * devicePixelRatio; - const yPx = (ev.clientY - rect.top) * devicePixelRatio; - - // inverse transform - const x = xPx * (vw / pxW); - const y = yPx * (vh / pxH); - - const tr = pickTrackAt(x, y); - if (tr) { - state.tracker.selectedTrackId = tr.id; - state.tracker.beamOn = true; - chipBeam.textContent = "BEAM:ON"; - log(`Manual target selected: ${tr.id}`, "t"); - renderTrackCards(); - } - }, { passive: true }); - - // ========= Trade-space rendering ========= - function refreshTradeTargets() { - const sel = tradeTarget.value; - tradeTarget.innerHTML = ""; - const ids = state.detections.map(d => d.id); - if (!ids.length) { - const opt = document.createElement("option"); - opt.value = ""; - opt.textContent = "No targets"; - tradeTarget.appendChild(opt); - return; - } - ids.forEach(id => { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = id; - tradeTarget.appendChild(opt); - }); - if (sel && ids.includes(sel)) tradeTarget.value = sel; - else tradeTarget.value = state.selectedId || ids[0]; - } - - btnReplot.addEventListener("click", renderTrade); - tradeTarget.addEventListener("change", renderTrade); - - btnSnap.addEventListener("click", () => { - if (!state.detections.length) return; - const id = tradeTarget.value; - const d = state.detections.find(x => x.id === id) || state.detections[0]; - const snap = { - target: id, - helPower_kW: +helPower.value, - vis_km: +atmVis.value, - cn2: +atmCn2.value, - ao: +aoQ.value, - baseRange_m: d.baseRange_m, - reqP_kW: d.reqP_kW, - baseDwell_s: d.baseDwell_s - }; - log("SNAPSHOT: " + JSON.stringify(snap), "t"); - }); - - function renderTrade() { - const ctx = tradeCanvas.getContext("2d"); - const W = tradeCanvas.width, H = tradeCanvas.height; - ctx.clearRect(0, 0, W, H); - - // background - ctx.fillStyle = "rgba(0,0,0,.32)"; - ctx.fillRect(0, 0, W, H); - - if (!state.detections.length) { - ctx.fillStyle = "rgba(255,255,255,.75)"; - ctx.font = "14px " + getComputedStyle(document.body).fontFamily; - ctx.fillText("Run Reason to populate trade-space curves.", 18, 34); - return; - } - - const id = tradeTarget.value || state.selectedId || state.detections[0].id; - const d = state.detections.find(x => x.id === id) || state.detections[0]; - - const r0 = Math.max(50, +rMin.value || 200); - const r1 = Math.max(r0 + 50, +rMax.value || 6000); - - // margins - const padL = 64, padR = 18, padT = 18, padB = 52; - const plotW = W - padL - padR; - const plotH = H - padT - padB; - - // compute sweep - const N = 120; - const xs = []; - let maxY = 0; - let minY = Infinity; - - for (let i = 0; i <= N; i++) { - const r = r0 + (r1 - r0) * (i / N); - const mp = maxPowerAtTarget(r).Ptar; - const reqP = d.reqP_kW || 40; - const reqD = requiredDwell(r, reqP, mp, d.baseDwell_s || 5); - - xs.push({ r, mp, reqP, reqD }); - maxY = Math.max(maxY, mp, reqP); - minY = Math.min(minY, mp, reqP); - } - - maxY = Math.max(maxY, 20); - minY = Math.max(0, minY - 10); - - // axes - ctx.strokeStyle = "rgba(255,255,255,.14)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(padL, padT); - ctx.lineTo(padL, padT + plotH); - ctx.lineTo(padL + plotW, padT + plotH); - ctx.stroke(); - - // grid lines - ctx.strokeStyle = "rgba(255,255,255,.07)"; - for (let i = 1; i <= 5; i++) { - const y = padT + plotH * (i / 5); - ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke(); - } - for (let i = 1; i <= 6; i++) { - const x = padL + plotW * (i / 6); - ctx.beginPath(); ctx.moveTo(x, padT); ctx.lineTo(x, padT + plotH); ctx.stroke(); - } - - // helpers - const xMap = (r) => padL + (r - r0) / (r1 - r0) * plotW; - const yMap = (p) => padT + (1 - (p - minY) / (maxY - minY)) * plotH; - - // curve: max power at target - ctx.strokeStyle = "rgba(34,211,238,.95)"; - ctx.lineWidth = 2.5; - ctx.beginPath(); - xs.forEach((pt, i) => { - const x = xMap(pt.r); - const y = yMap(pt.mp); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.stroke(); - - // curve: required power - ctx.strokeStyle = "rgba(239,68,68,.90)"; - ctx.lineWidth = 2.5; - ctx.beginPath(); - xs.forEach((pt, i) => { - const x = xMap(pt.r); - const y = yMap(pt.reqP); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.stroke(); - - // annotate margin zones - ctx.fillStyle = "rgba(34,197,94,.08)"; - ctx.beginPath(); - xs.forEach((pt, i) => { - const x = xMap(pt.r); - const y = yMap(Math.max(pt.reqP, pt.mp)); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - for (let i = xs.length - 1; i >= 0; i--) { - const x = xMap(xs[i].r); - const y = yMap(Math.min(xs[i].reqP, xs[i].mp)); - ctx.lineTo(x, y); - } - ctx.closePath(); - ctx.fill(); - - // second axis for dwell (scaled) - const dwellMax = Math.max(...xs.map(p => p.reqD)); - const yMapD = (dwell) => padT + (1 - (dwell / Math.max(1e-6, dwellMax))) * plotH; - - ctx.strokeStyle = "rgba(124,58,237,.85)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - xs.forEach((pt, i) => { - const x = xMap(pt.r); - const y = yMapD(pt.reqD); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.stroke(); - - // optional pkill band - if (showPk.value === "on") { - ctx.fillStyle = "rgba(245,158,11,.08)"; - ctx.beginPath(); - xs.forEach((pt, i) => { - const x = xMap(pt.r); - const mp = pt.mp; - const margin = mp - pt.reqP; - const pk = pkillFromMargin(margin, d.baseDwell_s || 5, pt.reqD); - const y = padT + plotH * (1 - pk); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.lineTo(padL + plotW, padT + plotH); - ctx.lineTo(padL, padT + plotH); - ctx.closePath(); - ctx.fill(); - } - - // labels - ctx.fillStyle = "rgba(255,255,255,.84)"; - ctx.font = "bold 14px " + getComputedStyle(document.body).fontFamily; - ctx.fillText(`Target: ${id} (${d.label})`, padL, 16); - - ctx.fillStyle = "rgba(34,211,238,.95)"; - ctx.fillText("Max P@Target (kW)", padL + 10, padT + plotH + 30); - - ctx.fillStyle = "rgba(239,68,68,.92)"; - ctx.fillText("Required P@Target (kW)", padL + 190, padT + plotH + 30); - - ctx.fillStyle = "rgba(124,58,237,.90)"; - ctx.fillText(`Required Dwell (s, scaled)`, padL + 420, padT + plotH + 30); - - ctx.fillStyle = "rgba(255,255,255,.55)"; - ctx.font = "11px " + getComputedStyle(document.body).fontFamily; - ctx.fillText(`Range (m)`, padL + plotW - 64, padT + plotH + 46); - - // axis ticks - ctx.fillStyle = "rgba(255,255,255,.55)"; - ctx.font = "11px " + getComputedStyle(document.body).fontFamily; - - for (let i = 0; i <= 5; i++) { - const p = minY + (maxY - minY) * (1 - i / 5); - const y = padT + plotH * (i / 5); - ctx.fillText(p.toFixed(0), 12, y + 4); - } - - for (let i = 0; i <= 6; i++) { - const r = r0 + (r1 - r0) * (i / 6); - const x = padL + plotW * (i / 6); - ctx.fillText(r.toFixed(0), x - 14, padT + plotH + 18); - } - - // marker at baseline range - const baseR = d.baseRange_m || +rangeBase.value; - const xb = xMap(clamp(baseR, r0, r1)); - ctx.strokeStyle = "rgba(255,255,255,.28)"; - ctx.setLineDash([6, 6]); - ctx.beginPath(); - ctx.moveTo(xb, padT); - ctx.lineTo(xb, padT + plotH); - ctx.stroke(); - ctx.setLineDash([]); - } - - // ========= Helpers: keep drawing when idle ========= - function idleLoop() { - requestAnimationFrame(idleLoop); - tickAgentCursor(); - } - idleLoop(); - - // ========= Init ========= - unloadVideo(); - log("Console initialized. Upload a video to begin.", "t"); - -})();