Files
App/www/topbar.js
HiepLM a2e87aeb29
Some checks failed
Test / test (push) Has been cancelled
Add function Language
2026-06-16 16:44:04 +07:00

458 lines
14 KiB
JavaScript

(() => {
const el = (id) => document.getElementById(id);
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const getLocale = () => window.I18n?.getLocale?.() ?? "vi";
let robotStatus = null;
let pollTimer = null;
let eventsBound = false;
let openPanel = null;
let joystickActive = false;
let joystickPointerId = null;
let joystickRaf = null;
let lastCmd = { linear: 0, angular: 0 };
function applyLocale(next) {
if (window.I18n) window.I18n.setLocale(next);
if (robotStatus) renderAll(robotStatus);
}
function loadLocale() {
/* locale owned by I18n */
}
function canSeeMissions() {
return window.AuthApp?.canAccessPage?.("missions");
}
function canControl() {
return window.AuthApp?.canWrite?.("missions");
}
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function closePanels() {
document.querySelectorAll(".mirPanel").forEach((p) => {
p.hidden = true;
});
document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => {
btn.setAttribute("aria-expanded", "false");
});
openPanel = null;
}
function togglePanel(btn, panel) {
if (!btn || !panel) return;
const isOpen = btn.getAttribute("aria-expanded") === "true";
closePanels();
if (!isOpen) {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
openPanel = panel;
}
}
function missionStripMessage(status) {
const pending = Number(status.queue_pending) || 0;
const runnerState = status.runner?.state || "idle";
const msg = status.message || "";
if (runnerState === "running" || runnerState === "paused") {
if (msg && msg !== t("topbar.waiting")) return msg;
const name = status.runner?.current_action;
if (name) return String(name);
}
if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue");
if (msg && msg !== t("topbar.waiting")) return msg;
return t("topbar.waiting");
}
function renderControl(status) {
const motion = status.motion || "paused";
const running = motion === "running";
const runnerState = status.runner?.state || "idle";
const isError = status.health === "error" || runnerState === "error";
const pauseIcon = el("mirControlIconPause");
const playIcon = el("mirControlIconPlay");
const pillEl = el("mirControlPill");
const msgEl = el("mirMissionMsg");
const btnEl = el("mirSegControl");
const stripEl = el("mirMissionStrip");
if (pauseIcon && playIcon) {
pauseIcon.hidden = !running;
playIcon.hidden = running;
}
if (pillEl) {
pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused");
pillEl.classList.toggle("is-running", running && !isError);
pillEl.classList.toggle("is-paused", !running && !isError);
pillEl.classList.toggle("is-error", isError);
}
if (msgEl) msgEl.textContent = missionStripMessage(status);
if (stripEl) stripEl.classList.toggle("is-error", isError);
if (btnEl) {
btnEl.disabled = !canControl() || status.health === "error";
btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint");
btnEl.classList.toggle("is-readonly", !canControl());
}
}
function renderStatus(status) {
const health = status.health || "ok";
const runnerState = status.runner?.state || "idle";
const isError = health === "error" || runnerState === "error";
const labelEl = el("mirStatusLabel");
const iconEl = el("mirStatusIcon");
const bodyEl = el("mirStatusPanelBody");
const footerEl = el("mirStatusPanelFooter");
const segEl = el("mirSegStatus");
if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk");
if (iconEl) {
iconEl.classList.toggle("is-ok", !isError);
iconEl.classList.toggle("is-error", isError);
}
if (segEl) segEl.classList.toggle("is-error", isError);
if (!bodyEl) return;
const err = status.error && typeof status.error === "object" ? status.error : null;
const runnerErr = runnerState === "error" ? status.runner?.message : "";
const message = status.message || t("topbar.waiting");
if (isError && (err || runnerErr)) {
bodyEl.innerHTML = `
<div class="mirStatusErrorTitle">${t("topbar.error")}</div>
${err?.code != null ? `<div class="mirStatusRow"><span>${t("topbar.code")}:</span> <strong>${err.code}</strong></div>` : ""}
${err?.module ? `<div class="mirStatusRow"><span>${t("topbar.module")}:</span> ${err.module}</div>` : ""}
<div class="mirStatusDesc">${err?.description || runnerErr || message}</div>`;
if (footerEl) footerEl.hidden = !canControl();
} else {
bodyEl.innerHTML = `
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
<div class="mirStatusDesc">${message}</div>
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${t("topbar.queueCount", { n: status.queue_pending })}</div>` : ""}`;
if (footerEl) footerEl.hidden = true;
}
}
function renderBattery(status) {
const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0));
const labelEl = el("mirBatteryLabel");
const levelEl = el("mirBatteryLevel");
const segEl = el("mirSegBattery");
if (labelEl) labelEl.textContent = `${pct}%`;
if (levelEl) levelEl.style.width = `${pct}%`;
if (segEl) {
segEl.classList.toggle("is-low", pct < 20);
segEl.classList.toggle("is-mid", pct >= 20 && pct < 50);
segEl.classList.toggle("is-charging", !!status.battery_charging);
}
}
function hideJoystickOverlay() {
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = true;
joystickActive = false;
const stick = el("joystickStick");
if (stick) stick.style.transform = "translate(0, 0)";
lastCmd = { linear: 0, angular: 0 };
}
function renderJoystick(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
const seg = el("mirSegJoystick");
const engaged = !!status.joystick_engaged;
if (seg) seg.classList.toggle("is-active", engaged);
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = !engaged;
joystickActive = engaged;
const speedSel = el("joystickSpeedSelect");
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
if (el("joystickSpeedLabel")) {
const speed = status.joystick_speed || "fast";
el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`);
}
}
function renderAll(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
robotStatus = status;
if (!canSeeMissions()) {
el("mirTopbar")?.classList.add("mirTopbar--no-missions");
return;
}
el("mirTopbar")?.classList.remove("mirTopbar--no-missions");
renderControl(status);
renderStatus(status);
renderBattery(status);
renderJoystick(status);
}
async function fetchStatus() {
if (!window.AuthApp?.isReady() || !canSeeMissions()) return;
try {
const data = await apiJson("/api/robot/status");
renderAll(data);
window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data }));
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
}
}
function startPoll() {
stopPoll();
fetchStatus();
pollTimer = setInterval(fetchStatus, 1500);
window.MissionsApp?.startQueuePoll?.();
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
window.MissionsApp?.stopQueuePoll?.();
}
async function toggleRobotMotion() {
if (!robotStatus || !canControl()) return;
const running = robotStatus.motion === "running";
const path = running ? "/api/robot/pause" : "/api/robot/start";
const data = await apiJson(path, { method: "POST", body: "{}" });
renderAll(data);
}
async function resetError() {
const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" });
renderAll(data);
closePanels();
}
async function engageJoystick(engaged, speed) {
const payload = { engaged };
if (speed) payload.speed = speed;
const data = await apiJson("/api/robot/joystick", {
method: "POST",
body: JSON.stringify(payload),
});
renderAll(data);
}
function sendCmdVel(linear, angular) {
if (!joystickActive) return;
if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return;
lastCmd = { linear, angular };
apiJson("/api/robot/cmd_vel", {
method: "POST",
body: JSON.stringify({ linear, angular }),
}).catch(() => {});
}
function bindJoystickPad() {
const pad = el("joystickPad");
const stick = el("joystickStick");
if (!pad || !stick) return;
const center = () => {
const r = pad.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 };
};
const moveStick = (clientX, clientY) => {
const c = center();
let dx = clientX - c.x;
let dy = clientY - c.y;
const dist = Math.hypot(dx, dy);
if (dist > c.radius) {
dx = (dx / dist) * c.radius;
dy = (dy / dist) * c.radius;
}
stick.style.transform = `translate(${dx}px, ${dy}px)`;
const linear = -dy / c.radius;
const angular = dx / c.radius;
if (joystickRaf) cancelAnimationFrame(joystickRaf);
joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular));
};
const resetStick = () => {
stick.style.transform = "translate(0, 0)";
sendCmdVel(0, 0);
lastCmd = { linear: 0, angular: 0 };
};
const onDown = (evt) => {
if (!joystickActive) return;
joystickPointerId = evt.pointerId;
pad.setPointerCapture(evt.pointerId);
moveStick(evt.clientX, evt.clientY);
};
const onMove = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
moveStick(evt.clientX, evt.clientY);
};
const onUp = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
joystickPointerId = null;
resetStick();
};
pad.addEventListener("pointerdown", onDown);
pad.addEventListener("pointermove", onMove);
pad.addEventListener("pointerup", onUp);
pad.addEventListener("pointercancel", onUp);
}
function bindEvents() {
if (eventsBound) return;
eventsBound = true;
el("mirSegControl")?.addEventListener("click", () => {
toggleRobotMotion().catch((e) => alert(e.message));
});
el("mirSegStatus")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegStatus"), el("mirStatusPanel"));
});
el("mirSegLocale")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegLocale"), el("mirLocalePanel"));
});
el("mirUserBtn")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirUserBtn"), el("mirUserPanel"));
});
el("mirErrorResetBtn")?.addEventListener("click", () => {
resetError().catch((e) => alert(e.message));
});
document.querySelectorAll(".mirLocaleOption").forEach((btn) => {
btn.addEventListener("click", (evt) => {
evt.stopPropagation();
applyLocale(btn.dataset.locale || "vi");
closePanels();
});
});
el("mirSegJoystick")?.addEventListener("click", async () => {
if (!canControl()) {
alert(t("topbar.noControlPermission"));
return;
}
try {
if (robotStatus?.joystick_engaged) await engageJoystick(false);
else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast");
} catch (e) {
alert(e.message);
}
});
el("joystickDisengageBtn")?.addEventListener("click", () => {
engageJoystick(false).catch((e) => alert(e.message));
});
el("joystickSpeedSelect")?.addEventListener("change", (evt) => {
if (robotStatus?.joystick_engaged) {
engageJoystick(true, evt.target.value).catch(() => {});
}
});
document.addEventListener("click", (evt) => {
if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return;
closePanels();
});
document.addEventListener("keydown", (evt) => {
if (evt.key === "Escape") {
closePanels();
if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {});
}
});
bindJoystickPad();
window.addEventListener("lm:locale-change", () => {
if (robotStatus) renderAll(robotStatus);
});
}
function start() {
loadLocale();
bindEvents();
if (!window.AuthApp?.isReady()) return;
startPoll();
}
function stop() {
stopPoll();
closePanels();
hideJoystickOverlay();
if (window.AuthApp?.isReady?.()) {
engageJoystick(false).catch(() => {});
}
}
async function disengageJoystick() {
hideJoystickOverlay();
if (!window.AuthApp?.isReady?.()) return;
try {
await engageJoystick(false);
} catch {
/* session may already be gone */
}
}
window.TopbarApp = {
t,
getLocale,
applyLocale,
refresh: fetchStatus,
getRobotStatus: () => robotStatus,
hideJoystickOverlay,
disengageJoystick,
updateUserMenu(user) {
const role = (user?.group_name || "USER").toUpperCase();
if (el("mirUserLabel")) el("mirUserLabel").textContent = role;
if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role;
if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—";
if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || "";
},
};
if (window.AuthApp?.isReady()) start();
else window.addEventListener("lm:auth-ready", () => start(), { once: true });
window.addEventListener("lm:auth-logout", () => stop());
hideJoystickOverlay();
})();