Files
2026-09-13 17:24:03 +07:00

1027 lines
37 KiB
JavaScript

/* ============================================================
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 = `
<div class="target-slot s${idx+1}">T${t.slot}</div>
<div class="target-detail">
<span class="status ${statusKey}">${i18n(statusKey)}</span>
<div class="target-metrics">
<div>${i18n("x_coord")}: <b>${t.x_mm} mm</b></div>
<div>${i18n("y_coord")}: <b>${t.y_mm} mm</b></div>
<div>${i18n("speed")}: <b>${speedTxt} cm/s</b></div>
<div>${i18n("distance")}: <b>${distanceKm} m</b></div>
<div style="grid-column: span 2;">${i18n("angle")}: <b>${angleTxt}</b></div>
</div>
</div>`;
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 = `
<div class="zone-swatch z${idx+1}"></div>
<div class="zone-info">
<div class="zone-name">${i18n("zone_"+(idx+1))} · Z${z.id}</div>
<div class="zone-coord">[${z.x1}, ${z.y1}] → [${z.x2}, ${z.y2}]</div>
</div>
<div class="zone-badge ${z.occupied ? "occupied" : "empty"}">${z.occupied ? i18n("occupied") : i18n("empty")}</div>
`;
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 = `
<label>${i18n("zone_"+(idx+1))} — Z${z.id}</label>
<div class="coord-grid">
<div class="coord-field">
<label>${i18n("edit_zone_x1")}</label>
<input type="number" data-zone="${idx}" data-field="x1" value="${z.x1}">
</div>
<div class="coord-field">
<label>${i18n("edit_zone_y1")}</label>
<input type="number" data-zone="${idx}" data-field="y1" value="${z.y1}">
</div>
<div class="coord-field">
<label>${i18n("edit_zone_x2")}</label>
<input type="number" data-zone="${idx}" data-field="x2" value="${z.x2}">
</div>
<div class="coord-field">
<label>${i18n("edit_zone_y2")}</label>
<input type="number" data-zone="${idx}" data-field="y2" value="${z.y2}">
</div>
</div>`;
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);
}
/* ---------- Tab wiring + History tab ---------- */
function switchTab(tabName) {
if (!["radar", "history"].includes(tabName)) tabName = "radar";
State.activeTab = tabName;
localStorage.setItem("activeTab", tabName);
$$(".tab-btn").forEach(b => {
const active = (b.dataset.tab === tabName);
b.classList.toggle("tab-active", active);
b.setAttribute("aria-selected", String(active));
});
$$("[data-tab-panel]").forEach(p => {
p.hidden = (p.dataset.tabPanel !== tabName);
});
if (tabName === "history") refreshHistory();
}
function pad2(n) { return String(n).padStart(2, "0"); }
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`;
}
function getHistoryFilters() {
const dateEl = $("#historyDate");
const hs = $("#hourStart").value;
const he = $("#hourEnd").value;
return {
date: dateEl && dateEl.value ? dateEl.value : todayIso(),
hour_start: hs === "" || Number.isNaN(+hs) ? null : Math.min(23, Math.max(0, +hs | 0)),
hour_end: he === "" || Number.isNaN(+he) ? null : Math.min(23, Math.max(0, +he | 0)),
};
}
async function refreshHistory() {
const summary = $("#historySummary");
const listEl = $("#historyList");
const hourlyEl = $("#historyHourly");
if (!listEl || !summary) return;
const f = getHistoryFilters();
const params = new URLSearchParams();
params.set("date", f.date);
if (f.hour_start != null) params.set("hour_start", String(f.hour_start));
if (f.hour_end != null) params.set("hour_end", String(f.hour_end));
params.set("limit", "500");
try {
listEl.innerHTML = "";
hourlyEl.innerHTML = "";
summary.textContent = i18n("history_summary_empty");
const res = await fetch("/api/history?" + params.toString());
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
renderHistory(data);
} catch (err) {
console.error(err);
listEl.innerHTML = `<div class="history-empty">${err.message || err}</div>`;
}
}
function renderHistory(data) {
const summary = $("#historySummary");
const listEl = $("#historyList");
const hourlyEl = $("#historyHourly");
if (!summary || !listEl || !hourlyEl) return;
const total = (data.total_detections | 0);
const uniq = (data.unique_slots | 0);
if (total <= 0) {
summary.textContent = i18n("history_summary_empty");
} else {
summary.textContent = i18n("history_summary_with")
.replace("{total}", String(total))
.replace("{uniq}", String(uniq));
}
/* 24-hour bars */
hourlyEl.innerHTML = "";
const hourly = Array.isArray(data.hourly) ? data.hourly : [];
const maxCount = hourly.reduce((m, h) => Math.max(m, h.count | 0), 0);
hourly.forEach(h => {
const hh = pad2(h.hour | 0);
const count = h.count | 0;
const pct = maxCount > 0 ? Math.max(4, Math.round((count / maxCount) * 100)) : 0;
const el = document.createElement("div");
el.className = "hour-bar";
el.dataset.count = String(count);
el.style.height = (count > 0 ? pct : 2) + "%";
el.title = i18n("history_hour_bucket")
.replace("{hh}", hh)
.replace("{n}", String(count));
hourlyEl.appendChild(el);
});
/* Row list */
const rows = Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
listEl.innerHTML = `<div class="history-empty">${i18n("history_no_data")}</div>`;
return;
}
const frag = document.createDocumentFragment();
rows.forEach(r => {
const iso = String(r.ts_local || "");
const dt = new Date(iso.includes("T") ? iso : new Date((r.unixtime|0) * 1000).toISOString());
const hh = pad2(dt.getHours());
const mm = pad2(dt.getMinutes());
const ss = pad2(dt.getSeconds());
const slot = (r.slot | 0);
const x = (r.x_mm | 0);
const y = (r.y_mm | 0);
const dist = Number(r.distance_mm) || 0;
const ang = Number(r.angle_deg) || 0;
const spd = (r.speed_cm_s | 0);
const row = document.createElement("div");
row.className = "detect";
row.innerHTML = `
<div class="detect-time">${hh}:${mm}:${ss}</div>
<div class="detect-slot s${Math.min(3, Math.max(1, slot))}">T${slot}</div>
<div class="detect-meta">
<span class="kv"><span>${i18n("history_xy")}:</span><b>(${x}, ${y})mm</b></span>
<span class="kv"><span>${i18n("angle")}:</span><b>${ang.toFixed(1)}°</b></span>
<span class="kv"><span>${i18n("speed")}:</span><b>${spd > 0 ? "+" : ""}${spd} cm/s</b></span>
</div>
<div class="detect-dist">${(dist/1000).toFixed(2)} m</div>
`;
frag.appendChild(row);
});
listEl.appendChild(frag);
}
/* ---------- 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();
});
/* 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);
/* Tabs: Radar / History */
$$(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
});
State.activeTab = localStorage.getItem("activeTab") || "radar";
switchTab(State.activeTab);
/* History filters */
const dateEl = $("#historyDate");
if (dateEl) dateEl.value = todayIso();
if (dateEl) dateEl.addEventListener("change", () => refreshHistory());
const hs = $("#hourStart"), he = $("#hourEnd");
if (hs) hs.addEventListener("change", () => refreshHistory());
if (he) he.addEventListener("change", () => refreshHistory());
const btnRef = $("#btnRefreshHistory");
if (btnRef) btnRef.addEventListener("click", () => refreshHistory());
/* 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");
});