change to web visualizer
This commit is contained in:
1 parent
87be72b52b
commit
417ced90fa
9 files changed
+2580
-680
No files matched your search
@@ -0,0 +1,850 @@
|
||||
/* ============================================================
|
||||
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 t(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 = t("page_title");
|
||||
$$("[data-i18n]").forEach(el => {
|
||||
const k = el.getAttribute("data-i18n");
|
||||
el.textContent = t(k);
|
||||
});
|
||||
$$("[data-i18n-title]").forEach(el => {
|
||||
const k = el.getAttribute("data-i18n-title");
|
||||
el.title = t(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 ? t("occupied") : t("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}">${t(statusKey)}</span>
|
||||
<div class="target-metrics">
|
||||
<div>${t("x_coord")}: <b>${t.x_mm} mm</b></div>
|
||||
<div>${t("y_coord")}: <b>${t.y_mm} mm</b></div>
|
||||
<div>${t("speed")}: <b>${speedTxt} cm/s</b></div>
|
||||
<div>${t("distance")}: <b>${distanceKm} m</b></div>
|
||||
<div style="grid-column: span 2;">${t("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">${t("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 ? t("occupied") : t("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>${t("zone_"+(idx+1))} — Z${z.id}</label>
|
||||
<div class="coord-grid">
|
||||
<div class="coord-field">
|
||||
<label>${t("edit_zone_x1")}</label>
|
||||
<input type="number" data-zone="${idx}" data-field="x1" value="${z.x1}">
|
||||
</div>
|
||||
<div class="coord-field">
|
||||
<label>${t("edit_zone_y1")}</label>
|
||||
<input type="number" data-zone="${idx}" data-field="y1" value="${z.y1}">
|
||||
</div>
|
||||
<div class="coord-field">
|
||||
<label>${t("edit_zone_x2")}</label>
|
||||
<input type="number" data-zone="${idx}" data-field="x2" value="${z.x2}">
|
||||
</div>
|
||||
<div class="coord-field">
|
||||
<label>${t("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 ---------- */
|
||||
let wsReconnectTimer = null;
|
||||
|
||||
function connectWS() {
|
||||
const proto = (location.protocol === "https:") ? "wss" : "ws";
|
||||
const url = `${proto}://${location.host}/ws`;
|
||||
updateWsStatus("connecting");
|
||||
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); }
|
||||
catch (e) { scheduleReconnect(); return; }
|
||||
|
||||
ws.onopen = () => {
|
||||
State.browserWsConnected = true;
|
||||
updateWsStatus("ok");
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
onBackendFrame(data);
|
||||
} catch (e) { console.warn("WS parse error", e); }
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
State.browserWsConnected = false;
|
||||
updateWsStatus("err");
|
||||
toast(t("ws_reconnecting"), "warning");
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
try { ws.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" ? t("ws_connected") : kind === "err" ? t("ws_disconnected") : t("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 ? t("esp32_connected") : t("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(t("zone_saved"), "success");
|
||||
$("#zoneEditor").hidden = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast(t("zone_save_error") + ": " + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
/* Canvas resize */
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
}
|
||||
|
||||
/* ---------- Bootstrap ---------- */
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyTheme();
|
||||
applyI18n();
|
||||
initUI();
|
||||
refreshPanels();
|
||||
/* Try to pre-load state via REST in case WS is slow */
|
||||
fetch("/api/state").then(r => r.ok ? r.json() : null).then(data => {
|
||||
if (data) onBackendFrame(data);
|
||||
}).catch(() => {});
|
||||
connectWS();
|
||||
loop();
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
/* ============================================================
|
||||
ESP32 LD2453 — Radar Web Visualizer
|
||||
Beautiful UI with light/dark themes, responsive layout
|
||||
============================================================ */
|
||||
|
||||
/* ---------- Design tokens ---------- */
|
||||
:root {
|
||||
/* Catppuccin Mocha (dark) */
|
||||
--bg: #1e1e2e;
|
||||
--bg-elev-1: #313244;
|
||||
--bg-elev-2: #45475a;
|
||||
--surface: #181825;
|
||||
--border: #45475a;
|
||||
--text: #cdd6f4;
|
||||
--text-muted: #a6adc8;
|
||||
--text-dim: #6c7086;
|
||||
|
||||
--accent-blue: #89b4fa;
|
||||
--accent-mauve: #cba6f7;
|
||||
--accent-pink: #f5c2e7;
|
||||
--accent-red: #f38ba8;
|
||||
--accent-peach: #fab387;
|
||||
--accent-yellow: #f9e2af;
|
||||
--accent-green: #a6e3a1;
|
||||
--accent-teal: #94e2d5;
|
||||
--accent-sky: #74c7ec;
|
||||
|
||||
--z1: #a6e3a1;
|
||||
--z1-strong: #40a02b;
|
||||
--z2: #fab387;
|
||||
--z2-strong: #fe640b;
|
||||
--z3: #f38ba8;
|
||||
--z3-strong: #d20f39;
|
||||
|
||||
--t1: #f9e2af;
|
||||
--t2: #cba6f7;
|
||||
--t3: #74c7ec;
|
||||
|
||||
--status-ok: #a6e3a1;
|
||||
--status-warn: #f9e2af;
|
||||
--status-err: #f38ba8;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
--radius-lg: 20px;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
|
||||
--header-h: 72px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* Catppuccin Latte (light) */
|
||||
--bg: #eff1f5;
|
||||
--bg-elev-1: #e6e9ef;
|
||||
--bg-elev-2: #dce0e8;
|
||||
--surface: #ffffff;
|
||||
--border: #ccd0da;
|
||||
--text: #4c4f69;
|
||||
--text-muted: #6c6f85;
|
||||
--text-dim: #8c8fa1;
|
||||
|
||||
--accent-blue: #1e66f5;
|
||||
--accent-mauve: #8839ef;
|
||||
--accent-pink: #ea76cb;
|
||||
--accent-red: #d20f39;
|
||||
--accent-peach: #fe640b;
|
||||
--accent-yellow: #df8e1d;
|
||||
--accent-green: #40a02b;
|
||||
--accent-teal: #179299;
|
||||
--accent-sky: #04a5e5;
|
||||
|
||||
--z1: #40a02b;
|
||||
--z1-strong: #2d7a1e;
|
||||
--z2: #fe640b;
|
||||
--z2-strong: #cc4f05;
|
||||
--z3: #d20f39;
|
||||
--z3-strong: #9c0a29;
|
||||
|
||||
--t1: #df8e1d;
|
||||
--t2: #8839ef;
|
||||
--t3: #04a5e5;
|
||||
|
||||
--shadow: 0 10px 30px rgba(76, 79, 105, 0.12);
|
||||
--shadow-sm: 0 2px 6px rgba(76, 79, 105, 0.08);
|
||||
}
|
||||
|
||||
/* ---------- Reset ---------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background .3s ease, color .3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1, h2, h3, h4 { margin: 0; font-weight: 600; }
|
||||
input, button { font-family: inherit; color: inherit; }
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.app-header {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
height: var(--header-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.logo { display: flex; align-items: center; gap: 12px; }
|
||||
.logo-radar-dot { fill: var(--accent-red); }
|
||||
.logo-arc { fill: none; stroke: var(--accent-blue); stroke-width: 4; stroke-linecap: round; }
|
||||
.logo-ring { fill: none; stroke: var(--accent-blue); stroke-width: 2; opacity: .5; }
|
||||
.logo-text h1 { font-size: 20px; letter-spacing: .2px; }
|
||||
.logo-text .subtitle { font-size: 12px; color: var(--text-muted); display: block; margin-top: 2px; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Status chip */
|
||||
.status-chip {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-elev-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--status-warn);
|
||||
box-shadow: 0 0 0 3px rgba(249, 226, 175, 0.15);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.status-dot.status-ok { background: var(--status-ok); box-shadow: 0 0 0 3px rgba(166, 227, 161, .2); }
|
||||
.status-dot.status-err { background: var(--status-err); box-shadow: 0 0 0 3px rgba(243, 139, 168, .2); }
|
||||
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
|
||||
|
||||
/* Icon buttons */
|
||||
.icon-btn, .lang-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px;
|
||||
background: var(--bg-elev-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all .2s ease;
|
||||
color: var(--text);
|
||||
}
|
||||
.icon-btn:hover, .lang-btn:hover { background: var(--bg-elev-2); transform: translateY(-1px); }
|
||||
.icon-btn svg { fill: none; stroke: var(--text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.icon-btn .icon-moon { display: none; }
|
||||
[data-theme="dark"] .icon-btn .icon-sun { display: none; }
|
||||
[data-theme="dark"] .icon-btn .icon-moon { display: block; }
|
||||
[data-theme="light"] .icon-btn .icon-sun { display: block; }
|
||||
[data-theme="light"] .icon-btn .icon-moon { display: none; }
|
||||
|
||||
/* Lang button */
|
||||
.lang-btn { width: auto; padding: 0 10px; gap: 4px; font-size: 12px; font-weight: 700; }
|
||||
.lang-vi, .lang-en { opacity: .45; transition: opacity .2s; }
|
||||
[data-lang="vi"] .lang-vi { color: var(--accent-blue); opacity: 1; }
|
||||
[data-lang="en"] .lang-en { color: var(--accent-blue); opacity: 1; }
|
||||
.lang-sep { color: var(--text-dim); font-weight: 400; }
|
||||
|
||||
/* ---------- Main layout ---------- */
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.app-main { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.app-header { padding: 0 14px; height: 64px; }
|
||||
.logo-text h1 { font-size: 16px; }
|
||||
.logo-text .subtitle { font-size: 10px; }
|
||||
.app-main { padding: 12px; gap: 14px; }
|
||||
.status-chip span:last-child { display: none; }
|
||||
.status-chip { padding: 6px 8px; }
|
||||
}
|
||||
|
||||
/* ---------- Panels ---------- */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--bg-elev-1), transparent);
|
||||
}
|
||||
.panel-header h2 { font-size: 16px; }
|
||||
.panel-tools { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip-toggle {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elev-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all .2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
.chip-toggle:hover { background: var(--bg-elev-2); }
|
||||
.chip-toggle input { accent-color: var(--accent-blue); }
|
||||
|
||||
/* Canvas wrapper */
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 520px;
|
||||
background:
|
||||
radial-gradient(ellipse at center bottom, rgba(137, 180, 250, 0.06), transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
#radarCanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend {
|
||||
position: absolute;
|
||||
top: 12px; left: 12px;
|
||||
background: rgba(24, 24, 37, .78);
|
||||
color: #cdd6f4;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
min-width: 160px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
[data-theme="light"] .legend { background: rgba(255,255,255,.85); color: #4c4f69; }
|
||||
.legend-title { font-weight: 700; margin-bottom: 8px; color: var(--accent-blue); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
|
||||
.legend-divider { height: 1px; background: var(--border); margin: 8px 0; }
|
||||
.swatch { width: 12px; height: 12px; border-radius: 50%; border: 2px solid rgba(255,255,255,.6); }
|
||||
.swatch-t1 { background: var(--t1); }
|
||||
.swatch-t2 { background: var(--t2); }
|
||||
.swatch-t3 { background: var(--t3); }
|
||||
.swatch-zone { width: 14px; height: 10px; border-radius: 3px; border: 1.5px solid; }
|
||||
.swatch-zone.z1 { background: color-mix(in srgb, var(--z1) 30%, transparent); border-color: var(--z1); }
|
||||
.swatch-zone.z2 { background: color-mix(in srgb, var(--z2) 30%, transparent); border-color: var(--z2); }
|
||||
.swatch-zone.z3 { background: color-mix(in srgb, var(--z3) 30%, transparent); border-color: var(--z3); }
|
||||
|
||||
/* Node info */
|
||||
.node-info {
|
||||
position: absolute;
|
||||
bottom: 12px; left: 50%; transform: translateX(-50%);
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(24, 24, 37, .78);
|
||||
color: #cdd6f4;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
[data-theme="light"] .node-info { background: rgba(255,255,255,.85); color: #4c4f69; }
|
||||
.node-info .node-id { font-weight: 700; color: var(--accent-blue); }
|
||||
.node-info .sep { color: var(--text-dim); }
|
||||
|
||||
/* ---------- Side panel ---------- */
|
||||
.panel-side { display: flex; flex-direction: column; gap: 16px; padding: 16px; background: transparent; border: none; box-shadow: none; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.card h3 { font-size: 14px; margin-bottom: 12px; color: var(--accent-blue); }
|
||||
.card-header-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.card-header-row h3 { margin-bottom: 0; }
|
||||
|
||||
.btn-link {
|
||||
background: none; border: none; color: var(--accent-blue);
|
||||
font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn-link:hover { background: var(--bg-elev-1); }
|
||||
|
||||
/* Stats grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.stat {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elev-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.stat-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; font-family: "JetBrains Mono", "Consolas", monospace; }
|
||||
.stat-value small { font-size: 12px; font-weight: 400; color: var(--text-muted); margin-left: 2px; }
|
||||
|
||||
/* Targets list */
|
||||
.targets-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.target-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elev-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
transition: all .2s;
|
||||
}
|
||||
.target-row.invalid { opacity: .45; filter: grayscale(.5); }
|
||||
.target-row.recent { opacity: .8; }
|
||||
.target-slot {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 13px; color: #1e1e2e;
|
||||
border: 2px solid rgba(255,255,255,.6);
|
||||
}
|
||||
[data-theme="light"] .target-slot { color: #ffffff; }
|
||||
.target-slot.s1 { background: var(--t1); }
|
||||
.target-slot.s2 { background: var(--t2); }
|
||||
.target-slot.s3 { background: var(--t3); }
|
||||
.target-detail { font-size: 12px; font-family: "JetBrains Mono", "Consolas", monospace; }
|
||||
.target-detail .status {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 700; margin-bottom: 6px;
|
||||
text-transform: uppercase; letter-spacing: .4px;
|
||||
}
|
||||
.target-detail .status.detected { background: color-mix(in srgb, var(--status-ok) 25%, transparent); color: var(--accent-green); }
|
||||
.target-detail .status.recent { background: color-mix(in srgb, var(--status-warn) 25%, transparent); color: var(--accent-yellow); }
|
||||
.target-detail .status.free { background: color-mix(in srgb, var(--text-dim) 20%, transparent); color: var(--text-muted); }
|
||||
.target-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; color: var(--text-muted); }
|
||||
.target-metrics b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Zones list */
|
||||
.zones-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.zone-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elev-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
transition: all .25s;
|
||||
}
|
||||
.zone-row.occupied { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
|
||||
.zone-swatch {
|
||||
width: 10px; height: 100%; min-height: 36px; border-radius: 6px;
|
||||
border: 1.5px solid;
|
||||
}
|
||||
.zone-row.occupied .zone-swatch { opacity: 1; }
|
||||
.zone-swatch.z1 { background: color-mix(in srgb, var(--z1) 30%, transparent); border-color: var(--z1); }
|
||||
.zone-swatch.z2 { background: color-mix(in srgb, var(--z2) 30%, transparent); border-color: var(--z2); }
|
||||
.zone-swatch.z3 { background: color-mix(in srgb, var(--z3) 30%, transparent); border-color: var(--z3); }
|
||||
.zone-row.occupied .zone-swatch.z1 { background: color-mix(in srgb, var(--z1) 65%, transparent); }
|
||||
.zone-row.occupied .zone-swatch.z2 { background: color-mix(in srgb, var(--z2) 65%, transparent); }
|
||||
.zone-row.occupied .zone-swatch.z3 { background: color-mix(in srgb, var(--z3) 65%, transparent); }
|
||||
.zone-info .zone-name { font-weight: 700; font-size: 13px; margin-bottom: 2px; }
|
||||
.zone-info .zone-coord { font-size: 11px; color: var(--text-muted); font-family: "JetBrains Mono", monospace; }
|
||||
.zone-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .4px;
|
||||
}
|
||||
.zone-badge.occupied { background: color-mix(in srgb, var(--status-ok) 25%, transparent); color: var(--accent-green); }
|
||||
.zone-badge.empty { background: color-mix(in srgb, var(--text-dim) 20%, transparent); color: var(--text-muted); }
|
||||
|
||||
/* Zone editor */
|
||||
.zone-editor {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--border);
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
animation: slideDown .3s ease;
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.editor-title { font-size: 12px; font-weight: 600; color: var(--accent-blue); }
|
||||
.editor-grid {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.zone-editor-block {
|
||||
padding: 10px;
|
||||
background: var(--bg-elev-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.zone-editor-block label {
|
||||
font-size: 12px; font-weight: 600; display: block; margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.zone-editor-block .coord-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.zone-editor-block .coord-grid label {
|
||||
font-size: 10px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: .3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.zone-editor-block .coord-field { display: flex; flex-direction: column; }
|
||||
.zone-editor-block input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-family: "JetBrains Mono", monospace; font-size: 12px;
|
||||
color: var(--text);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.zone-editor-block input:focus { outline: none; border-color: var(--accent-blue); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-blue) 20%, transparent); }
|
||||
|
||||
.editor-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev-1);
|
||||
color: var(--text);
|
||||
font-size: 12px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn-ghost:hover { background: var(--bg-elev-2); }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-mauve));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-blue) 30%, transparent);
|
||||
}
|
||||
.btn-primary:hover { box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-blue) 40%, transparent); }
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed; bottom: 20px; right: 20px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px; font-weight: 500;
|
||||
min-width: 260px;
|
||||
animation: toastIn .3s ease, toastOut .3s ease 2.7s forwards;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.toast.success { border-left-color: var(--accent-green); }
|
||||
.toast.error { border-left-color: var(--accent-red); }
|
||||
.toast.warning { border-left-color: var(--accent-yellow); }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } }
|
||||
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
|
||||
Reference in new issue
Block a user