/* ============================================================ ESP32 LD2453 Radar Web Visualizer — Frontend Canvas rendering, WebSocket client, i18n, theme toggle ============================================================ */ /* ---------- i18n translations ---------- */ const I18N = { vi: { page_title: "ESP32 LD2453 — Radar Web Visualizer", app_title: "Radar LD2453", app_subtitle: "ESP32-S3 — Phát hiện người theo vùng", toggle_theme: "Chuyển giao diện sáng/tối", esp32_connecting: "ESP32: Đang kết nối…", esp32_connected: "ESP32: Đã kết nối", esp32_disconnected: "ESP32: Mất kết nối", ws_connecting: "WS: Đang kết nối…", ws_connected: "WS: Đã kết nối", ws_disconnected: "WS: Mất kết nối", radar_view: "Khung nhìn Radar", sweep_effect: "Hiệu ứng quét", target_trails: "Đường dẫn target", show_labels: "Hiển thị label", legend: "Chú thích", target_1: "Target 1 (Slot 1)", target_2: "Target 2 (Slot 2)", target_3: "Target 3 (Slot 3)", zone_1: "Zone 1", zone_2: "Zone 2", zone_3: "Zone 3", seq: "Seq", targets: "Targets", statistics: "Thống kê", frames_received: "Frames nhận", last_update: "Cập nhật cách đây", valid_targets: "Target hợp lệ", zones_occupied: "Vùng có người", targets_detail: "Chi tiết Target", detected: "Đã phát hiện", recent: "Vừa mất dấu", free: "Trống", x_coord: "X", y_coord: "Y", speed: "Tốc độ", distance: "Khoảng cách", angle: "Góc", zones: "Zones", edit_zones: "Sửa zones", zone_editor_title: "Chỉnh sửa tọa độ zone (mm)", cancel: "Hủy", reset_default: "Mặc định", save_and_apply: "Lưu & Áp dụng", occupied: "CÓ NGƯỜI", empty: "TRỐNG", zone_saved: "Zones đã được cập nhật thành công", zone_save_error: "Lỗi khi lưu zones", ws_reconnecting: "Đang kết nối lại WebSocket…", edit_zone_x1: "X1", edit_zone_y1: "Y1", edit_zone_x2: "X2", edit_zone_y2: "Y2", }, en: { page_title: "ESP32 LD2453 — Radar Web Visualizer", app_title: "LD2453 Radar", app_subtitle: "ESP32-S3 — Zone Presence Detection", toggle_theme: "Toggle light/dark theme", esp32_connecting: "ESP32: Connecting…", esp32_connected: "ESP32: Connected", esp32_disconnected: "ESP32: Disconnected", ws_connecting: "WS: Connecting…", ws_connected: "WS: Connected", ws_disconnected: "WS: Disconnected", radar_view: "Radar View", sweep_effect: "Sweep effect", target_trails: "Target trails", show_labels: "Show labels", legend: "Legend", target_1: "Target 1 (Slot 1)", target_2: "Target 2 (Slot 2)", target_3: "Target 3 (Slot 3)", zone_1: "Zone 1", zone_2: "Zone 2", zone_3: "Zone 3", seq: "Seq", targets: "Targets", statistics: "Statistics", frames_received: "Frames received", last_update: "Last update", valid_targets: "Valid targets", zones_occupied: "Zones occupied", targets_detail: "Target details", detected: "Detected", recent: "Recently seen", free: "Free", x_coord: "X", y_coord: "Y", speed: "Speed", distance: "Distance", angle: "Angle", zones: "Zones", edit_zones: "Edit zones", zone_editor_title: "Edit zone coordinates (mm)", cancel: "Cancel", reset_default: "Reset defaults", save_and_apply: "Save & Apply", occupied: "OCCUPIED", empty: "EMPTY", zone_saved: "Zones updated successfully", zone_save_error: "Failed to save zones", ws_reconnecting: "Reconnecting WebSocket…", edit_zone_x1: "X1", edit_zone_y1: "Y1", edit_zone_x2: "X2", edit_zone_y2: "Y2", }, }; /* ---------- Global state ---------- */ const State = { lang: localStorage.getItem("lang") || "vi", theme: localStorage.getItem("theme") || "dark", esp32Connected: false, browserWsConnected: false, radar: { node_id: "RADAR_A", sequence: 0, timestamp_ms: 0, target_count: 0, targets: [ { slot:1, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null }, { slot:2, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null }, { slot:3, valid:false, x_mm:0, y_mm:0, speed_cm_s:0, distance_mm:0, angle_deg:0, last_seen:null }, ], zones: [ { id:1, x1:-4000, y1:1, x2:-1, y2:4000, occupied:false }, { id:2, x1:1, y1:1, x2:4000, y2:4000, occupied:false }, { id:3, x1:-4001, y1:4001, x2:4001, y2:6000, occupied:false }, ], frames_received: 0, last_update: null, }, /* Trails for each target: [{x,y,age}, ...] oldest → newest */ trails: [[], [], []], /* Options toggled by the user */ options: { sweep: true, trails: true, labels: true, }, }; const DEFAULT_ZONES = [ { id:1, x1:-4000, y1:1, x2:-1, y2:4000 }, { id:2, x1:1, y1:1, x2:4000, y2:4000 }, { id:3, x1:-4001, y1:4001, x2:4001, y2:6000 }, ]; /* Radar coordinate → pixel mapping config (mm) */ const MAP = { X_MIN: -5000, X_MAX: 5000, Y_MIN: 0, Y_MAX: 7000, MARGIN_X: 70, MARGIN_Y: 70, }; /* ---------- Utilities ---------- */ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); function i18n(key) { return I18N[State.lang][key] || I18N.en[key] || key; } function applyI18n() { document.documentElement.lang = State.lang; document.body.dataset.lang = State.lang; document.title = i18n("page_title"); $$("[data-i18n]").forEach(el => { const k = el.getAttribute("data-i18n"); el.textContent = i18n(k); }); $$("[data-i18n-title]").forEach(el => { const k = el.getAttribute("data-i18n-title"); el.title = i18n(k); }); } function applyTheme() { document.body.dataset.theme = State.theme; } function toast(message, type = "info") { const container = $("#toastContainer"); const el = document.createElement("div"); el.className = "toast " + type; el.textContent = message; container.appendChild(el); setTimeout(() => el.remove(), 3200); } /* ---------- Canvas rendering ---------- */ const canvas = $("#radarCanvas"); const ctx = canvas.getContext("2d"); let DPR = window.devicePixelRatio || 1; let canvasW = 0, canvasH = 0; function resizeCanvas() { const wrap = canvas.parentElement; const rect = wrap.getBoundingClientRect(); DPR = window.devicePixelRatio || 1; canvasW = rect.width; canvasH = Math.max(rect.height, 520); canvas.style.width = canvasW + "px"; canvas.style.height = canvasH + "px"; canvas.width = canvasW * DPR; canvas.height = canvasH * DPR; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } /* Convert radar (x_mm, y_mm) → pixel (px, py) on canvas. Radar origin is at the BOTTOM CENTER (Y grows upward, X is left-right). Canvas origin (0,0) is TOP LEFT. */ function mmToPixel(x_mm, y_mm) { const innerW = canvasW - 2 * MAP.MARGIN_X; const innerH = canvasH - 2 * MAP.MARGIN_Y; const xRatio = (x_mm - MAP.X_MIN) / (MAP.X_MAX - MAP.X_MIN); const yRatio = 1.0 - (y_mm - MAP.Y_MIN) / (MAP.Y_MAX - MAP.Y_MIN); return { x: MAP.MARGIN_X + xRatio * innerW, y: MAP.MARGIN_Y + yRatio * innerH, }; } function getCssVar(name) { return getComputedStyle(document.body).getPropertyValue(name).trim(); } /* Draw radar grid, axes, rings */ function drawBackground() { ctx.clearRect(0, 0, canvasW, canvasH); const gridColor = getCssVar("--bg-elev-1"); const axisColor = getCssVar("--accent-blue"); const labelColor = getCssVar("--text-dim"); /* Grid lines every 1m (1000 mm) */ ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.font = "11px 'JetBrains Mono', Consolas, monospace"; ctx.fillStyle = labelColor; /* Vertical lines (X) */ for (let x_mm = MAP.X_MIN; x_mm <= MAP.X_MAX; x_mm += 1000) { const p1 = mmToPixel(x_mm, MAP.Y_MIN); const p2 = mmToPixel(x_mm, MAP.Y_MAX); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); if (x_mm !== 0) { ctx.textAlign = "center"; ctx.fillText(`${(x_mm/1000).toFixed(0)}m`, p1.x, canvasH - 30); } } /* Horizontal lines (Y) */ for (let y_mm = MAP.Y_MIN; y_mm <= MAP.Y_MAX; y_mm += 1000) { const p1 = mmToPixel(MAP.X_MIN, y_mm); const p2 = mmToPixel(MAP.X_MAX, y_mm); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); ctx.textAlign = "right"; ctx.fillText(`${(y_mm/1000).toFixed(0)}m`, 44, p1.y + 4); } /* Concentric range rings (every 1m) from radar position */ const radar = mmToPixel(0, 0); ctx.strokeStyle = gridColor; ctx.setLineDash([3, 4]); for (let r = 1000; r <= MAP.Y_MAX; r += 1000) { const edge = mmToPixel(0, r); const radiusPx = Math.abs(edge.y - radar.y); ctx.beginPath(); ctx.arc(radar.x, radar.y, radiusPx, Math.PI, 0); ctx.stroke(); } ctx.setLineDash([]); /* Y-axis (central axis) */ const axisTop = mmToPixel(0, MAP.Y_MAX); ctx.strokeStyle = axisColor; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(radar.x, radar.y); ctx.lineTo(axisTop.x, axisTop.y); ctx.stroke(); ctx.setLineDash([]); /* Radar icon */ const radarColor = getCssVar("--accent-red"); ctx.fillStyle = radarColor; ctx.beginPath(); ctx.arc(radar.x, radar.y, 10, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,.7)"; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = radarColor; ctx.textAlign = "center"; ctx.font = "bold 11px 'JetBrains Mono', Consolas, monospace"; ctx.fillText("RADAR", radar.x, radar.y + 26); } /* Sweep animation: rotating cone */ let sweepAngle = 0; function drawSweep(dt) { if (!State.options.sweep) return; const sweepSpeed = 1.4; // rad/s sweepAngle += dt * sweepSpeed; sweepAngle = sweepAngle % (2 * Math.PI); const radar = mmToPixel(0, 0); const outer = mmToPixel(0, MAP.Y_MAX); const radiusPx = Math.abs(outer.y - radar.y); const color = getCssVar("--accent-blue"); const startA = sweepAngle - Math.PI - 0.35; const endA = sweepAngle - Math.PI; /* Only draw if within top semi-circle (Y >= 0 → angles -π → 0 in canvas) */ const grad = ctx.createRadialGradient(radar.x, radar.y, 0, radar.x, radar.y, radiusPx); grad.addColorStop(0, hexToRgba(color, 0.45)); grad.addColorStop(0.5, hexToRgba(color, 0.18)); grad.addColorStop(1, hexToRgba(color, 0.0)); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(radar.x, radar.y); ctx.arc(radar.x, radar.y, radiusPx, startA, endA); ctx.closePath(); ctx.fill(); /* Sweep line */ const lineGrad = ctx.createLinearGradient( radar.x, radar.y, radar.x + Math.cos(endA) * radiusPx, radar.y + Math.sin(endA) * radiusPx ); lineGrad.addColorStop(0, color); lineGrad.addColorStop(1, hexToRgba(color, 0)); ctx.strokeStyle = lineGrad; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(radar.x, radar.y); ctx.lineTo(radar.x + Math.cos(endA) * radiusPx, radar.y + Math.sin(endA) * radiusPx); ctx.stroke(); } function hexToRgba(hex, alpha) { if (!hex.startsWith("#")) return `rgba(255,255,255,${alpha})`; const h = hex.slice(1); const bigint = parseInt(h.length === 3 ? h.split("").map(c => c+c).join("") : h, 16); const r = (bigint >> 16) & 255; const g = (bigint >> 8) & 255; const b = bigint & 255; return `rgba(${r},${g},${b},${alpha})`; } /* Zones (rectangles) */ function drawZones(zones) { const zColors = [getCssVar("--z1"), getCssVar("--z2"), getCssVar("--z3")]; const zStrong = [getCssVar("--z1-strong"), getCssVar("--z2-strong"), getCssVar("--z3-strong")]; zones.forEach((z, i) => { const p1 = mmToPixel(z.x1, z.y1); const p2 = mmToPixel(z.x2, z.y2); const left = Math.min(p1.x, p2.x); const top = Math.min(p1.y, p2.y); const w = Math.abs(p2.x - p1.x); const h = Math.abs(p2.y - p1.y); const fill = z.occupied ? hexToRgba(zStrong[i], 0.45) : hexToRgba(zColors[i], 0.15); const edge = z.occupied ? zStrong[i] : zColors[i]; ctx.fillStyle = fill; ctx.strokeStyle = edge; ctx.lineWidth = z.occupied ? 2.5 : 1.5; if (!z.occupied) ctx.setLineDash([6, 4]); roundRect(ctx, left, top, w, h, 8); ctx.fill(); ctx.stroke(); ctx.setLineDash([]); /* Label in the center of zone */ if (State.options.labels) { const cx = left + w / 2; const cy = top + h / 2; ctx.save(); ctx.font = "bold 11px 'Inter', system-ui, sans-serif"; ctx.textAlign = "center"; const occText = z.occupied ? i18n("occupied") : i18n("empty"); ctx.fillStyle = z.occupied ? "#ffffff" : hexToRgba("#000000", 0.55); const label = `Z${z.id} · ${occText}`; const metrics = ctx.measureText(label); const labelW = metrics.width + 12; const labelH = 20; ctx.fillStyle = z.occupied ? hexToRgba(zStrong[i], 0.92) : hexToRgba("#ffffff", 0.85); roundRect(ctx, cx - labelW/2, cy - labelH/2, labelW, labelH, 6); ctx.fill(); ctx.fillStyle = z.occupied ? "#ffffff" : zStrong[i]; ctx.fillText(label, cx, cy + 4); ctx.restore(); } }); } /* Rounded rectangle helper */ function roundRect(c, x, y, w, h, r) { r = Math.min(r, w/2, h/2); c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath(); } /* Target trails + dots + velocity arrows */ function drawTargets(targets, dt) { const tColors = [getCssVar("--t1"), getCssVar("--t2"), getCssVar("--t3")]; /* Update trails */ targets.forEach((t, idx) => { const trail = State.trails[idx]; if (t.valid) { trail.push({ x: t.x_mm, y: t.y_mm, age: 0 }); if (trail.length > 60) trail.shift(); } /* Age each point */ for (let i = trail.length - 1; i >= 0; i--) { trail[i].age += dt; if (trail[i].age > 3.5) trail.splice(i, 1); } }); /* Draw trails */ if (State.options.trails) { targets.forEach((_t, idx) => { const trail = State.trails[idx]; if (trail.length < 2) return; const color = tColors[idx]; for (let i = 1; i < trail.length; i++) { const a = trail[i - 1]; const b = trail[i]; const alpha = Math.max(0, 1 - b.age / 3.5); const pa = mmToPixel(a.x, a.y); const pb = mmToPixel(b.x, b.y); ctx.strokeStyle = hexToRgba(color, alpha * 0.6); ctx.lineWidth = 2 + alpha * 1.5; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke(); } }); } /* Draw target dots, arrows, labels */ targets.forEach((t, idx) => { const nowSec = performance.now() / 1000; if (!t.valid) { /* Faded ghost if recently seen */ if (t.last_seen != null && t.last_seen < 3.5) { const p = mmToPixel(t.x_mm, t.y_mm); const alpha = 1 - t.last_seen / 3.5; ctx.strokeStyle = hexToRgba(tColors[idx], alpha * 0.5); ctx.setLineDash([3, 3]); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } return; } const p = mmToPixel(t.x_mm, t.y_mm); const color = tColors[idx]; /* Outer glow */ const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 28); glow.addColorStop(0, hexToRgba(color, 0.4)); glow.addColorStop(1, hexToRgba(color, 0)); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(p.x, p.y, 28, 0, Math.PI * 2); ctx.fill(); /* Dot */ ctx.fillStyle = color; ctx.strokeStyle = "rgba(255,255,255,.85)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, 9, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); /* Velocity arrow */ if (Math.abs(t.speed_cm_s) >= 1 && t.distance_mm > 1) { const speedScale = 2.0; // px per cm/s const ux = t.x_mm / t.distance_mm; const uy = t.y_mm / t.distance_mm; /* Speed sign: positive = moving towards radar? Match convention in backend. We'll render direction assuming speed > 0 = forward along (ux,uy) */ const len = t.speed_cm_s * speedScale; const ax = p.x + ux * len; /* In canvas Y is inverted; uy in radar space is up → in canvas negative Y */ const ay = p.y - uy * len; drawArrow(ctx, p.x, p.y, ax, ay, color, 2); } /* Label */ if (State.options.labels) { const lang = State.lang; const lines = [ `T${t.slot}`, `${t.x_mm >= 0 ? "+":""}${t.x_mm}, ${t.y_mm} mm`, `${t.speed_cm_s >= 0 ? "+":""}${t.speed_cm_s} cm/s`, ]; ctx.save(); ctx.font = "bold 11px 'JetBrains Mono', Consolas, monospace"; const maxW = Math.max(...lines.map(l => ctx.measureText(l).width)); const labelW = maxW + 14; const labelH = lines.length * 14 + 10; const lx = p.x + 16; const ly = p.y - labelH - 4; ctx.fillStyle = hexToRgba("#000000", 0.72); ctx.strokeStyle = hexToRgba(color, 0.9); ctx.lineWidth = 1.5; roundRect(ctx, lx, ly, labelW, labelH, 8); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#ffffff"; ctx.textAlign = "left"; lines.forEach((line, i) => { ctx.fillText(line, lx + 7, ly + 16 + i * 14); }); ctx.restore(); } }); } /* Draw a nice arrow from (x1,y1) to (x2,y2) */ function drawArrow(c, x1, y1, x2, y2, color, lw) { const angle = Math.atan2(y2 - y1, x2 - x1); const headLen = 8; c.strokeStyle = color; c.fillStyle = color; c.lineWidth = lw; c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke(); c.beginPath(); c.moveTo(x2, y2); c.lineTo(x2 - headLen * Math.cos(angle - Math.PI/6), y2 - headLen * Math.sin(angle - Math.PI/6)); c.lineTo(x2 - headLen * Math.cos(angle + Math.PI/6), y2 - headLen * Math.sin(angle + Math.PI/6)); c.closePath(); c.fill(); } /* ---------- Side panels ---------- */ function refreshPanels() { const s = State.radar; /* Node info overlay */ $("#nodeId").textContent = s.node_id; $("#seqNum").textContent = s.sequence.toLocaleString(); $("#tgtCount").textContent = `${s.target_count}/3`; /* Stats */ $("#statFrames").textContent = s.frames_received.toLocaleString(); $("#statLastUpdate").textContent = s.last_update != null ? s.last_update.toFixed(1) : "—"; const validCt = s.targets.filter(t => t.valid).length; $("#statValid").textContent = `${validCt} / 3`; const occCt = s.zones.filter(z => z.occupied).length; $("#statZones").textContent = `${occCt} / 3`; /* Targets list */ const tl = $("#targetsList"); tl.innerHTML = ""; s.targets.forEach((t, idx) => { const row = document.createElement("div"); let cls = "target-row"; let statusKey = "free"; if (t.valid) { cls += ""; statusKey = "detected"; } else if (t.last_seen != null && t.last_seen < 3.5) { cls += " recent"; statusKey = "recent"; } else { cls += " invalid"; } row.className = cls; const distanceKm = t.distance_mm ? (t.distance_mm/1000).toFixed(2) : "—"; const angleTxt = (t.angle_deg != null) ? `${t.angle_deg.toFixed(1)}°` : "—"; const speedTxt = (t.speed_cm_s != null) ? `${t.speed_cm_s >= 0 ? "+":""}${t.speed_cm_s}` : "—"; row.innerHTML = `
T${t.slot}
${i18n(statusKey)}
${i18n("x_coord")}: ${t.x_mm} mm
${i18n("y_coord")}: ${t.y_mm} mm
${i18n("speed")}: ${speedTxt} cm/s
${i18n("distance")}: ${distanceKm} m
${i18n("angle")}: ${angleTxt}
`; tl.appendChild(row); }); /* Zones list */ const zl = $("#zonesList"); zl.innerHTML = ""; s.zones.forEach((z, idx) => { const row = document.createElement("div"); row.className = "zone-row" + (z.occupied ? " occupied" : ""); row.innerHTML = `
${i18n("zone_"+(idx+1))} · Z${z.id}
[${z.x1}, ${z.y1}] → [${z.x2}, ${z.y2}]
${z.occupied ? i18n("occupied") : i18n("empty")}
`; zl.appendChild(row); }); } /* ---------- Zone editor ---------- */ function buildEditorInputs() { const grid = $("#editorGrid"); grid.innerHTML = ""; State.radar.zones.forEach((z, idx) => { const block = document.createElement("div"); block.className = "zone-editor-block"; block.innerHTML = `
`; grid.appendChild(block); }); } function collectEditorPayload() { const inputs = $$("#editorGrid input"); const zones = State.radar.zones.map(z => ({ id: z.id, x1: z.x1, y1: z.y1, x2: z.x2, y2: z.y2 })); inputs.forEach(inp => { const zi = parseInt(inp.dataset.zone); const f = inp.dataset.field; const v = parseInt(inp.value); if (!Number.isNaN(v)) zones[zi][f] = v; }); return zones; } /* ---------- WebSocket to backend + Polling fallback ---------- */ let wsReconnectTimer = null; let fallbackPollTimer = null; let _activeWs = null; async function pollFallbackOnce() { /* Try REST /api/state as a safety net even while WebSocket might be blocked */ try { const r = await fetch("/api/state", { cache: "no-store" }); if (r.ok) { const d = await r.json(); onBackendFrame(d); } } catch (_) {} } function startFallbackPoller() { if (fallbackPollTimer) return; /* Every 2 s also poll via REST, to cover browser cases where WS silently drops */ fallbackPollTimer = setInterval(pollFallbackOnce, 2000); } function connectWS() { const proto = (location.protocol === "https:") ? "wss" : "ws"; const url = `${proto}://${location.host}/ws`; updateWsStatus("connecting"); console.info("[Radar] Connecting WebSocket:", url); try { _activeWs = new WebSocket(url); } catch (e) { console.warn("[Radar] WebSocket construct error:", e); scheduleReconnect(); return; } _activeWs.onopen = () => { State.browserWsConnected = true; updateWsStatus("ok"); console.info("[Radar] WebSocket connected"); /* Ask for first state immediately via REST too, in case the WS initial send was lost */ pollFallbackOnce(); }; _activeWs.onmessage = (ev) => { try { const data = JSON.parse(ev.data); onBackendFrame(data); } catch (e) { console.warn("[Radar] WS parse error", e); } }; _activeWs.onclose = (ev) => { console.warn("[Radar] WebSocket closed (code=" + ev.code + " reason=" + ev.reason + ")"); State.browserWsConnected = false; updateWsStatus("err"); if (wsReconnectTimer == null) toast(i18n("ws_reconnecting"), "warning"); scheduleReconnect(); }; _activeWs.onerror = (ev) => { console.warn("[Radar] WebSocket error, will reconnect shortly"); try { if (_activeWs) _activeWs.close(); } catch (_) {} }; } function scheduleReconnect() { if (wsReconnectTimer) return; wsReconnectTimer = setTimeout(() => { wsReconnectTimer = null; connectWS(); }, 2000); } function updateWsStatus(kind) { const chip = $("#statusWs"); const dot = chip.querySelector(".status-dot"); const txt = chip.querySelector("span:last-child"); dot.className = "status-dot " + (kind === "ok" ? "status-ok" : kind === "err" ? "status-err" : "status-warn"); txt.textContent = kind === "ok" ? i18n("ws_connected") : kind === "err" ? i18n("ws_disconnected") : i18n("ws_connecting"); } function updateEsp32Status(connected) { const chip = $("#statusEsp32"); const dot = chip.querySelector(".status-dot"); const txt = chip.querySelector("span:last-child"); dot.className = "status-dot " + (connected ? "status-ok" : "status-err"); txt.textContent = connected ? i18n("esp32_connected") : i18n("esp32_disconnected"); } /* Called on every broadcast frame from backend */ function onBackendFrame(data) { State.esp32Connected = !!data.esp32_connected; updateEsp32Status(State.esp32Connected); State.radar.node_id = data.node_id || State.radar.node_id; State.radar.sequence = data.sequence | 0; State.radar.timestamp_ms = data.timestamp_ms | 0; State.radar.target_count = data.target_count | 0; State.radar.frames_received = data.frames_received | 0; State.radar.last_update = data.last_update; if (Array.isArray(data.targets)) { State.radar.targets = data.targets.map(t => ({ slot: t.slot | 0, valid: !!t.valid, x_mm: t.x_mm | 0, y_mm: t.y_mm | 0, speed_cm_s: t.speed_cm_s | 0, distance_mm: Number(t.distance_mm) || 0, angle_deg: Number(t.angle_deg) || 0, last_seen: (t.last_seen == null) ? null : Number(t.last_seen), })); } if (Array.isArray(data.zones)) { data.zones.forEach((z, i) => { if (i >= State.radar.zones.length) return; const s = State.radar.zones[i]; s.id = z.id | 0; s.x1 = z.x1 | 0; s.y1 = z.y1 | 0; s.x2 = z.x2 | 0; s.y2 = z.y2 | 0; s.occupied = !!z.occupied; }); } refreshPanels(); } /* ---------- Animation loop ---------- */ let lastFrameTs = performance.now(); function loop() { const now = performance.now(); const dt = Math.min(0.1, (now - lastFrameTs) / 1000); lastFrameTs = now; drawBackground(); drawZones(State.radar.zones); drawSweep(dt); drawTargets(State.radar.targets, dt); requestAnimationFrame(loop); } /* ---------- Wiring up ---------- */ function initUI() { /* Theme toggle */ $("#btnTheme").addEventListener("click", () => { State.theme = State.theme === "dark" ? "light" : "dark"; localStorage.setItem("theme", State.theme); applyTheme(); }); /* Language toggle */ $("#btnLang").addEventListener("click", () => { State.lang = State.lang === "vi" ? "en" : "vi"; localStorage.setItem("lang", State.lang); applyI18n(); refreshPanels(); buildEditorInputs(); }); /* Tool toggles */ $("#chkSweep").addEventListener("change", e => State.options.sweep = e.target.checked); $("#chkTrails").addEventListener("change", e => State.options.trails = e.target.checked); $("#chkLabels").addEventListener("change", e => State.options.labels = e.target.checked); /* Zone editor */ $("#btnEditZones").addEventListener("click", () => { buildEditorInputs(); $("#zoneEditor").hidden = false; }); $("#btnCancelZones").addEventListener("click", () => { $("#zoneEditor").hidden = true; }); $("#btnResetZones").addEventListener("click", () => { DEFAULT_ZONES.forEach((def, idx) => { const z = State.radar.zones[idx]; z.x1 = def.x1; z.y1 = def.y1; z.x2 = def.x2; z.y2 = def.y2; }); buildEditorInputs(); }); $("#zoneEditor").addEventListener("submit", async (e) => { e.preventDefault(); const payload = collectEditorPayload(); try { const res = await fetch("/api/zones", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error("HTTP " + res.status); toast(i18n("zone_saved"), "success"); $("#zoneEditor").hidden = true; } catch (err) { console.error(err); toast(i18n("zone_save_error") + ": " + err.message, "error"); } }); /* Canvas resize */ window.addEventListener("resize", resizeCanvas); resizeCanvas(); } /* ---------- Bootstrap ---------- */ document.addEventListener("DOMContentLoaded", () => { applyTheme(); applyI18n(); initUI(); refreshPanels(); /* Pre-load state via REST immediately (before WS even connects) */ pollFallbackOnce(); /* Start safety-net REST poller every 2 s in addition to WebSocket */ startFallbackPoller(); connectWS(); loop(); console.info("%c[Radar Visualizer ready]", "color:#89b4fa; font-weight:bold; padding:4px 8px; border-radius:6px; background:#181825;"); console.info(" - Open DevTools → Network → WS → /ws to see live messages"); console.info(" - If canvas is empty: check Console for errors, then check /api/state in browser"); });