458 lines
14 KiB
JavaScript
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();
|
|
})();
|