(() => { 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 }; })();