(() => { const el = (id) => document.getElementById(id); const t = (key, vars) => window.I18n?.t(key, vars) ?? key; const Geo = () => window.MapGeo; const Occ = () => window.MapOccupancyCanvas; const state = { mapId: null, map: null, callbacks: {}, readOnly: false, dirty: false, activeTool: "pan", /** Layer 1 — view space (screen pan/zoom only). */ view: Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 }, panning: null, tipVisible: true, showOrigin: true, /** ROS yaml thresholds (occupied_thresh, free_thresh, negate) from map.yaml. */ yamlMeta: null, /** Pending ROS metadata from upload dialog (set before PNG picker). */ uploadMeta: null, }; const titleEl = el("mapEditorTitle"); const dirtyEl = el("mapEditorDirty"); const canvasWrapEl = el("mapEditorCanvasWrap"); const viewportEl = el("mapEditorViewport"); const canvasInnerEl = el("mapEditorCanvasInner"); const imageLayerEl = el("mapEditorImageLayer"); const sheetEl = el("mapEditorSheet"); const gridEl = el("mapEditorSheetGrid"); const imageEl = el("mapEditorImage"); const occupancyCanvasEl = el("mapEditorOccupancyCanvas"); const originEl = el("mapEditorOrigin"); const originHitEl = el("mapEditorOriginHit"); const originLabelEl = el("mapEditorOriginLabel"); const emptyEl = el("mapEditorEmpty"); const tipEl = el("mapEditorCanvasTip"); const statusViewEl = el("mapEditorStatusView"); const statusImageEl = el("mapEditorStatusImage"); const statusWorldEl = el("mapEditorStatusWorld"); const uploadInputEl = el("mapEditorUploadInput"); const menuDialogEl = el("mapEditorMenuDialog"); const settingsDialogEl = el("mapEditorSettingsDialog"); const activateDialogEl = el("mapActivateDialog"); const uploadConfirmDialogEl = el("mapUploadConfirmDialog"); const uploadMetaDialogEl = el("mapUploadMetaDialog"); const uploadYamlInputEl = el("mapUploadYamlInput"); const uploadMetaFields = { resolution: el("mapUploadResolution"), originX: el("mapUploadOriginX"), originY: el("mapUploadOriginY"), originYaw: el("mapUploadOriginYaw"), negate: el("mapUploadNegate"), occupiedThresh: el("mapUploadOccupiedThresh"), freeThresh: el("mapUploadFreeThresh"), }; 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 viewportSize() { const rect = viewportEl?.getBoundingClientRect(); return { width: rect?.width || 1, height: rect?.height || 1 }; } /** Layer 2 — image dimensions in floor-plan pixels. */ function floorPlanSize() { return Geo()?.imageSize(state.map, imageEl) || { width: 0, height: 0 }; } function hasFloorPlan() { return !!( state.map?.image_file && imageEl && !imageEl.hidden && imageEl.naturalWidth && occupancyCanvasEl && !occupancyCanvasEl.hidden && occupancyCanvasEl.width ); } /** ROS yaml thresholds for occupancy coloring. */ function mapRenderMeta() { const base = mapMetaForOriginDisplay() || state.map || {}; const yaml = state.yamlMeta || {}; return { occupied_thresh: base.occupied_thresh != null ? base.occupied_thresh : yaml.occupied_thresh, free_thresh: base.free_thresh != null ? base.free_thresh : yaml.free_thresh, negate: base.negate != null ? base.negate : yaml.negate, }; } async function loadYamlMeta() { state.yamlMeta = await fetchExistingYamlMeta(); } function setOccupancyCanvasVisible(visible) { if (!occupancyCanvasEl) return; occupancyCanvasEl.hidden = !visible; occupancyCanvasEl.setAttribute("aria-hidden", visible ? "false" : "true"); } /** Paint RViz-style occupancy colors from loaded PNG (hidden loader img). */ function paintOccupancyFromImage() { const occ = Occ(); if (!occ || !occupancyCanvasEl || !imageEl?.naturalWidth) return false; const ok = occ.renderFromImage(occupancyCanvasEl, imageEl, mapRenderMeta()); setOccupancyCanvasVisible(ok); return ok; } /** * Paint live occupancy grid (record/stream — roadmap step 2+). * @param {{ width: number, height: number, data: number[]|Int8Array|string }} grid */ function paintOccupancyGrid(grid) { const occ = Occ(); if (!occ || !occupancyCanvasEl || !grid) return false; const ok = occ.renderGrid(occupancyCanvasEl, grid); if (ok) setOccupancyCanvasVisible(true); return ok; } 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(); } /** Layer 1 — apply view transform to inner canvas only. */ function applyViewTransform() { Geo()?.applyViewTransform(canvasInnerEl, state.view); updateStatusBar(); } function fitToView() { dismissCanvasTip(); const vp = viewportSize(); const { width, height } = floorPlanSize(); const blankW = sheetEl?.offsetWidth || 480; const blankH = sheetEl?.offsetHeight || 360; state.view = Geo()?.fitViewToImage( vp.width, vp.height, hasFloorPlan() ? width : blankW, hasFloorPlan() ? height : blankH, ) || state.view; applyViewTransform(); updateCanvasCursor(); } function centerSheetInView() { const vp = viewportSize(); const { width, height } = floorPlanSize(); const blankW = sheetEl?.offsetWidth || 480; const blankH = sheetEl?.offsetHeight || 360; state.view = Geo()?.centerViewOnImage( vp.width, vp.height, hasFloorPlan() ? width : blankW, hasFloorPlan() ? height : blankH, state.view, ) || state.view; applyViewTransform(); } function zoomBy(factor, anchorClientX, anchorClientY) { dismissCanvasTip(); const vpRect = viewportEl?.getBoundingClientRect(); if (!vpRect) return; const anchorVx = anchorClientX != null ? anchorClientX - vpRect.left : vpRect.width / 2; const anchorVy = anchorClientY != null ? anchorClientY - vpRect.top : vpRect.height / 2; state.view = Geo()?.zoomViewAt(state.view, anchorVx, anchorVy, factor) || state.view; applyViewTransform(); } /** Layer 2 — sheet sized 1:1 with PNG pixels. */ function updateImageLayer() { if (!sheetEl || !imageLayerEl) return; const has = hasFloorPlan(); const { width, height } = floorPlanSize(); sheetEl.classList.toggle("mapEditorSheet--hasImage", has); sheetEl.classList.toggle("mapEditorSheet--blank", !has); if (has && width && height) { sheetEl.style.width = `${width}px`; sheetEl.style.height = `${height}px`; imageLayerEl.style.width = `${width}px`; imageLayerEl.style.height = `${height}px`; } else { sheetEl.style.width = ""; sheetEl.style.height = ""; sheetEl.style.minWidth = "480px"; sheetEl.style.minHeight = "360px"; imageLayerEl.style.width = ""; imageLayerEl.style.height = ""; } if (gridEl && has) { const mapForLayer = mapMetaForOriginDisplay() || state.map; const steps = Geo()?.gridSteps(mapForLayer) || { minor: 20, major: 100 }; gridEl.style.setProperty("--map-grid-minor", `${steps.minor}px`); gridEl.style.setProperty("--map-grid-major", `${steps.major}px`); gridEl.hidden = false; } else if (gridEl) { gridEl.hidden = true; } updateOriginMarker(); updateStatusBar(); } /** Origin fields → world (0,0) on the floor plan (ROS map_server). */ function mapMetaForOriginDisplay() { if (!state.map) return null; const base = { ...state.map }; if (uploadMetaDialogEl?.open) { const m = readUploadMetaPayload(); return { ...base, ...m }; } if (settingsDialogEl?.open || state.dirty) { const s = readSettingsPayload(); return { ...base, ...s }; } return base; } function setShowOrigin(show) { state.showOrigin = !!show; const btn = el("mapEditorOriginBtn"); btn?.classList.toggle("is-active", state.showOrigin); btn?.setAttribute("aria-pressed", state.showOrigin ? "true" : "false"); updateOriginMarker(); } function setOriginLabelVisible(visible) { if (!originEl) return; originEl.classList.toggle("mapEditorOrigin--showLabel", !!visible); if (originLabelEl) originLabelEl.setAttribute("aria-hidden", visible ? "false" : "true"); } function updateOriginMarker() { if (!originEl) return; const geo = Geo(); const { width, height } = floorPlanSize(); if (!state.showOrigin || !geo || !hasFloorPlan() || !width || !height) { originEl.hidden = true; originEl.setAttribute("aria-hidden", "true"); return; } const mapMeta = mapMetaForOriginDisplay(); const pt = geo.worldToPixel(mapMeta, width, height, 0, 0); const ox = Number(mapMeta?.origin_x) || 0; const oy = Number(mapMeta?.origin_y) || 0; const oyaw = Number(mapMeta?.origin_yaw) || 0; const yawDeg = (-oyaw * 180) / Math.PI; const onMap = pt.x >= -2 && pt.y >= -2 && pt.x <= width + 2 && pt.y <= height + 2; originEl.hidden = false; originEl.setAttribute("aria-hidden", "false"); originEl.classList.toggle("mapEditorOrigin--offMap", !onMap); originEl.style.left = `${pt.x}px`; originEl.style.top = `${pt.y}px`; originEl.style.transform = `rotate(${yawDeg}deg)`; if (originLabelEl) { originLabelEl.textContent = t("maps.editor.originLabelShort", { x: ox.toFixed(2), y: oy.toFixed(2), }); originLabelEl.setAttribute("aria-hidden", "true"); } const tooltip = t("maps.editor.originTooltip", { x: ox.toFixed(3), y: oy.toFixed(3), yaw: ((oyaw * 180) / Math.PI).toFixed(1), }); if (originHitEl) { originHitEl.title = tooltip; originHitEl.setAttribute("aria-label", tooltip); } setOriginLabelVisible(false); } function updateStatusBar(pointerClient) { const geo = Geo(); const { width, height } = floorPlanSize(); const pct = Math.round((state.view.scale || 1) * 100); if (statusViewEl) { statusViewEl.textContent = t("maps.editor.statusView", { zoom: pct, panX: Math.round(state.view.panX), panY: Math.round(state.view.panY), }); } if (!geo || !hasFloorPlan() || !pointerClient) { if (statusImageEl) statusImageEl.textContent = t("maps.editor.statusImageIdle"); if (statusWorldEl) statusWorldEl.textContent = t("maps.editor.statusWorldIdle"); return; } const sheetRect = sheetEl?.getBoundingClientRect(); const imgPt = geo.clientToImage(pointerClient.x, pointerClient.y, sheetRect, width, height); if (!imgPt) { if (statusImageEl) statusImageEl.textContent = t("maps.editor.statusImageIdle"); if (statusWorldEl) statusWorldEl.textContent = t("maps.editor.statusWorldIdle"); return; } const world = geo.pixelToWorld(state.map, width, height, imgPt.x, imgPt.y); if (statusImageEl) { statusImageEl.textContent = t("maps.editor.statusImage", { px: Math.round(imgPt.x), py: Math.round(imgPt.y), }); } if (statusWorldEl) { statusWorldEl.textContent = t("maps.editor.statusWorld", { x: world.x.toFixed(2), y: world.y.toFixed(2), }); } } function renderMapImage() { const url = mapImageUrl(state.map); if (url && imageEl) { imageEl.src = url; imageEl.hidden = false; if (emptyEl) emptyEl.hidden = true; setOccupancyCanvasVisible(false); } else { if (imageEl) { imageEl.hidden = true; imageEl.removeAttribute("src"); } setOccupancyCanvasVisible(false); if (emptyEl) emptyEl.hidden = false; } updateMenuActionsUi(); updateImageLayer(); imageEl?.addEventListener( "load", () => { paintOccupancyFromImage(); updateImageLayer(); 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)}`); await loadYamlMeta(); 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.showOrigin = true; state.activeTool = "pan"; state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 }; if (tipEl) { tipEl.hidden = false; tipEl.textContent = t("maps.editor.canvasTip"); } setActiveTool("pan"); setShowOrigin(true); setDirty(false); applyReadOnlyUi(); reloadMap().catch((e) => alert(e.message)); } function close() { state.mapId = null; state.map = null; state.callbacks = {}; state.uploadMeta = null; state.yamlMeta = null; menuDialogEl?.close(); settingsDialogEl?.close(); activateDialogEl?.close(); uploadConfirmDialogEl?.close(); uploadMetaDialogEl?.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; }); } function readUploadMetaPayload() { return { resolution: Number(uploadMetaFields.resolution?.value) || 0.05, origin_x: Number(uploadMetaFields.originX?.value) || 0, origin_y: Number(uploadMetaFields.originY?.value) || 0, origin_yaw: Number(uploadMetaFields.originYaw?.value) || 0, negate: Number(uploadMetaFields.negate?.value) || 0, occupied_thresh: Number(uploadMetaFields.occupiedThresh?.value) || 0.65, free_thresh: Number(uploadMetaFields.freeThresh?.value) || 0.196, }; } function fillUploadMetaForm(meta) { const m = meta || {}; if (uploadMetaFields.resolution) { uploadMetaFields.resolution.value = m.resolution != null ? m.resolution : 0.05; } if (uploadMetaFields.originX) uploadMetaFields.originX.value = m.origin_x != null ? m.origin_x : 0; if (uploadMetaFields.originY) uploadMetaFields.originY.value = m.origin_y != null ? m.origin_y : 0; if (uploadMetaFields.originYaw) uploadMetaFields.originYaw.value = m.origin_yaw != null ? m.origin_yaw : 0; if (uploadMetaFields.negate) uploadMetaFields.negate.value = m.negate != null ? m.negate : 0; if (uploadMetaFields.occupiedThresh) { uploadMetaFields.occupiedThresh.value = m.occupied_thresh != null ? m.occupied_thresh : 0.65; } if (uploadMetaFields.freeThresh) { uploadMetaFields.freeThresh.value = m.free_thresh != null ? m.free_thresh : 0.196; } } async function fetchExistingYamlMeta() { if (!state.map?.yaml_file) return null; try { const res = await fetch(`/api/maps/${encodeURIComponent(state.map.id)}/yaml`, { credentials: "include", }); if (!res.ok) return null; const text = await res.text(); const parsed = window.MapYaml?.parse(text); if (!parsed || parsed.error) return null; return parsed; } catch { return null; } } async function openUploadMetaDialog() { const map = state.map; const defaults = { resolution: map?.resolution != null ? map.resolution : 0.05, origin_x: map?.origin_x != null ? map.origin_x : 0, origin_y: map?.origin_y != null ? map.origin_y : 0, origin_yaw: map?.origin_yaw != null ? map.origin_yaw : 0, negate: 0, occupied_thresh: 0.65, free_thresh: 0.196, }; const fromYaml = await fetchExistingYamlMeta(); fillUploadMetaForm(fromYaml || defaults); menuDialogEl?.close(); uploadMetaDialogEl?.showModal(); updateOriginMarker(); } function beginUploadOverwrite() { if (!state.map || state.readOnly) return; if (state.map.image_file) { const textEl = el("mapUploadConfirmText"); if (textEl) textEl.textContent = t("maps.uploadConfirm.text"); menuDialogEl?.close(); uploadConfirmDialogEl?.showModal(); return; } openUploadMetaDialog().catch((e) => alert(e.message)); } function applyYamlToUploadForm(text) { const parsed = window.MapYaml?.parse(text); if (!parsed || parsed.error) { alert(t("maps.uploadMeta.invalidYaml")); return; } fillUploadMetaForm(parsed); } async function saveYamlForMap(imageFilename) { const meta = state.uploadMeta || readUploadMetaPayload(); const yamlText = window.MapYaml?.serialize({ ...meta, image: imageFilename || "map.png", }); if (!yamlText) return; await api(`/api/maps/${encodeURIComponent(state.map.id)}/yaml`, { method: "POST", headers: { "Content-Type": "text/yaml; charset=utf-8" }, body: yamlText, }); } async function uploadImage(file) { if (!state.map || !file || state.readOnly) return; if (!/\.png$/i.test(file.name)) { alert(t("maps.error.pngOnly")); return; } const meta = state.uploadMeta || readUploadMetaPayload(); if (!meta.resolution || meta.resolution <= 0) { alert(t("maps.uploadMeta.invalidResolution")); return; } const dims = await loadImageDimensions(file); const pngName = file.name.endsWith(".png") ? file.name : `${file.name}.png`; const form = new FormData(); form.append("file", file, pngName); 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({ resolution: meta.resolution, origin_x: meta.origin_x, origin_y: meta.origin_y, origin_yaw: meta.origin_yaw, width: dims.width, height: dims.height, }), }); try { await saveYamlForMap(pngName); updated = (await api(`/api/maps/${encodeURIComponent(state.map.id)}`)) || updated; } catch { /* yaml save is best-effort */ } state.uploadMeta = null; state.map = updated; state.callbacks.onMapUpdated?.(updated); setDirty(false); fillSettingsForm(); await loadYamlMeta(); renderMapImage(); menuDialogEl?.close(); uploadMetaDialogEl?.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(); updateImageLayer(); 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() { const blockNativeDrag = (evt) => { evt.preventDefault(); }; imageEl?.addEventListener("dragstart", blockNativeDrag); sheetEl?.addEventListener("dragstart", blockNativeDrag); imageLayerEl?.addEventListener("dragstart", blockNativeDrag); viewportEl?.addEventListener("wheel", (evt) => { evt.preventDefault(); dismissCanvasTip(); const factor = evt.deltaY < 0 ? 1.1 : 0.9; zoomBy(factor, evt.clientX, evt.clientY); }, { passive: false }); viewportEl?.addEventListener("mousemove", (evt) => { updateStatusBar({ x: evt.clientX, y: evt.clientY }); }); viewportEl?.addEventListener("mouseleave", () => { updateStatusBar(); }); viewportEl?.addEventListener("mousedown", (evt) => { if (evt.button !== 0 || state.activeTool !== "pan") return; evt.preventDefault(); 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(); }); window.addEventListener("resize", () => { if (!state.mapId) return; applyViewTransform(); }); } function bindEvents() { originHitEl?.addEventListener("mouseenter", () => setOriginLabelVisible(true)); originHitEl?.addEventListener("mouseleave", () => setOriginLabelVisible(false)); originHitEl?.addEventListener("focus", () => setOriginLabelVisible(true)); originHitEl?.addEventListener("blur", () => setOriginLabelVisible(false)); 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(); updateOriginMarker(); }); el("mapEditorSaveBtn")?.addEventListener("click", () => { saveMap().catch((e) => alert(e.message)); }); el("mapEditorPanBtn")?.addEventListener("click", () => setActiveTool("pan")); el("mapEditorOriginBtn")?.addEventListener("click", () => setShowOrigin(!state.showOrigin)); el("mapEditorFitBtn")?.addEventListener("click", fitToView); el("mapEditorCenterBtn")?.addEventListener("click", () => { dismissCanvasTip(); centerSheetInView(); }); el("mapEditorZoomInBtn")?.addEventListener("click", () => { const rect = viewportEl?.getBoundingClientRect(); zoomBy(1.2, rect ? rect.left + rect.width / 2 : undefined, rect ? rect.top + rect.height / 2 : undefined); }); el("mapEditorZoomOutBtn")?.addEventListener("click", () => { const rect = viewportEl?.getBoundingClientRect(); zoomBy(1 / 1.2, rect ? rect.left + rect.width / 2 : undefined, rect ? rect.top + rect.height / 2 : undefined); }); el("mapMenuUploadOverwrite")?.addEventListener("click", () => beginUploadOverwrite()); 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("mapUploadConfirmYesBtn")?.addEventListener("click", () => { uploadConfirmDialogEl?.close(); openUploadMetaDialog().catch((e) => alert(e.message)); }); el("mapUploadConfirmNoBtn")?.addEventListener("click", () => uploadConfirmDialogEl?.close()); uploadConfirmDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); uploadConfirmDialogEl?.close(); }); el("mapUploadImportYamlBtn")?.addEventListener("click", () => uploadYamlInputEl?.click()); uploadYamlInputEl?.addEventListener("change", () => { const file = uploadYamlInputEl.files?.[0]; uploadYamlInputEl.value = ""; if (!file) return; const reader = new FileReader(); reader.onload = () => { applyYamlToUploadForm(String(reader.result || "")); updateOriginMarker(); }; reader.onerror = () => alert(t("maps.uploadMeta.invalidYaml")); reader.readAsText(file); }); el("mapUploadMetaCancelBtn")?.addEventListener("click", () => { state.uploadMeta = null; uploadMetaDialogEl?.close(); updateOriginMarker(); }); uploadMetaDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); state.uploadMeta = null; uploadMetaDialogEl?.close(); updateOriginMarker(); }); el("mapUploadMetaForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); const meta = readUploadMetaPayload(); if (!meta.resolution || meta.resolution <= 0) { alert(t("maps.uploadMeta.invalidResolution")); return; } state.uploadMeta = meta; uploadMetaDialogEl?.close(); uploadInputEl?.click(); }); el("mapEditorSettingsForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); if (!state.map) return; Object.assign(state.map, readSettingsPayload()); setDirty(true); updateImageLayer(); 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); updateOriginMarker(); }); }); Object.values(uploadMetaFields).forEach((node) => { node?.addEventListener("input", () => { updateOriginMarker(); if (uploadMetaDialogEl?.open && imageEl?.naturalWidth) { paintOccupancyFromImage(); } if (node === uploadMetaFields.resolution && uploadMetaDialogEl?.open) { updateImageLayer(); } }); }); window.addEventListener("lm:locale-change", () => { if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip"); updateHeader(); updateStatusBar(); updateOriginMarker(); }); } bindCanvasPanZoom(); bindEvents(); window.MapEditorApp = { open, close, reloadMap, paintOccupancyGrid, paintOccupancyFromImage, }; })();