} 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;
return {
bbox: [x1, y1, Math.max(1, x2 - x1), Math.max(1, y2 - y1)],
class: d.label || "drone",
score: d.score ?? 0
};
});
}
// 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;
return {
bbox: [x1, y1, Math.max(1, x2 - x1), Math.max(1, y2 - y1)],
class: d.label || "object",
score: d.score ?? 0
};
});
}
// 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;
renderObjectList();
renderFrameOverlay();
renderSummary();
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(objList.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
};
});
// 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;
renderObjectList();
renderFrameOverlay();
renderSummary();
renderFeatures(getSelected());
renderTrade();
state.hasReasoned = true;
setStatus("good", "READY · Reason complete (you can Engage)");
log("Reason complete.", "g");
// 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
renderObjectList();
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 =========
function renderObjectList() {
objList.innerHTML = "";
objCount.textContent = `${state.detections.length}`;
if (!state.detections.length) {
const empty = document.createElement("div");
empty.className = "mini";
empty.style.padding = "8px";
empty.textContent = "No detections yet. Click Reason.";
objList.appendChild(empty);
return;
}
const dets = state.detections.slice();
// Sort by confidence (mission filtering is handled by backend)
dets.sort((a, b) => ((b.score || 0) - (a.score || 0)));
dets.forEach(d => {
const div = document.createElement("div");
div.className = "obj" + (d.id === state.selectedId ? " active" : "");
div.dataset.id = d.id;
const rangeTxt = d.baseRange_m ? `${Math.round(d.baseRange_m)} m` : "—";
const dwellTxt = d.baseDwell_s ? `${d.baseDwell_s.toFixed(1)} s` : "—";
const pkTxt = (d.pkill != null) ? `${Math.round(d.pkill * 100)}%` : "—";
div.innerHTML = `
${d.id}
${escapeHtml(d.label)}
${isMissionFocusLabel(d.label) ? `
FOCUS` : ""}
${Math.round(d.score * 100)}%
RANGE:${rangeTxt}
DWELL:${dwellTxt}
P(k):${pkTxt}
AIM:${escapeHtml(d.aim?.label || "center")}
`;
div.addEventListener("click", () => {
state.selectedId = d.id;
renderObjectList();
renderFeatures(d);
renderFrameOverlay();
renderTrade();
});
objList.appendChild(div);
});
}
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);
}
}
}
function renderSummary() {
const tbody = summaryTable.querySelector("tbody");
tbody.innerHTML = "";
if (!state.detections.length) {
tbody.innerHTML = `| — | No outputs yet. Click Reason. |
`;
mMaxP.textContent = "—";
mReqP.textContent = "—";
mMargin.textContent = "—";
mPlan.textContent = "—";
return;
}
state.detections.forEach(d => {
const tr = document.createElement("tr");
const range = d.baseRange_m ?? 0;
const reqP = d.reqP_kW ?? 0;
const maxP = d.maxP_kW ?? 0;
const dwell = d.baseDwell_s ?? 0;
const p = d.pkill ?? 0;
tr.innerHTML = `
${d.id} |
${escapeHtml(d.label)} |
${Math.round(range)} |
${escapeHtml(d.aim?.label || "center")} |
${reqP.toFixed(1)} kW |
${maxP.toFixed(1)} kW |
${dwell.toFixed(1)} s |
${Math.round(p * 100)}% |
`;
tbody.appendChild(tr);
});
}
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);
// label
const range = Math.round(d.baseRange_m || 0);
const dwell = (d.baseDwell_s != null) ? d.baseDwell_s.toFixed(1) : "—";
const pk = (d.pkill != null) ? Math.round(d.pkill * 100) : "—";
ctx.font = "bold 14px " + getComputedStyle(document.body).fontFamily;
const tag = `${d.id} · ${d.label} · R=${range}m · DWELL=${dwell}s · Pk=${pk}%`;
const tw = ctx.measureText(tag).width;
const tx = clamp(b.x, 6, w - tw - 12);
const ty = clamp(b.y - 12, 18, h - 12);
ctx.fillStyle = "rgba(0,0,0,.50)";
ctx.strokeStyle = "rgba(255,255,255,.14)";
ctx.lineWidth = 1;
roundRect(ctx, tx - 6, ty - 14, tw + 12, 18, 8, true, true);
ctx.fillStyle = "rgba(255,255,255,.86)";
ctx.fillText(tag, tx, ty);
});
// 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) {
state.selectedId = hit.d.id;
renderObjectList();
renderFeatures(hit.d);
renderFrameOverlay();
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();
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,
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,
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)) {
if (!state.detector.hfTrackingWarned) {
state.detector.hfTrackingWarned = true;
log("HF mode uses backend processing; local COCO tracking is disabled to avoid GPU leaks. Use External tracker or RAW feed.", "w");
}
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
}));
// 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;
}
}
if (best && bestI >= 0.18) {
used.add(bestIdx);
// velocity estimate
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;
tr.vx = (cx1 - cx0) / Math.max(1e-6, dtSec);
tr.vy = (cy1 - cy0) / Math.max(1e-6, dtSec);
// smooth bbox update
tr.bbox.x = lerp(tr.bbox.x, best.bbox.x, 0.65);
tr.bbox.y = lerp(tr.bbox.y, best.bbox.y, 0.65);
tr.bbox.w = lerp(tr.bbox.w, best.bbox.w, 0.55);
tr.bbox.h = lerp(tr.bbox.h, best.bbox.h, 0.55);
tr.label = best.label || tr.label;
tr.score = best.score || tr.score;
tr.lastSeen = now();
}
}
// add unmatched detections as new tracks (optional)
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,
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) < 2200 || 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 rangeFromArea(track) {
const w = videoEngage.videoWidth || state.frame.w;
const h = videoEngage.videoHeight || state.frame.h;
const a = (track.bbox.w * track.bbox.h) / (w * h);
const baseA = Math.max(1e-6, track.baseAreaFrac || a);
const rel = Math.sqrt(baseA / Math.max(1e-6, a));
return clamp(track.baseRange_m * rel, 80, 16000);
}
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 = rangeFromArea(tr);
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;
const range = rangeFromArea(tr);
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) {
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");
}
}
// update dwell bar UI
const pct = clamp(tr.dwellAccum / Math.max(0.001, reqD), 0, 1) * 100;
dwellBar.style.width = `${pct.toFixed(0)}%`;
dwellText.textContent = `${tr.id} · ${tr.state} · ${(tr.dwellAccum).toFixed(1)}s / ${reqD.toFixed(1)}s · R=${Math.round(range)}m`;
}
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
const hz = +detHz.value;
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();
}
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 range = rangeFromArea(tr);
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
ctx.fillStyle = "rgba(34,197,94,.95)";
ctx.font = "14px " + getComputedStyle(document.body).fontFamily;
ctx.fillText("NEUTRALIZED", b.x + 10, b.y + 22);
}
// 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();
}
// label with distance + dwell + margin
const tag = `${tr.id} · R=${Math.round(range)}m · DWELL=${reqD.toFixed(1)}s · ΔP=${margin >= 0 ? "+" : ""}${margin.toFixed(1)}kW`;
ctx.font = "bold 14px " + getComputedStyle(document.body).fontFamily;
const tw = ctx.measureText(tag).width;
const tx = clamp(b.x, 6, w - tw - 12);
const ty = clamp(b.y - 12, 18, h - 12);
ctx.fillStyle = "rgba(0,0,0,.55)";
ctx.strokeStyle = "rgba(255,255,255,.14)";
ctx.lineWidth = 1;
roundRect(ctx, tx - 6, ty - 14, tw + 12, 18, 8, true, true);
ctx.fillStyle = "rgba(255,255,255,.86)";
ctx.fillText(tag, tx, ty);
// 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;
ctx.fillStyle = "rgba(255,255,255,.82)";
ctx.font = "11px " + getComputedStyle(document.body).fontFamily;
ctx.fillText(st, b.x, b.y + b.h + 18);
// 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 range = rangeFromArea(tr);
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:${Math.round(range)}m
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);
});
}
// ========= Radar rendering =========
function renderRadar() {
const ctx = radarCanvas.getContext("2d");
const w = radarCanvas.width, h = radarCanvas.height;
ctx.clearRect(0, 0, w, h);
// background
ctx.fillStyle = "rgba(0,0,0,.35)";
ctx.fillRect(0, 0, w, h);
const cx = w * 0.5, cy = h * 0.55;
const R = Math.min(w, h) * 0.42;
// rings
ctx.strokeStyle = "rgba(255,255,255,.10)";
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.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
// sweep
const t = now() / 1000;
const ang = (t * 0.65) % (Math.PI * 2);
ctx.strokeStyle = "rgba(34,211,238,.22)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(ang) * R, cy + Math.sin(ang) * R);
ctx.stroke();
// ownship
ctx.fillStyle = "rgba(34,211,238,.85)";
ctx.beginPath();
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
ctx.fill();
// tracks as blips
const tracks = state.tracker.tracks.filter(t => !t.killed);
tracks.forEach(tr => {
const range = rangeFromArea(tr);
const rr = clamp(range / Math.max(250, +rangeBase.value), 0.1, 3.5); // relative
const b = tr.bbox;
// bearing from image position
const vw = videoEngage.videoWidth || state.frame.w;
const vh = videoEngage.videoHeight || state.frame.h;
const tx = (b.x + b.w * 0.5) / vw - 0.5;
const ty = (b.y + b.h * 0.5) / vh - 0.5;
const bearing = Math.atan2(ty, tx);
const rad = clamp(rr, 0, 3.2) * (R / 3.2);
const px = cx + Math.cos(bearing) * rad;
const py = cy + Math.sin(bearing) * rad;
// color by engagement state
const col = tr.state === "FIRE" ? "rgba(239,68,68,.9)" :
(tr.state === "ASSESS" ? "rgba(245,158,11,.9)" :
"rgba(124,58,237,.9)");
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(px, py, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,.75)";
ctx.font = "11px " + getComputedStyle(document.body).fontFamily;
ctx.fillText(tr.id, px + 8, py + 4);
});
// label
ctx.fillStyle = "rgba(255,255,255,.55)";
ctx.font = "11px " + getComputedStyle(document.body).fontFamily;
ctx.fillText("CENTER: OWN-SHIP", 10, 18);
ctx.fillText("BLIPS: RELATIVE RANGE + BEARING (from video kinematics)", 10, 36);
}
// ========= 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");
})();