/* ============================================================ 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", tab_radar: "Radar", tab_history: "Lịch sử", history_title: "Lịch sử phát hiện", history_summary_empty: "Chưa có dữ liệu — hãy vẫy tay trước radar để tạo bản ghi đầu tiên.", history_summary_with: "Tổng {total} lần detect · {uniq} slot khác nhau trong ngày.", history_date: "Ngày", history_hour: "Giờ", history_refresh: "Tải lại", history_hour_bucket: "{hh}h — {n} lần detect", history_no_data: "Không có bản ghi nào trong khoảng thời gian đã chọn", history_detected: "Phát hiện", history_xy: "Tọa độ", }, 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", tab_radar: "Radar", tab_history: "History", history_title: "Detection history", history_summary_empty: "No records yet — wave in front of the radar to create the first row.", history_summary_with: "Total {total} detections · {uniq} different slots for the day.", history_date: "Date", history_hour: "Hour", history_refresh: "Refresh", history_hour_bucket: "{hh}:00 — {n} detections", history_no_data: "No detections in the selected window", history_detected: "Detected", history_xy: "Coordinates", }, }; /* ---------- 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 = `