Files
App/www/map-editor.js
HiepLM a6cf06d7eb
Some checks failed
Test / test (push) Has been cancelled
Add phần create map by upload
2026-06-19 11:52:21 +07:00

458 lines
14 KiB
JavaScript

(() => {
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const state = {
mapId: null,
map: null,
callbacks: {},
readOnly: false,
dirty: false,
activeTool: "pan",
view: { scale: 1, panX: 0, panY: 0 },
panning: null,
tipVisible: true,
};
const titleEl = el("mapEditorTitle");
const dirtyEl = el("mapEditorDirty");
const canvasWrapEl = el("mapEditorCanvasWrap");
const canvasInnerEl = el("mapEditorCanvasInner");
const sheetEl = el("mapEditorSheet");
const imageEl = el("mapEditorImage");
const emptyEl = el("mapEditorEmpty");
const tipEl = el("mapEditorCanvasTip");
const uploadInputEl = el("mapEditorUploadInput");
const menuDialogEl = el("mapEditorMenuDialog");
const settingsDialogEl = el("mapEditorSettingsDialog");
const activateDialogEl = el("mapActivateDialog");
const toolBtnEls = () => document.querySelectorAll(".mapEditorMapTool[data-tool]");
const settingsFields = {
name: el("mapSettingsName"),
desc: el("mapSettingsDesc"),
resolution: el("mapSettingsResolution"),
originX: el("mapSettingsOriginX"),
originY: el("mapSettingsOriginY"),
originYaw: el("mapSettingsOriginYaw"),
};
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: "include", ...opts });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function mapImageUrl(map) {
if (!map?.id || !map.image_file) return null;
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
}
function setDirty(flag) {
state.dirty = !!flag;
if (dirtyEl) dirtyEl.hidden = !state.dirty;
el("mapEditorSaveBtn")?.toggleAttribute("disabled", !state.dirty || state.readOnly);
}
function dismissCanvasTip() {
if (!state.tipVisible) return;
state.tipVisible = false;
if (tipEl) tipEl.hidden = true;
}
function updateCanvasCursor() {
if (!canvasWrapEl) return;
canvasWrapEl.classList.toggle("is-pan-tool", state.activeTool === "pan" && !state.panning);
canvasWrapEl.classList.toggle("is-panning", !!state.panning);
}
function setActiveTool(tool) {
if (tool !== "pan") return;
state.activeTool = tool;
toolBtnEls().forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.tool === tool);
});
updateCanvasCursor();
}
function centerSheetInView() {
if (!canvasWrapEl || !sheetEl) return;
const wrap = canvasWrapEl.getBoundingClientRect();
const sw = sheetEl.offsetWidth || 480;
const sh = sheetEl.offsetHeight || 360;
state.view.panX = Math.max(40, (wrap.width - sw * state.view.scale) / 2);
state.view.panY = Math.max(40, (wrap.height - sh * state.view.scale) / 2);
}
function applyViewTransform() {
if (!canvasInnerEl) return;
const { scale, panX, panY } = state.view;
canvasInnerEl.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
function fitToView() {
dismissCanvasTip();
if (!canvasWrapEl || !sheetEl) return;
const wrap = canvasWrapEl.getBoundingClientRect();
const sw = imageEl && !imageEl.hidden ? imageEl.naturalWidth || sheetEl.offsetWidth : sheetEl.offsetWidth;
const sh = imageEl && !imageEl.hidden ? imageEl.naturalHeight || sheetEl.offsetHeight : sheetEl.offsetHeight;
const pad = 48;
const scale = Math.min((wrap.width - pad) / sw, (wrap.height - pad) / sh, 4);
state.view.scale = Math.max(0.1, scale);
state.view.panX = (wrap.width - sw * state.view.scale) / 2;
state.view.panY = (wrap.height - sh * state.view.scale) / 2;
applyViewTransform();
updateCanvasCursor();
}
function zoomBy(factor) {
dismissCanvasTip();
state.view.scale = Math.min(8, Math.max(0.1, state.view.scale * factor));
applyViewTransform();
}
function updateSheetSize() {
if (!sheetEl || !imageEl) return;
if (!imageEl.hidden && imageEl.naturalWidth) {
sheetEl.style.width = `${imageEl.naturalWidth}px`;
sheetEl.style.minHeight = `${imageEl.naturalHeight}px`;
} else {
sheetEl.style.width = "";
sheetEl.style.minWidth = "480px";
sheetEl.style.minHeight = "360px";
}
}
function renderMapImage() {
const url = mapImageUrl(state.map);
if (url && imageEl) {
imageEl.src = url;
imageEl.hidden = false;
if (emptyEl) emptyEl.hidden = true;
} else {
if (imageEl) {
imageEl.hidden = true;
imageEl.removeAttribute("src");
}
if (emptyEl) emptyEl.hidden = false;
}
updateMenuActionsUi();
updateSheetSize();
imageEl?.addEventListener(
"load",
() => {
updateSheetSize();
fitToView();
},
{ once: true },
);
if (!url) {
centerSheetInView();
applyViewTransform();
}
}
function fillSettingsForm() {
const map = state.map;
if (!map) return;
if (settingsFields.name) settingsFields.name.value = map.name || "";
if (settingsFields.desc) settingsFields.desc.value = map.description || "";
if (settingsFields.resolution) settingsFields.resolution.value = map.resolution != null ? map.resolution : 0.05;
if (settingsFields.originX) settingsFields.originX.value = map.origin_x != null ? map.origin_x : 0;
if (settingsFields.originY) settingsFields.originY.value = map.origin_y != null ? map.origin_y : 0;
if (settingsFields.originYaw) settingsFields.originYaw.value = map.origin_yaw != null ? map.origin_yaw : 0;
}
function readSettingsPayload() {
return {
name: settingsFields.name?.value.trim() || "",
description: settingsFields.desc?.value.trim() || "",
resolution: Number(settingsFields.resolution?.value) || 0.05,
origin_x: Number(settingsFields.originX?.value) || 0,
origin_y: Number(settingsFields.originY?.value) || 0,
origin_yaw: Number(settingsFields.originYaw?.value) || 0,
};
}
function updateHeader() {
if (titleEl) titleEl.textContent = state.map?.name || "—";
}
function updateMenuActionsUi() {
const hasImage = !!(state.map?.image_file);
const ro = state.readOnly;
el("mapMenuUploadOverwrite")?.toggleAttribute("disabled", ro);
el("mapMenuUploadAppend")?.toggleAttribute("disabled", true);
el("mapMenuDownload")?.toggleAttribute("disabled", !hasImage);
el("mapMenuRecordOverwrite")?.toggleAttribute("disabled", true);
el("mapMenuRecordAppend")?.toggleAttribute("disabled", true);
}
function applyReadOnlyUi() {
const ro = state.readOnly;
el("mapEditorMenuBtn")?.toggleAttribute("disabled", ro);
el("mapEditorSaveBtn")?.toggleAttribute("disabled", ro || !state.dirty);
el("mapEditorSettingsBtn")?.toggleAttribute("disabled", ro);
updateMenuActionsUi();
}
async function reloadMap() {
if (!state.mapId) return;
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
updateHeader();
renderMapImage();
fillSettingsForm();
}
function open(mapId, callbacks = {}) {
state.mapId = mapId;
state.callbacks = callbacks;
state.readOnly = !!callbacks.readOnly || !callbacks.canWrite;
state.dirty = false;
state.tipVisible = true;
state.activeTool = "pan";
if (tipEl) {
tipEl.hidden = false;
tipEl.textContent = t("maps.editor.canvasTip");
}
setActiveTool("pan");
setDirty(false);
applyReadOnlyUi();
reloadMap().catch((e) => alert(e.message));
}
function close() {
state.mapId = null;
state.map = null;
state.callbacks = {};
menuDialogEl?.close();
settingsDialogEl?.close();
activateDialogEl?.close();
}
function loadImageDimensions(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("invalid image"));
};
img.src = url;
});
}
async function uploadImage(file) {
if (!state.map || !file || state.readOnly) return;
if (!/\.png$/i.test(file.name)) {
alert(t("maps.error.pngOnly"));
return;
}
const dims = await loadImageDimensions(file);
const form = new FormData();
form.append("file", file, file.name.endsWith(".png") ? file.name : `${file.name}.png`);
const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/image`, {
credentials: "include",
method: "POST",
body: form,
});
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
let updated = await res.json();
updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...readSettingsPayload(),
width: dims.width,
height: dims.height,
}),
});
state.map = updated;
state.callbacks.onMapUpdated?.(updated);
setDirty(false);
renderMapImage();
menuDialogEl?.close();
promptActivate();
}
async function saveMap() {
if (!state.map || state.readOnly) return;
const payload = readSettingsPayload();
if (!payload.name) {
alert(t("maps.error.nameEmpty"));
return;
}
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
state.map = updated;
state.callbacks.onMapUpdated?.(updated);
setDirty(false);
updateHeader();
menuDialogEl?.close();
promptActivate();
}
function promptActivate() {
if (!state.map?.image_file) return;
if (state.callbacks.getActiveMapId?.() === state.map.id) return;
const textEl = el("mapActivateDialogText");
if (textEl) {
textEl.textContent = t("maps.activateDialog.text", { name: state.map.name });
}
activateDialogEl?.showModal();
}
async function activateCurrentMap() {
if (!state.map) return;
await api("/api/robot/active_map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ map_id: state.map.id }),
});
state.callbacks.onActivated?.(state.map.id);
activateDialogEl?.close();
}
function bindCanvasPanZoom() {
canvasWrapEl?.addEventListener("wheel", (evt) => {
evt.preventDefault();
dismissCanvasTip();
const factor = evt.deltaY < 0 ? 1.1 : 0.9;
zoomBy(factor);
}, { passive: false });
canvasWrapEl?.addEventListener("mousedown", (evt) => {
if (evt.button !== 0 || state.activeTool !== "pan") return;
dismissCanvasTip();
state.panning = {
startX: evt.clientX,
startY: evt.clientY,
startPanX: state.view.panX,
startPanY: state.view.panY,
};
updateCanvasCursor();
});
window.addEventListener("mousemove", (evt) => {
if (!state.panning) return;
state.view.panX = state.panning.startPanX + (evt.clientX - state.panning.startX);
state.view.panY = state.panning.startPanY + (evt.clientY - state.panning.startY);
applyViewTransform();
});
window.addEventListener("mouseup", () => {
if (!state.panning) return;
state.panning = null;
updateCanvasCursor();
});
}
function bindEvents() {
el("mapEditorBackBtn")?.addEventListener("click", () => {
if (state.dirty && !confirm(t("maps.editor.unsavedLeave"))) return;
state.callbacks.onClose?.();
close();
});
el("mapEditorHelpBtn")?.addEventListener("click", () => alert(t("maps.editor.helpText")));
el("mapEditorMenuBtn")?.addEventListener("click", () => {
updateMenuActionsUi();
menuDialogEl?.showModal();
});
el("mapMenuCancelBtn")?.addEventListener("click", () => menuDialogEl?.close());
menuDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
menuDialogEl?.close();
});
el("mapEditorSettingsBtn")?.addEventListener("click", () => {
fillSettingsForm();
settingsDialogEl?.showModal();
});
el("mapEditorSaveBtn")?.addEventListener("click", () => {
saveMap().catch((e) => alert(e.message));
});
el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan"));
el("mapEditorFitBtn")?.addEventListener("click", fitToView);
el("mapEditorCenterBtn")?.addEventListener("click", () => {
dismissCanvasTip();
centerSheetInView();
applyViewTransform();
});
el("mapEditorZoomInBtn")?.addEventListener("click", () => zoomBy(1.2));
el("mapEditorZoomOutBtn")?.addEventListener("click", () => zoomBy(1 / 1.2));
el("mapMenuUploadOverwrite")?.addEventListener("click", () => uploadInputEl?.click());
el("mapMenuDownload")?.addEventListener("click", () => {
const url = mapImageUrl(state.map);
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = `${state.map.name || "map"}.png`;
a.click();
menuDialogEl?.close();
});
uploadInputEl?.addEventListener("change", () => {
const file = uploadInputEl.files?.[0];
uploadInputEl.value = "";
if (!file) return;
uploadImage(file).catch((e) => alert(e.message));
});
el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
if (!state.map) return;
Object.assign(state.map, readSettingsPayload());
setDirty(true);
updateHeader();
settingsDialogEl?.close();
});
el("mapActivateYesBtn")?.addEventListener("click", () => {
activateCurrentMap().catch((e) => alert(e.message));
});
el("mapActivateNoBtn")?.addEventListener("click", () => activateDialogEl?.close());
Object.values(settingsFields).forEach((node) => {
node?.addEventListener("input", () => setDirty(true));
});
window.addEventListener("lm:locale-change", () => {
if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip");
updateHeader();
});
}
bindCanvasPanZoom();
bindEvents();
window.MapEditorApp = { open, close, reloadMap };
})();