Add phần create map by upload
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-19 11:52:21 +07:00
parent 098e1b2b69
commit a6cf06d7eb
27 changed files with 4960 additions and 129 deletions

View File

@@ -3,6 +3,18 @@
const STORAGE_KEY_V2 = "phenikaax_dashboard_v2";
const PAGE_SIZE = 10;
const DEFAULT_ID = "dashboard_default";
const GRID_COLS = 12;
const GRID_ROW_PX = 52;
const GRID_GAP = 10;
const DEFAULT_W = 4;
const DEFAULT_H = 3;
const MIN_W = 2;
const MIN_H = 2;
const MAX_W = 12;
const MAX_H = 8;
let dragSession = null;
let resizeSession = null;
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
@@ -18,6 +30,10 @@
const listCountEl = el("dashboardListCount");
const pageLabelEl = el("dashboardPageLabel");
const designerTitleEl = el("dashboardDesignerTitle");
const designerToolbarEl = el("dashboardDesignerToolbar");
const editModeBtnEl = el("dashboardEditModeBtn");
const saveBtnEl = el("dashboardSaveBtn");
let activeWidgetTab = "missions";
const editDialogEl = el("dashboardEditDialog");
const permissionsDialogEl = el("dashboardPermissionsDialog");
const addDialogEl = el("dashboardAddWidgetDialog");
@@ -38,8 +54,229 @@
pollActive: false,
queueUnsub: null,
userGroups: [],
maps: [],
mapsLoaded: false,
robotPose: null,
robotPoseAt: 0,
mapPollTimer: null,
};
function hasMapWidget(widgets = activeWidgets()) {
return widgets.some((w) => w.type === "map" || w.type === "map_locked");
}
async function ensureMapsLoaded(force = false) {
if (store.mapsLoaded && !force) return store.maps;
try {
const res = await fetch("/api/maps", { credentials: "include" });
if (res.ok) {
const data = await res.json();
store.maps = Array.isArray(data.maps) ? data.maps : [];
} else {
store.maps = [];
}
} catch {
store.maps = [];
}
store.mapsLoaded = true;
return store.maps;
}
async function fetchRobotPose(force = false) {
const now = Date.now();
if (!force && store.robotPoseAt && now - store.robotPoseAt < 1200) return store.robotPose;
try {
const res = await fetch("/api/robot/status", { credentials: "include" });
if (res.ok) {
const data = await res.json();
const pose = data.pose && typeof data.pose === "object" ? data.pose : {};
store.robotPose = {
x: Number(pose.x) || 0,
y: Number(pose.y) || 0,
yaw: Number(pose.yaw) || 0,
mapId: data.active_map_id || null,
};
}
} catch {
/* keep last pose */
}
store.robotPoseAt = Date.now();
return store.robotPose;
}
function mapMeta(map) {
return {
resolution: Number(map?.resolution) || 0.05,
originX: Number(map?.origin_x) || 0,
originY: Number(map?.origin_y) || 0,
};
}
function worldToPixel(map, imgW, imgH, wx, wy) {
const { resolution, originX, originY } = mapMeta(map);
return {
x: (wx - originX) / resolution,
y: imgH - (wy - originY) / resolution,
};
}
function defaultMapPose(map, imgW, imgH) {
const { resolution, originX, originY } = mapMeta(map);
return {
x: originX + (imgW * resolution) / 2,
y: originY + (imgH * resolution) / 2,
yaw: 0,
};
}
function resolveWidgetMap(widget) {
const preferred = normalizeStr(widget.map_id) || store.robotPose?.mapId || "";
if (preferred) {
const hit = store.maps.find((m) => m.id === preferred);
if (hit) return hit;
}
return store.maps[0] || null;
}
function poseForWidget(map, imgW, imgH) {
const pose = store.robotPose;
if (pose && (!pose.mapId || pose.mapId === map.id)) {
return { x: pose.x, y: pose.y, yaw: pose.yaw };
}
return defaultMapPose(map, imgW, imgH);
}
function mapOptions(selected = "", includeActive = true) {
const opts = [];
if (includeActive) {
opts.push(
`<option value="" ${!selected ? "selected" : ""}>${escapeHtml(t("dashboard.widget.mapActive"))}</option>`
);
}
store.maps.forEach((map) => {
opts.push(
`<option value="${escapeHtml(map.id)}" ${map.id === selected ? "selected" : ""}>${escapeHtml(map.name || map.id)}</option>`
);
});
return opts.join("");
}
function mapImageUrl(map) {
if (!map?.id || !map.image_file) return null;
return `/api/maps/${encodeURIComponent(map.id)}/image`;
}
function applyMapLayout(viewportEl, map, locked, pose, imgW, imgH) {
if (!viewportEl || !map) return;
const width = imgW || 800;
const height = imgH || 600;
const px = worldToPixel(map, width, height, pose.x, pose.y);
const vw = viewportEl.clientWidth || 1;
const vh = viewportEl.clientHeight || 1;
const layer = viewportEl.querySelector("[data-map-layer]");
const marker = viewportEl.querySelector("[data-map-marker]");
if (!layer || !marker) return;
if (locked) {
const scale = Math.max(vw / width, vh / height) * 1.35;
const tx = vw / 2 - px.x * scale;
const ty = vh / 2 - px.y * scale;
layer.style.width = `${width}px`;
layer.style.height = `${height}px`;
layer.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
marker.style.left = `${vw / 2}px`;
marker.style.top = `${vh / 2}px`;
} else {
const scale = Math.min(vw / width, vh / height);
const offsetX = (vw - width * scale) / 2;
const offsetY = (vh - height * scale) / 2;
layer.style.width = `${width}px`;
layer.style.height = `${height}px`;
layer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
marker.style.left = `${offsetX + px.x * scale}px`;
marker.style.top = `${offsetY + px.y * scale}px`;
}
marker.style.transform = `translate(-50%, -50%) rotate(${pose.yaw}rad)`;
viewportEl.dataset.mapId = map.id;
}
function mountMapViewport(viewportEl, map, locked) {
const imgUrl = mapImageUrl(map);
const label = escapeHtml(map.name || map.id);
viewportEl.classList.toggle("dashboardMapViewport--locked", locked);
viewportEl.innerHTML = imgUrl
? `
<div class="dashboardMapLayer" data-map-layer>
<img class="dashboardMapImage" data-map-image src="${escapeHtml(imgUrl)}" alt="${label}" draggable="false" />
</div>
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`
: `
<div class="dashboardMapLayer dashboardMapLayer--grid" data-map-layer>
<div class="dashboardMapGridFallback">
<span class="dashboardMapGridTitle">${label}</span>
<span class="mutedNote">${escapeHtml(t("dashboard.widget.mapNoImage"))}</span>
</div>
</div>
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`;
const imgEl = viewportEl.querySelector("[data-map-image]");
const layout = () => {
const w = imgEl?.naturalWidth || 800;
const h = imgEl?.naturalHeight || 600;
const pose = poseForWidget(map, w, h);
applyMapLayout(viewportEl, map, locked, pose, w, h);
};
if (imgEl) {
if (imgEl.complete) layout();
else {
imgEl.addEventListener("load", layout);
imgEl.addEventListener("error", () => {
viewportEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapImageError"))}</div>`;
});
}
} else {
layout();
}
if (!viewportEl.dataset.roObserved) {
viewportEl.dataset.roObserved = "1";
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => layout());
ro.observe(viewportEl);
viewportEl._mapRo = ro;
}
}
}
async function layoutMapWidget(widget, bodyEl, locked) {
let viewportEl = bodyEl.querySelector("[data-map-viewport]");
if (!viewportEl) {
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport></div>`;
viewportEl = bodyEl.querySelector("[data-map-viewport]");
}
await ensureMapsLoaded();
await fetchRobotPose();
const map = resolveWidgetMap(widget);
if (!map) {
bodyEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapEmpty"))}</div>`;
return;
}
mountMapViewport(viewportEl, map, locked);
}
function refreshMapWidgets() {
activeWidgets().forEach((widget) => {
if (widget.type !== "map" && widget.type !== "map_locked") return;
const bodyEl = gridEl?.querySelector(`[data-widget-id="${widget.id}"] .dashboardWidgetBody`);
if (!bodyEl) return;
void layoutMapWidget(widget, bodyEl, widget.type === "map_locked");
});
}
function widgetTypeLabel(type) {
return t(`dashboard.widget.${type}`) || type;
}
@@ -99,6 +336,108 @@
if (db) db.widgets = widgets;
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function hasGridPos(widget) {
return Number.isFinite(widget?.col) && Number.isFinite(widget?.row);
}
function normalizeWidget(widget) {
if (!widget || typeof widget !== "object") return widget;
widget.w = clamp(Number(widget.w) || DEFAULT_W, MIN_W, MAX_W);
widget.h = clamp(Number(widget.h) || DEFAULT_H, MIN_H, MAX_H);
if (widget.col != null) widget.col = clamp(Math.round(Number(widget.col)), 1, GRID_COLS);
if (widget.row != null) widget.row = Math.max(1, Math.round(Number(widget.row)));
return widget;
}
function gridMetrics() {
const rect = gridEl.getBoundingClientRect();
const colW = (rect.width - GRID_GAP * (GRID_COLS - 1)) / GRID_COLS;
return { rect, colW, rowH: GRID_ROW_PX, gap: GRID_GAP };
}
function pointerToGrid(clientX, clientY) {
const { rect, colW, rowH, gap } = gridMetrics();
const x = Math.max(0, clientX - rect.left);
const y = Math.max(0, clientY - rect.top);
const col = clamp(Math.floor(x / (colW + gap)) + 1, 1, GRID_COLS);
const row = Math.max(1, Math.floor(y / (rowH + gap)) + 1);
return { col, row };
}
function rectsOverlap(c1, r1, w1, h1, c2, r2, w2, h2) {
return c1 < c2 + w2 && c1 + w1 > c2 && r1 < r2 + h2 && r1 + h1 > r2;
}
function widgetFits(widgets, ignoreId, col, row, w, h) {
if (col < 1 || row < 1 || col + w - 1 > GRID_COLS) return false;
return !widgets.some((other) => {
if (other.id === ignoreId || !hasGridPos(other)) return false;
return rectsOverlap(col, row, w, h, other.col, other.row, other.w, other.h);
});
}
function findFreeGridSpot(widgets, w, h) {
const maxRow = widgets.reduce((max, item) => {
if (!hasGridPos(item)) return max;
return Math.max(max, item.row + item.h);
}, 4);
for (let row = 1; row <= maxRow + 6; row += 1) {
for (let col = 1; col <= GRID_COLS - w + 1; col += 1) {
if (widgetFits(widgets, null, col, row, w, h)) return { col, row };
}
}
return { col: 1, row: maxRow + 1 };
}
function ensureWidgetPositions(widgets) {
let cursorCol = 1;
let cursorRow = 1;
let rowHeight = 0;
widgets.forEach((widget) => {
normalizeWidget(widget);
if (hasGridPos(widget)) return;
if (cursorCol + widget.w - 1 > GRID_COLS) {
cursorRow += rowHeight || widget.h;
cursorCol = 1;
rowHeight = 0;
}
widget.col = cursorCol;
widget.row = cursorRow;
cursorCol += widget.w;
rowHeight = Math.max(rowHeight, widget.h);
if (cursorCol > GRID_COLS) {
cursorRow += rowHeight;
cursorCol = 1;
rowHeight = 0;
}
});
}
function updateGridCanvasHeight(widgets) {
if (!gridEl) return;
let maxRowEnd = 4;
widgets.forEach((widget) => {
if (hasGridPos(widget)) maxRowEnd = Math.max(maxRowEnd, widget.row + widget.h - 1);
});
const h = maxRowEnd * GRID_ROW_PX + Math.max(0, maxRowEnd - 1) * GRID_GAP;
gridEl.style.minHeight = `${h}px`;
}
function normalizeDashboards() {
store.dashboards.forEach((db) => {
if (!Array.isArray(db.widgets)) db.widgets = [];
ensureWidgetPositions(db.widgets);
});
}
function bootstrapDefaultDashboard(widgets = []) {
store.dashboards = [
{
@@ -140,6 +479,7 @@
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
if (!store.dashboards.length) bootstrapDefaultDashboard();
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
normalizeDashboards();
} catch {
bootstrapDefaultDashboard();
}
@@ -159,6 +499,7 @@
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
if (!store.dashboards.length) bootstrapDefaultDashboard();
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
normalizeDashboards();
} catch {
loadStoreLocal();
}
@@ -253,6 +594,7 @@
function setView(view) {
store.view = view;
document.querySelector(".dashboardShell")?.classList.toggle("dashboardShell--designer", view === "designer");
if (listViewEl) listViewEl.hidden = view !== "list";
if (createViewEl) createViewEl.hidden = view !== "create";
if (designerViewEl) designerViewEl.hidden = view !== "designer";
@@ -262,6 +604,7 @@
} else if (view === "create") {
stopDashboardPoll();
} else {
syncDesignerEditMode();
renderDesignerChrome();
renderDashboard();
startDashboardPoll();
@@ -332,9 +675,164 @@
if (createBtn) createBtn.disabled = !canEditDashboardsModule();
}
function setWidgetTab(tab) {
activeWidgetTab = tab;
designerToolbarEl?.querySelectorAll("[data-widget-tab]").forEach((btn) => {
const active = btn.dataset.widgetTab === tab;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
});
designerToolbarEl?.querySelectorAll("[data-panel]").forEach((panel) => {
panel.hidden = panel.dataset.panel !== tab;
});
}
function renderDesignerChrome() {
const db = activeDashboard();
if (designerTitleEl) designerTitleEl.textContent = db?.name || "—";
const canEdit = dashboardCanEdit(db);
if (designerToolbarEl) designerToolbarEl.hidden = !canEdit || !store.editMode;
if (saveBtnEl) saveBtnEl.hidden = !canEdit || !store.editMode;
if (editModeBtnEl) {
editModeBtnEl.hidden = !canEdit;
editModeBtnEl.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
editModeBtnEl.setAttribute("aria-pressed", store.editMode ? "true" : "false");
}
if (!designerToolbarEl?.hidden) setWidgetTab(activeWidgetTab);
}
function syncDesignerEditMode() {
/* operate mode by default — edit toggled explicitly */
}
function addWidget(type) {
const db = activeDashboard();
if (!db || !store.editMode || !dashboardCanEdit(db)) return;
const isMap = type === "map_locked" || type === "map";
const widget = normalizeWidget({
id: newId("w"),
type,
title: type === "mission_queue" ? "Mission queue" : "",
w: isMap ? 6 : DEFAULT_W,
h: isMap ? 6 : DEFAULT_H,
});
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
widget.col = spot.col;
widget.row = spot.row;
db.widgets.push(widget);
persistStore();
renderDashboard();
if (type === "mission_button" || type === "mission_group") openEditDialog(widget.id);
else if (type === "map_locked" || type === "map") openEditDialog(widget.id);
}
function applyWidgetGridStyle(card, widget) {
normalizeWidget(widget);
card.style.setProperty("--dw", String(widget.w));
card.style.setProperty("--dh", String(widget.h));
if (hasGridPos(widget)) {
card.style.gridColumn = `${widget.col} / span ${widget.w}`;
card.style.gridRow = `${widget.row} / span ${widget.h}`;
} else {
card.style.gridColumn = `span ${widget.w}`;
card.style.gridRow = `span ${widget.h}`;
}
}
function startWidgetMove(evt, widget, card) {
if (!store.editMode || resizeSession || dragSession) return;
if (evt.button !== 0) return;
if (!hasGridPos(widget)) return;
evt.preventDefault();
const startCol = widget.col;
const startRow = widget.row;
const grab = pointerToGrid(evt.clientX, evt.clientY);
const grabColOff = grab.col - widget.col;
const grabRowOff = grab.row - widget.row;
dragSession = { widgetId: widget.id };
card.classList.add("is-dragging");
document.body.classList.add("dashboard-widget-dragging");
const onMove = (e) => {
const pt = pointerToGrid(e.clientX, e.clientY);
let col = pt.col - grabColOff;
let row = pt.row - grabRowOff;
col = clamp(col, 1, GRID_COLS - widget.w + 1);
row = Math.max(1, row);
widget.col = col;
widget.row = row;
applyWidgetGridStyle(card, widget);
updateGridCanvasHeight(activeWidgets());
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
card.classList.remove("is-dragging");
document.body.classList.remove("dashboard-widget-dragging");
dragSession = null;
const widgets = activeWidgets();
if (!widgetFits(widgets, widget.id, widget.col, widget.row, widget.w, widget.h)) {
widget.col = startCol;
widget.row = startRow;
applyWidgetGridStyle(card, widget);
} else {
persistStore();
}
updateGridCanvasHeight(widgets);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function startWidgetResize(evt, widget, card) {
if (!store.editMode) return;
evt.preventDefault();
evt.stopPropagation();
const startX = evt.clientX;
const startY = evt.clientY;
const startW = widget.w;
const startH = widget.h;
const colW = gridEl.clientWidth / GRID_COLS;
const onMove = (e) => {
const dw = Math.round((e.clientX - startX) / colW);
const dh = Math.round((e.clientY - startY) / GRID_ROW_PX);
widget.w = clamp(startW + dw, MIN_W, MAX_W);
widget.h = clamp(startH + dh, MIN_H, MAX_H);
applyWidgetGridStyle(card, widget);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
persistStore();
resizeSession = null;
};
resizeSession = { widgetId: widget.id };
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function attachWidgetInteractions(card, widget) {
applyWidgetGridStyle(card, widget);
const header = card.querySelector(".dashboardWidgetHeader");
const pen = card.querySelector("[data-widget-config]");
const resize = card.querySelector("[data-widget-resize]");
pen?.addEventListener("click", (evt) => {
evt.stopPropagation();
openEditDialog(widget.id);
});
resize?.addEventListener("mousedown", (evt) => startWidgetResize(evt, widget, card));
if (!store.editMode) return;
header?.addEventListener("mousedown", (evt) => startWidgetMove(evt, widget, card));
}
function openCreateView() {
@@ -371,8 +869,11 @@
editDialogEl?.showModal();
}
function openDesignerFor(id) {
function openDesignerFor(id, { edit = false } = {}) {
setActiveDashboard(id);
const db = activeDashboard();
store.editMode = edit && dashboardCanEdit(db);
if (store.editMode) setWidgetTab("missions");
setView("designer");
window.NavApp?.syncDashboardSection?.(`dashboard-${id}`);
}
@@ -456,6 +957,35 @@
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>
<p class="mutedNote">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
} else if (type === "mission_action_log") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission action log")}" />
</div>`;
} else if (type === "logout_button") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.logout_button"))}" />
</div>`;
} else if (type === "map_locked" || type === "map") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.map")}</label>
<select data-field="map_id">${mapOptions(widget.map_id || "")}</select>
</div>
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || widgetTypeLabel(type))}" />
</div>
<p class="mutedNote">${escapeHtml(t("dashboard.widget.mapHint"))}</p>`;
} else if (type === "robot_summary") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
</div>`;
}
}
@@ -524,11 +1054,86 @@
);
}
function renderMissionActionLogWidget(widget, bodyEl) {
const snap = missions()?.getQueueSnapshot?.();
const runner = snap?.runner || {};
const executing = (snap?.queue || []).find((e) => e.status === "executing");
const lines = [];
if (runner.current_action) {
lines.push({ message: runner.current_action, current: true });
} else if (runner.message) {
lines.push({ message: runner.message, current: true });
}
if (executing?.log && Array.isArray(executing.log)) {
executing.log
.slice(-10)
.reverse()
.forEach((entry) => {
if (entry?.message) lines.push({ message: entry.message, level: entry.level || "info" });
});
}
if (!lines.length) {
bodyEl.innerHTML = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.actionLog.empty"))}</p>`;
return;
}
bodyEl.innerHTML = `<ul class="dashboardActionLogList"></ul>`;
const listEl = bodyEl.querySelector(".dashboardActionLogList");
lines.forEach((line) => {
const li = document.createElement("li");
li.className = `dashboardActionLogItem${line.current ? " is-current" : ""}${line.level ? ` level-${line.level}` : ""}`;
li.textContent = line.message;
listEl.appendChild(li);
});
}
function renderLogoutButtonWidget(widget, bodyEl) {
const label = widget.title || t("dashboard.widget.logout_button");
bodyEl.innerHTML = `<button type="button" class="dashboardLogoutBtn">${escapeHtml(label)}</button>`;
bodyEl.querySelector(".dashboardLogoutBtn")?.addEventListener("click", () => window.AuthApp?.logout?.());
}
function renderMapWidget(widget, bodyEl, locked = false) {
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport>
<div class="dashboardMapLoading mutedNote">${escapeHtml(t("dashboard.widget.mapLoading"))}</div>
</div>`;
void layoutMapWidget(widget, bodyEl, locked);
}
function renderRobotSummaryWidget(widget, bodyEl) {
const title = widget.title || t("dashboard.widget.robot_summary");
bodyEl.innerHTML = `
<div class="dashboardRobotSummary">
<div class="dashboardRobotSummaryIcon" aria-hidden="true">⬡</div>
<div class="dashboardRobotSummaryMeta">
<div class="dashboardRobotSummaryName">${escapeHtml(t("app.robotName"))}</div>
<div class="dashboardRobotSummarySub mutedNote">${escapeHtml(title)}</div>
</div>
</div>`;
}
function renderPauseContinueWidget(widget, bodyEl) {
const snap = missions()?.getQueueSnapshot?.();
const state = snap?.runner?.state || "idle";
const paused = state === "paused" || snap?.runner?.paused;
const running = state === "running" || paused;
if (!store.editMode) {
bodyEl.innerHTML = `
<button type="button" class="dashboardMirToggleBtn ${paused ? "is-continue" : "is-pause"}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
</button>`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
if (action === "pause") await missions()?.pauseRunner?.();
else await missions()?.continueRunner?.();
} catch (e) {
alert(e.message);
}
});
return;
}
bodyEl.innerHTML = `
<div class="dashboardRunnerControls">
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
@@ -538,7 +1143,7 @@
${t("dashboard.widget.cancelMission")}
</button>
</div>
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}</p>`;
<p class="mutedNote dashboardWidgetHint">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
@@ -558,19 +1163,22 @@
}
function renderWidget(widget) {
normalizeWidget(widget);
const card = document.createElement("article");
card.className = `dashboardWidget dashboardWidget--${widget.type}`;
card.classList.toggle("dashboardWidget--operate", !store.editMode);
card.classList.toggle("dashboardWidget--edit", store.editMode);
card.dataset.widgetId = widget.id;
card.innerHTML = `
<div class="dashboardWidgetHeader">
${store.editMode ? `
<div class="dashboardWidgetHeader" title="${escapeHtml(t("dashboard.designer.dragHint"))}">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="${escapeHtml(t("common.configure"))}">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="${escapeHtml(t("common.delete"))}">×</button>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
<span class="dashboardWidgetDragHint" aria-hidden="true">⠿</span>
</div>` : ""}
<div class="dashboardWidgetBody"></div>
<button type="button" class="dashboardWidgetPen" data-widget-config title="${escapeHtml(t("dashboard.designer.configure"))}" aria-label="${escapeHtml(t("dashboard.designer.configure"))}" ${store.editMode ? "" : "hidden"}>✎</button>
<div class="dashboardWidgetResize" data-widget-resize title="${escapeHtml(t("dashboard.designer.resize"))}" aria-label="${escapeHtml(t("dashboard.designer.resize"))}" ${store.editMode ? "" : "hidden"}>↘</div>`;
const bodyEl = card.querySelector(".dashboardWidgetBody");
switch (widget.type) {
@@ -586,12 +1194,26 @@
case "pause_continue":
renderPauseContinueWidget(widget, bodyEl);
break;
case "mission_action_log":
renderMissionActionLogWidget(widget, bodyEl);
break;
case "logout_button":
renderLogoutButtonWidget(widget, bodyEl);
break;
case "map_locked":
renderMapWidget(widget, bodyEl, true);
break;
case "map":
renderMapWidget(widget, bodyEl, false);
break;
case "robot_summary":
renderRobotSummaryWidget(widget, bodyEl);
break;
default:
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
}
card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id));
card.querySelector("[data-widget-delete]")?.addEventListener("click", () => deleteWidget(widget.id));
attachWidgetInteractions(card, widget);
return card;
}
@@ -599,12 +1221,20 @@
if (!gridEl) return;
const widgets = activeWidgets();
gridEl.innerHTML = "";
if (designerEmptyEl) designerEmptyEl.hidden = widgets.length > 0;
if (designerEmptyEl) {
designerEmptyEl.hidden = widgets.length > 0;
designerEmptyEl.textContent = store.editMode
? t("dashboard.designer.emptyEdit")
: t("dashboard.designer.empty");
}
gridEl.classList.toggle("dashboardGrid--edit", store.editMode);
ensureWidgetPositions(widgets);
widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => {
n.hidden = !store.editMode;
});
updateGridCanvasHeight(widgets);
renderDesignerChrome();
if (hasMapWidget(widgets)) {
void ensureMapsLoaded().then(() => refreshMapWidgets());
}
}
function refreshDynamicWidgets() {
@@ -614,7 +1244,9 @@
const bodyEl = card.querySelector(".dashboardWidgetBody");
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl);
});
if (hasMapWidget()) refreshMapWidgets();
}
function openEditDialog(widgetId) {
@@ -622,8 +1254,15 @@
if (!widget) return;
editWidgetIdEl.value = widget.id;
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
fillTypeFields(editFieldsEl, widget.type, widget);
editWidgetDialogEl.showModal();
const open = () => {
fillTypeFields(editFieldsEl, widget.type, widget);
editWidgetDialogEl.showModal();
};
if (widget.type === "map" || widget.type === "map_locked") {
void ensureMapsLoaded().then(open);
} else {
open();
}
}
function deleteWidget(widgetId) {
@@ -645,6 +1284,7 @@
const id = section.slice("dashboard-".length);
if (store.dashboards.some((d) => d.id === id)) {
setActiveDashboard(id);
store.editMode = false;
setView("designer");
}
}
@@ -700,7 +1340,7 @@
if (!btn) return;
const id = btn.dataset.id;
const action = btn.dataset.action;
if (action === "design") openDesignerFor(id);
if (action === "design") openDesignerFor(id, { edit: true });
else if (action === "edit") openEditDashboardDialog(id);
else if (action === "delete") deleteDashboard(id);
});
@@ -753,7 +1393,11 @@
if (!db) return;
const type = addTypeEl.value;
const fields = readFields(addFieldsEl);
db.widgets.push({ id: newId("w"), type, ...fields });
const widget = normalizeWidget({ id: newId("w"), type, ...fields, w: DEFAULT_W, h: DEFAULT_H });
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
widget.col = spot.col;
widget.row = spot.row;
db.widgets.push(widget);
persistStore();
addDialogEl.close();
renderDashboard();
@@ -771,6 +1415,39 @@
});
el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value));
editModeBtnEl?.addEventListener("click", async () => {
if (!dashboardCanEdit(activeDashboard())) return;
if (store.editMode) {
clearTimeout(persistTimer);
await syncStoreToBackend();
}
store.editMode = !store.editMode;
if (store.editMode) setWidgetTab("missions");
renderDashboard();
});
saveBtnEl?.addEventListener("click", async () => {
clearTimeout(persistTimer);
await syncStoreToBackend();
saveBtnEl.classList.add("is-saved");
saveBtnEl.textContent = t("dashboard.designer.saved");
setTimeout(() => {
saveBtnEl.classList.remove("is-saved");
saveBtnEl.textContent = t("dashboard.designer.save");
}, 1600);
});
designerToolbarEl?.addEventListener("click", (evt) => {
const tabBtn = evt.target.closest("[data-widget-tab]");
if (tabBtn) {
setWidgetTab(tabBtn.dataset.widgetTab);
return;
}
const btn = evt.target.closest("[data-add-widget]");
if (!btn) return;
addWidget(btn.dataset.addWidget);
});
}
function startDashboardPoll() {
@@ -781,6 +1458,12 @@
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
missions()?.startQueuePoll?.();
store.pollActive = true;
if (hasMapWidget()) {
void fetchRobotPose(true);
store.mapPollTimer = setInterval(() => {
void fetchRobotPose(true).then(() => refreshMapWidgets());
}, 2000);
}
}
function stopDashboardPoll() {
@@ -792,6 +1475,10 @@
store.queueUnsub();
store.queueUnsub = null;
}
if (store.mapPollTimer) {
clearInterval(store.mapPollTimer);
store.mapPollTimer = null;
}
}
async function init() {
@@ -807,6 +1494,7 @@
handleNav,
onPageShow() {
if (store.view === "designer") {
syncDesignerEditMode();
renderDesignerChrome();
renderDashboard();
startDashboardPoll();