(() => { 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 OccEdit = () => window.MapOccupancyEdit; const Objects = () => window.MapObjects; const Planner = () => window.MapPlannerZones; const Behavior = () => window.MapBehaviorZones; const Advanced = () => window.MapAdvancedZones; const state = { mapId: null, map: null, callbacks: {}, readOnly: false, dirty: false, activeTool: "pan", /** Selected object type for MiR-style zones (wall | floor | position). */ objectType: "", /** Saved zones — persisted to database via map.zones on save. */ zones: [], /** In-progress draft: shape (wall/floor) or position placement. */ draft: null, selectedZoneId: null, undoStack: [], /** Grayscale floor-plan raster (map_server PNG) was modified. */ rasterDirty: false, /** Base scan layer edited by eraser (map_base.png). */ baseDirty: false, /** Wall/floor zone ids already baked into composite — hide SVG unless selected. */ bakedZoneIds: new Set(), /** Active brush stroke for pixel eraser. */ eraserStroke: null, /** Dragging a wall/floor vertex in Select mode. */ vertexDrag: null, /** Dragging erase-by-selection rectangle. */ selectionDrag: null, /** Live selection rectangle (image pixels). */ selectionRect: null, /** Placing a position: click anchor + drag for yaw. */ positionDrag: null, /** Pending position dialog payload. */ pendingPosition: null, /** Pending speed/sound zone dialog payload. */ pendingBehavior: null, /** Pending directional / planner / I/O zone dialog payload. */ pendingAdvanced: null, /** Line width chosen in Draw line dialog (cm). */ lineDrawWidthCm: null, /** Cached sounds for sound-zone picker. */ soundsCache: [], /** Suppress click after pointer drag. */ pointerMoved: false, /** 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 objectsSvgEl = el("mapEditorObjectsSvg"); const objectTypeBtnEl = el("mapEditorObjectTypeBtn"); const objectTypeIconEl = el("mapEditorObjectTypeIcon"); const objectTypeLabelEl = el("mapEditorObjectTypeLabel"); const objectTypeMenuEl = el("mapEditorObjectTypeMenu"); const objectTypeOptionEls = () => objectTypeMenuEl?.querySelectorAll(".mapEditorObjectTypeOption") || []; 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 positionDialogEl = el("mapEditorPositionDialog"); const positionFormEl = el("mapEditorPositionForm"); const positionFields = { name: el("mapPositionName"), x: el("mapPositionX"), y: el("mapPositionY"), yaw: el("mapPositionYaw"), }; const speedDialogEl = el("mapEditorSpeedDialog"); const speedFormEl = el("mapEditorSpeedForm"); const speedFields = { speed: el("mapSpeedMps"), }; const soundDialogEl = el("mapEditorSoundDialog"); const soundFormEl = el("mapEditorSoundForm"); const soundFields = { soundId: el("mapSoundZoneSelect"), manageLink: el("mapSoundManageLink"), }; const directionalDialogEl = el("mapEditorDirectionalDialog"); const directionalFormEl = el("mapEditorDirectionalForm"); const directionalFields = { shapePanel: el("mapDirectionalShapePanel"), linePanel: el("mapDirectionalLinePanel"), direction: el("mapDirectionalDeg"), reversed: el("mapDirectionalReversed"), lineWidth: el("mapDirectionalLineWidth"), }; const plannerDialogEl = el("mapEditorPlannerDialog"); const plannerFormEl = el("mapEditorPlannerForm"); const plannerFields = { noLocalization: el("mapPlannerNoLocalization"), lookAhead: el("mapPlannerLookAhead"), ignoreObstacles: el("mapPlannerIgnoreObstacles"), pathDeviation: el("mapPlannerPathDeviation"), pathTimeout: el("mapPlannerPathTimeout"), }; const ioDialogEl = el("mapEditorIoDialog"); const ioFormEl = el("mapEditorIoForm"); const drawLineDialogEl = el("mapEditorDrawLineDialog"); const drawLineFormEl = el("mapEditorDrawLineForm"); const drawLineFields = { width: el("mapDrawLineWidth"), }; const ioFields = { module: el("mapIoModule"), plcRegister: el("mapIoPlcRegister"), plcValue: el("mapIoPlcValue"), plcMode: el("mapIoPlcMode"), }; /** Off-screen base scan layer (map_base.png — eraser edits this). */ let baseCanvasEl = null; function getBaseCanvas() { if (!baseCanvasEl) { baseCanvasEl = document.createElement("canvas"); baseCanvasEl.className = "mapEditorBaseCanvas"; baseCanvasEl.hidden = true; baseCanvasEl.setAttribute("aria-hidden", "true"); } return baseCanvasEl; } /** Off-screen composite = rebake(base + zones). */ let sourceCanvasEl = null; function getSourceCanvas() { if (!sourceCanvasEl) { sourceCanvasEl = document.createElement("canvas"); sourceCanvasEl.className = "mapEditorSourceCanvas"; sourceCanvasEl.hidden = true; sourceCanvasEl.setAttribute("aria-hidden", "true"); } return sourceCanvasEl; } 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 || "")}`; } /** Base scan layer for editor rebake (map_base.png). */ function mapBaseImageUrl(map) { if (!map?.id || !map.image_file) return null; return `/api/maps/${encodeURIComponent(map.id)}/image/base?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"); } /** Load base scan, rebake composite (base + zone layers), paint display. */ function paintOccupancyFromImage() { const occ = Occ(); if (!occ || !occupancyCanvasEl || !imageEl?.naturalWidth) return false; initBaseFromImage(); const ok = refreshDisplayFromSource(); setOccupancyCanvasVisible(ok); return ok; } function initBaseFromImage() { const edit = OccEdit(); if (!edit || !imageEl?.naturalWidth) return false; const ok = edit.initSourceFromImage(getBaseCanvas(), imageEl); if (!ok) return false; state.baseDirty = false; rebakeComposite(); return true; } function floorPlanZoneIds() { const obj = Objects(); return new Set( state.zones.filter((z) => obj?.isFloorPlanType(z.type)).map((z) => z.id), ); } function rebakeComposite() { const edit = OccEdit(); if (!edit?.rebakeComposite(getBaseCanvas(), getSourceCanvas(), state.zones, mapMetaForEditor())) return false; state.bakedZoneIds = floorPlanZoneIds(); refreshDisplayFromSource(); return true; } function refreshDisplayFromSource() { const edit = OccEdit(); if (!edit || !occupancyCanvasEl || !getSourceCanvas().width) return false; return edit.renderDisplayFromSource(occupancyCanvasEl, getSourceCanvas(), mapRenderMeta(), Occ()); } /** * 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); canvasWrapEl.classList.toggle( "is-draw-tool", isDrawTool(state.activeTool) || state.activeTool === "select" || state.activeTool === "eraseShape" || state.activeTool === "eraseSelection", ); canvasWrapEl.classList.toggle("is-eraser-tool", state.activeTool === "eraser"); canvasWrapEl.classList.toggle("is-erase-selection-tool", state.activeTool === "eraseSelection"); } function isDrawTool(tool) { return tool === "drawLine" || tool === "drawShape"; } function drawToolSupported(tool) { const obj = Objects(); const type = state.objectType; if (!obj || !type) return false; if (tool === "drawLine") return obj.supportsDrawLine(type); if (tool === "drawShape") return obj.supportsDrawShape(type); return false; } function isInteractionBlocked() { return !!( state.draft || state.eraserStroke || state.vertexDrag || state.selectionDrag || state.positionDrag ); } function clearTransientDrag() { state.vertexDrag = null; state.selectionDrag = null; state.selectionRect = null; state.positionDrag = null; state.pointerMoved = false; } function setActiveTool(tool) { const allowed = ["pan", "drawLine", "drawShape", "select", "eraser", "eraseShape", "eraseSelection"]; if (!allowed.includes(tool)) return; if (tool !== "pan" && !state.objectType) return; if (isDrawTool(tool) && !drawToolSupported(tool)) return; if (isDrawTool(tool) && isDrawTool(state.activeTool) && tool !== state.activeTool) cancelDraft(); if (!isDrawTool(tool)) cancelDraft(); state.eraserStroke = null; clearTransientDrag(); state.activeTool = tool; toolBtnEls().forEach((btn) => { btn.classList.toggle("is-active", btn.dataset.tool === tool); }); updateCanvasCursor(); renderObjects(); } function beginDrawLine() { if (state.readOnly || !drawToolSupported("drawLine")) return; const obj = Objects(); const type = state.objectType; const minWidth = obj?.minLineWidthCm(type) ?? 10; const defaultW = state.lineDrawWidthCm ?? obj?.defaultLineWidthCm(type) ?? 40; if (drawLineFields.width) { drawLineFields.width.min = String(minWidth); drawLineFields.width.step = "1"; drawLineFields.width.value = String(Math.round(defaultW)); } drawLineDialogEl?.showModal(); } function cancelDrawLineDialog() { drawLineDialogEl?.close(); } function commitDrawLineDialog() { const obj = Objects(); const type = state.objectType; const minWidth = obj?.minLineWidthCm(type) ?? 10; const widthCm = Number(drawLineFields.width?.value); if (!Number.isFinite(widthCm) || widthCm < minWidth) { alert(t("maps.editor.drawLine.lineWidthInvalid")); return false; } state.lineDrawWidthCm = widthCm; if (state.draft) cancelDraft(); setActiveTool("drawLine"); drawLineDialogEl?.close(); return true; } function syncZonesFromMap() { state.zones = Objects()?.parseZones(state.map?.zones, mapMetaForEditor()) || []; state.undoStack = []; state.draft = null; state.selectedZoneId = null; state.eraserStroke = null; if (getBaseCanvas().width) { rebakeComposite(); } else { state.bakedZoneIds = floorPlanZoneIds(); } renderObjects(); updateUndoUi(); } function pushUndo() { const edit = OccEdit(); state.undoStack.push({ zones: JSON.parse(JSON.stringify(state.zones)), baseRaster: edit?.cloneRaster(getBaseCanvas()), bakedIds: [...state.bakedZoneIds], }); if (state.undoStack.length > 30) state.undoStack.shift(); updateUndoUi(); } function updateUndoUi() { const blocked = isInteractionBlocked(); el("mapEditorUndoBtn")?.toggleAttribute( "disabled", !state.undoStack.length || state.readOnly || blocked, ); } function undoZones() { if (!state.undoStack.length || isInteractionBlocked()) return; const snap = state.undoStack.pop(); state.zones = snap.zones; state.bakedZoneIds = new Set(snap.bakedIds || []); state.draft = null; state.selectedZoneId = null; OccEdit()?.restoreRaster(getBaseCanvas(), snap.baseRaster); rebakeComposite(); renderObjects(); setDirty(true); state.rasterDirty = true; state.baseDirty = true; updateUndoUi(); } function mapMetaForEditor() { return mapMetaForOriginDisplay() || state.map || {}; } function renderObjects() { const obj = Objects(); if (!objectsSvgEl || !obj) return; const has = hasFloorPlan(); const { width, height } = floorPlanSize(); if (!has || !width) { objectsSvgEl.hidden = true; objectsSvgEl.setAttribute("aria-hidden", "true"); return; } objectsSvgEl.hidden = false; objectsSvgEl.setAttribute("aria-hidden", "false"); objectsSvgEl.setAttribute("width", String(width)); objectsSvgEl.setAttribute("height", String(height)); objectsSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); const visibleZones = state.zones.filter( (z) => !obj.isFloorPlanType(z.type) || !state.bakedZoneIds.has(z.id) || z.id === state.selectedZoneId, ); obj.render(objectsSvgEl, visibleZones, { mapMeta: mapMetaForEditor(), imageWidth: width, imageHeight: height, selectedId: state.selectedZoneId, showVertices: state.activeTool === "select", draft: state.draft, selectionRect: state.selectionRect, }); } function imagePointFromEvent(evt) { const geo = Geo(); const { width, height } = floorPlanSize(); const rect = sheetEl?.getBoundingClientRect(); return geo?.clientToImage(evt.clientX, evt.clientY, rect, width, height); } function removeZone(id) { const obj = Objects(); const zone = state.zones.find((z) => z.id === id); pushUndo(); state.zones = state.zones.filter((z) => z.id !== id); if (state.selectedZoneId === id) state.selectedZoneId = null; if (obj?.isFloorPlanType(zone?.type)) { rebakeComposite(); state.rasterDirty = true; } renderObjects(); setDirty(true); } function commitDraft() { const obj = Objects(); if (!state.draft || state.draft.kind !== "shape" || !obj) return false; const drawMode = state.draft.drawMode || "shape"; const zoneType = obj.resolveZoneTypeForDraw(state.draft.type, drawMode); const lineWidthCm = state.draft.line_width_cm ?? state.lineDrawWidthCm ?? obj.defaultLineWidthCm(state.draft.type); const extra = {}; if (drawMode === "line" && obj.isPlannerZoneType(state.draft.type)) { extra.geometry = obj.GEOMETRY_LINE; extra.line_width_cm = lineWidthCm; } if (state.draft.type === obj.TYPES.speed) { extra.speed_mps = Behavior()?.DEFAULT_SPEED_MPS ?? 0.8; } if (zoneType === obj.TYPES.directional) { extra.direction_deg = 0; } if (zoneType === obj.TYPES.directional_line || zoneType === obj.TYPES.wall) { extra.line_width_cm = lineWidthCm; } if (state.draft.type === obj.TYPES.planner) { Object.assign(extra, Advanced()?.DEFAULT_PLANNER || {}); } if (state.draft.type === obj.TYPES.io) { extra.io_module = ""; } const zone = obj.createZone(zoneType, state.draft.points, extra); if (!zone) return false; pushUndo(); state.zones = [...state.zones, zone]; state.draft = null; if (obj.isFloorPlanType(zone.type)) { rebakeComposite(); state.rasterDirty = true; } renderObjects(); setDirty(true); updateDraftUi(); updateUndoUi(); if (obj.isBehaviorZoneType(zone.type)) { state.selectedZoneId = zone.id; renderObjects(); openBehaviorDialog(zone.id, "create"); } if (obj.isAdvancedZoneType(zone.type)) { state.selectedZoneId = zone.id; renderObjects(); openAdvancedDialog(zone.id, "create"); } return true; } function cancelDraft() { state.draft = null; state.positionDrag = null; updateDraftUi(); updateUndoUi(); renderObjects(); } function updateDraftUi() { const obj = Objects(); const shapeDraft = state.draft?.kind === "shape" ? state.draft : null; const canConfirm = shapeDraft && obj && shapeDraft.points.length >= obj.minPointsForDraw(shapeDraft.type, shapeDraft.drawMode || "shape"); const confirmBtn = el("mapEditorConfirmDrawBtn"); if (confirmBtn) { confirmBtn.hidden = !shapeDraft; confirmBtn.toggleAttribute("disabled", !canConfirm || state.readOnly); } } function objectTypeLabelKey(type) { if (type === "wall") return "maps.editor.objectType.wall"; if (type === "floor") return "maps.editor.objectType.floor"; if (type === "position") return "maps.editor.objectType.position"; if (type === "forbidden") return "maps.editor.objectType.forbidden"; if (type === "preferred") return "maps.editor.objectType.preferred"; if (type === "unpreferred") return "maps.editor.objectType.unpreferred"; if (type === "speed") return "maps.editor.objectType.speed"; if (type === "sound") return "maps.editor.objectType.sound"; if (type === "directional") return "maps.editor.objectType.directional"; if (type === "directional_line") return "maps.editor.objectType.directionalLine"; if (type === "planner") return "maps.editor.objectType.planner"; if (type === "io") return "maps.editor.objectType.io"; return "maps.editor.objectTypesNone"; } function objectTypeIconMarkup(type) { if (type === "wall") { return ``; } if (type === "floor") { return ``; } if (type === "position") { return ``; } if (type === "forbidden") { return ``; } if (type === "preferred") { return ``; } if (type === "unpreferred") { return ``; } if (type === "speed") { return ``; } if (type === "sound") { return ``; } if (type === "directional") { return ``; } if (type === "directional_line") { return ``; } if (type === "planner") { return ``; } if (type === "io") { return ``; } return ``; } function closeObjectTypeMenu() { objectTypeMenuEl?.setAttribute("hidden", ""); objectTypeBtnEl?.setAttribute("aria-expanded", "false"); objectTypeMenuEl?.removeAttribute("style"); el("mapEditorObjectTypePicker")?.classList.remove("is-open"); } function positionObjectTypeMenu() { if (!objectTypeBtnEl || !objectTypeMenuEl || objectTypeMenuEl.hidden) return; const rect = objectTypeBtnEl.getBoundingClientRect(); objectTypeMenuEl.style.position = "fixed"; objectTypeMenuEl.style.top = `${rect.bottom + 1}px`; objectTypeMenuEl.style.left = `${rect.left}px`; objectTypeMenuEl.style.width = `${rect.width}px`; objectTypeMenuEl.style.right = "auto"; objectTypeMenuEl.style.zIndex = "200"; } function openObjectTypeMenu() { if (objectTypeBtnEl?.disabled) return; objectTypeMenuEl?.removeAttribute("hidden"); objectTypeBtnEl?.setAttribute("aria-expanded", "true"); el("mapEditorObjectTypePicker")?.classList.add("is-open"); positionObjectTypeMenu(); } function toggleObjectTypeMenu() { if (objectTypeMenuEl?.hidden) openObjectTypeMenu(); else closeObjectTypeMenu(); } function updateObjectTypePickerUi() { const type = state.objectType || ""; if (objectTypeIconEl) { objectTypeIconEl.innerHTML = objectTypeIconMarkup(type); objectTypeIconEl.className = "mapEditorObjectTypeIcon"; if (type === "wall") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--wall"); if (type === "floor") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--floor"); if (type === "position") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--position"); if (type === "forbidden") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--forbidden"); if (type === "preferred") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--preferred"); if (type === "unpreferred") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--unpreferred"); if (type === "speed") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--speed"); if (type === "sound") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--sound"); if (type === "directional") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--directional"); if (type === "directional_line") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--directionalLine"); if (type === "planner") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--planner"); if (type === "io") objectTypeIconEl.classList.add("mapEditorObjectTypeOptionIcon--io"); } if (objectTypeLabelEl) { objectTypeLabelEl.textContent = t(objectTypeLabelKey(type)); } objectTypeOptionEls().forEach((node) => { const selected = (node.dataset.value || "") === type; node.classList.toggle("is-selected", selected); node.setAttribute("aria-selected", selected ? "true" : "false"); }); } function updateObjectToolsUi() { const canEdit = hasFloorPlan() && !state.readOnly; const hasType = !!state.objectType && canEdit; const obj = Objects(); const isOverlay = obj?.isOverlayObjectType(state.objectType); const type = state.objectType || ""; const canLine = hasType && obj?.supportsDrawLine(type); const canShape = hasType && obj?.supportsDrawShape(type); objectTypeBtnEl?.toggleAttribute("disabled", !canEdit); if (!canEdit) closeObjectTypeMenu(); el("mapEditorDrawLineBtn")?.toggleAttribute("disabled", !canLine); el("mapEditorDrawShapeBtn")?.toggleAttribute("disabled", !canShape); el("mapEditorSelectBtn")?.toggleAttribute("disabled", !hasType); el("mapEditorEraserBtn")?.toggleAttribute("disabled", !hasType || isOverlay); el("mapEditorEraseShapeBtn")?.toggleAttribute("disabled", !hasType); el("mapEditorEraseSelectionBtn")?.toggleAttribute("disabled", !hasType || isOverlay); updateObjectTypePickerUi(); updateDraftUi(); } function setObjectType(type) { const allowed = new Set([ "wall", "floor", "position", "forbidden", "preferred", "unpreferred", "speed", "sound", "directional", "directional_line", "planner", "io", "", ]); state.objectType = allowed.has(type) ? type : ""; cancelDraft(); closeObjectTypeMenu(); updateObjectTypePickerUi(); const obj = Objects(); if (state.objectType === obj?.TYPES.position) setActiveTool("select"); else if (state.objectType) { const defaultTool = obj?.defaultDrawTool(state.objectType) || "drawShape"; if (defaultTool === "drawLine") beginDrawLine(); else setActiveTool(defaultTool); } else setActiveTool("pan"); updateObjectToolsUi(); } function pointInRect(x0, y0, x1, y1, px, py) { const x = Math.min(x0, x1); const y = Math.min(y0, y1); const w = Math.abs(x1 - x0); const h = Math.abs(y1 - y0); return px >= x && px <= x + w && py >= y && py <= y + h; } function zoneIntersectsRect(zone, x0, y0, x1, y1) { const obj = Objects(); if (!obj?.isFloorPlanType(zone?.type) || !zone.points?.length) return false; return zone.points.some((p) => pointInRect(x0, y0, x1, y1, p.x, p.y)); } function removeZonesInRect(x0, y0, x1, y1, withUndo = true) { const obj = Objects(); const toRemove = state.zones.filter( (z) => obj?.isFloorPlanType(z.type) && zoneIntersectsRect(z, x0, y0, x1, y1), ); if (!toRemove.length) return false; if (withUndo) pushUndo(); const ids = new Set(toRemove.map((z) => z.id)); state.zones = state.zones.filter((z) => !ids.has(z.id)); if (state.selectedZoneId && ids.has(state.selectedZoneId)) state.selectedZoneId = null; rebakeComposite(); renderObjects(); setDirty(true); state.rasterDirty = true; return true; } function applyVertexMove(zoneId, pointIndex, pt) { const obj = Objects(); const zone = state.zones.find((z) => z.id === zoneId); if (!zone?.points?.[pointIndex] || !pt) return; zone.points[pointIndex] = { x: pt.x, y: pt.y }; if (obj?.isFloorPlanType(zone.type)) { rebakeComposite(); state.rasterDirty = true; } renderObjects(); setDirty(true); } function beginVertexDrag(evt) { if (state.activeTool !== "select" || !state.objectType || state.readOnly) return false; const pt = imagePointFromEvent(evt); const obj = Objects(); if (!pt || !obj) return false; const hit = obj.hitTestVertex(state.zones, pt.x, pt.y, state.selectedZoneId); if (!hit) return false; evt.preventDefault(); dismissCanvasTip(); pushUndo(); state.selectedZoneId = hit.zoneId; state.vertexDrag = hit; state.pointerMoved = false; updateUndoUi(); renderObjects(); return true; } function continueVertexDrag(evt) { if (!state.vertexDrag) return; const pt = imagePointFromEvent(evt); if (!pt) return; state.pointerMoved = true; applyVertexMove(state.vertexDrag.zoneId, state.vertexDrag.pointIndex, pt); } function endVertexDrag() { if (!state.vertexDrag) return; state.vertexDrag = null; updateUndoUi(); } function beginSelectionDrag(evt) { if (state.activeTool !== "eraseSelection" || !state.objectType || state.readOnly) return false; const obj = Objects(); if (!obj?.isFloorPlanType(state.objectType)) return false; const pt = imagePointFromEvent(evt); if (!pt) return false; evt.preventDefault(); dismissCanvasTip(); state.selectionDrag = { x0: pt.x, y0: pt.y }; state.selectionRect = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y }; state.pointerMoved = false; renderObjects(); return true; } function continueSelectionDrag(evt) { if (!state.selectionDrag) return; const pt = imagePointFromEvent(evt); if (!pt) return; state.pointerMoved = true; state.selectionRect = { x0: state.selectionDrag.x0, y0: state.selectionDrag.y0, x1: pt.x, y1: pt.y, }; renderObjects(); } function endSelectionDrag() { if (!state.selectionDrag || !state.selectionRect) { state.selectionDrag = null; state.selectionRect = null; return; } const { x0, y0, x1, y1 } = state.selectionRect; const w = Math.abs(x1 - x0); const h = Math.abs(y1 - y0); state.selectionDrag = null; state.selectionRect = null; if (w >= 3 && h >= 3 && state.pointerMoved) { pushUndo(); OccEdit()?.eraseRect(getBaseCanvas(), x0, y0, x1, y1, state.objectType); removeZonesInRect(x0, y0, x1, y1, false); rebakeComposite(); state.baseDirty = true; state.rasterDirty = true; setDirty(true); } state.pointerMoved = false; renderObjects(); updateUndoUi(); } function beginPositionDrag(evt) { const obj = Objects(); if ( state.activeTool !== "select" || state.objectType !== obj?.TYPES.position || state.readOnly ) { return false; } const pt = imagePointFromEvent(evt); if (!pt) return false; evt.preventDefault(); dismissCanvasTip(); const yaw = obj.yawFromPoints(pt, pt); state.positionDrag = { px: pt.x, py: pt.y }; state.draft = { kind: "position", px: pt.x, py: pt.y, yaw }; state.pointerMoved = false; updateUndoUi(); renderObjects(); return true; } function continuePositionDrag(evt) { if (!state.positionDrag || state.draft?.kind !== "position") return; const pt = imagePointFromEvent(evt); const obj = Objects(); if (!pt || !obj) return; state.pointerMoved = true; state.draft.yaw = obj.yawFromPoints( { x: state.positionDrag.px, y: state.positionDrag.py }, pt, ); renderObjects(); } function endPositionDrag() { if (!state.positionDrag || state.draft?.kind !== "position") return; const anchor = state.positionDrag; const draft = state.draft; state.positionDrag = null; if (!state.pointerMoved) { state.draft = null; renderObjects(); updateUndoUi(); return; } openPositionDialogFromDraft(anchor.px, anchor.py, draft.yaw); state.draft = null; updateUndoUi(); renderObjects(); } function openPositionDialogFromDraft(px, py, yawRad, zoneId = null) { const geo = Geo(); const { width, height } = floorPlanSize(); const mapMeta = mapMetaForEditor(); const world = geo?.pixelToWorld(mapMeta, width, height, px, py) || { x: 0, y: 0 }; const existing = zoneId ? state.zones.find((z) => z.id === zoneId) : null; state.pendingPosition = { mode: existing ? "edit" : "create", zoneId: existing?.id || null, px, py, yaw: existing ? Number(existing.yaw) : yawRad, }; if (positionFields.name) positionFields.name.value = existing?.name || ""; if (positionFields.x) positionFields.x.value = (existing ? existing.x : world.x).toFixed(3); if (positionFields.y) positionFields.y.value = (existing ? existing.y : world.y).toFixed(3); const storedYaw = existing ? Number(existing.yaw) : yawRad; const yawDeg = (-storedYaw * 180) / Math.PI; if (positionFields.yaw) positionFields.yaw.value = yawDeg.toFixed(1); positionDialogEl?.showModal(); } function commitPositionFromDialog() { const obj = Objects(); if (!obj || !state.pendingPosition) return false; const name = positionFields.name?.value.trim() || ""; const worldX = Number(positionFields.x?.value); const worldY = Number(positionFields.y?.value); const yawDeg = Number(positionFields.yaw?.value); const yaw = (-yawDeg * Math.PI) / 180; if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(yawDeg)) { alert(t("maps.editor.position.invalid")); return false; } pushUndo(); if (state.pendingPosition.mode === "edit" && state.pendingPosition.zoneId) { state.zones = state.zones.map((z) => z.id === state.pendingPosition.zoneId ? { ...z, name, x: worldX, y: worldY, yaw } : z, ); state.selectedZoneId = state.pendingPosition.zoneId; } else { const zone = obj.createPosition(worldX, worldY, yaw, name); if (!zone) return false; state.zones = [...state.zones, zone]; state.selectedZoneId = zone.id; } state.pendingPosition = null; positionDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } async function fetchSoundsList() { try { const res = await fetch("/api/sounds", { credentials: "include" }); if (!res.ok) throw new Error("failed"); const data = await res.json(); state.soundsCache = Array.isArray(data.sounds) ? data.sounds : []; } catch { state.soundsCache = []; } return state.soundsCache; } function fillSoundZoneSelect(selectedId = "") { const select = soundFields.soundId; if (!select) return; select.innerHTML = ""; const empty = document.createElement("option"); empty.value = ""; empty.textContent = t("maps.editor.sound.noSound"); select.appendChild(empty); state.soundsCache.forEach((s) => { const opt = document.createElement("option"); opt.value = s.id; opt.textContent = s.name || s.id; if (s.id === selectedId) opt.selected = true; select.appendChild(opt); }); if (selectedId && !state.soundsCache.some((s) => s.id === selectedId)) { const missing = document.createElement("option"); missing.value = selectedId; missing.textContent = selectedId; missing.selected = true; select.appendChild(missing); } } async function openBehaviorDialog(zoneId, mode = "edit") { const obj = Objects(); const zone = state.zones.find((z) => z.id === zoneId); if (!obj || !zone) return; state.pendingBehavior = { mode, zoneId, type: zone.type }; if (zone.type === obj.TYPES.speed) { if (speedFields.speed) { speedFields.speed.min = String(Behavior()?.SPEED_MIN ?? 0.1); speedFields.speed.max = String(Behavior()?.SPEED_MAX ?? 1.5); speedFields.speed.step = "0.05"; speedFields.speed.value = String(zone.speed_mps ?? Behavior()?.DEFAULT_SPEED_MPS ?? 0.8); } speedDialogEl?.showModal(); return; } if (zone.type === obj.TYPES.sound) { await fetchSoundsList(); fillSoundZoneSelect(zone.sound_id || ""); soundDialogEl?.showModal(); } } function cancelBehaviorDialog() { const pending = state.pendingBehavior; state.pendingBehavior = null; if (pending?.mode === "create" && pending.zoneId) { state.zones = state.zones.filter((z) => z.id !== pending.zoneId); if (state.selectedZoneId === pending.zoneId) state.selectedZoneId = null; renderObjects(); setDirty(true); } speedDialogEl?.close(); soundDialogEl?.close(); } function commitSpeedFromDialog() { const obj = Objects(); if (!obj || !state.pendingBehavior?.zoneId) return false; const speed = obj.clampSpeedMps(Number(speedFields.speed?.value)); if (!Number.isFinite(speed)) { alert(t("maps.editor.speed.invalid")); return false; } const zoneId = state.pendingBehavior.zoneId; if (state.pendingBehavior.mode === "edit") pushUndo(); state.zones = state.zones.map((z) => z.id === zoneId && z.type === obj.TYPES.speed ? { ...z, speed_mps: speed } : z, ); state.selectedZoneId = zoneId; state.pendingBehavior = null; speedDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } function commitSoundFromDialog() { const obj = Objects(); if (!obj || !state.pendingBehavior?.zoneId) return false; const soundId = soundFields.soundId?.value || ""; if (!soundId) { alert(t("maps.editor.sound.invalid")); return false; } const zoneId = state.pendingBehavior.zoneId; if (state.pendingBehavior.mode === "edit") pushUndo(); state.zones = state.zones.map((z) => z.id === zoneId && z.type === obj.TYPES.sound ? { ...z, sound_id: soundId } : z, ); state.selectedZoneId = zoneId; state.pendingBehavior = null; soundDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } function fillDirectionSelect(selected = 0) { const select = directionalFields.direction; if (!select) return; select.innerHTML = ""; (Advanced()?.DIRECTION_DEGREES || [0, 45, 90, 135, 180, 225, 270, 315]).forEach((deg) => { const opt = document.createElement("option"); opt.value = String(deg); opt.textContent = t("maps.editor.directional.degOption", { deg }); if (deg === selected) opt.selected = true; select.appendChild(opt); }); } function openAdvancedDialog(zoneId, mode = "edit") { const obj = Objects(); const zone = state.zones.find((z) => z.id === zoneId); const adv = Advanced(); if (!obj || !zone || !adv) return; state.pendingAdvanced = { mode, zoneId, type: zone.type }; if (zone.type === obj.TYPES.directional) { if (directionalFields.shapePanel) directionalFields.shapePanel.hidden = false; if (directionalFields.linePanel) directionalFields.linePanel.hidden = true; fillDirectionSelect(adv.normalizeDirectionDeg(zone.direction_deg)); directionalDialogEl?.showModal(); return; } if (zone.type === obj.TYPES.directional_line) { if (directionalFields.shapePanel) directionalFields.shapePanel.hidden = true; if (directionalFields.linePanel) directionalFields.linePanel.hidden = false; if (directionalFields.reversed) directionalFields.reversed.checked = !!zone.reversed; if (directionalFields.lineWidth) { directionalFields.lineWidth.value = String( Math.round(obj.zoneLineWidthCm(zone, mapMetaForEditor())), ); } directionalDialogEl?.showModal(); return; } if (zone.type === obj.TYPES.planner) { const s = adv.normalizePlannerSettings(zone); if (plannerFields.noLocalization) plannerFields.noLocalization.checked = s.no_localization; if (plannerFields.lookAhead) plannerFields.lookAhead.checked = s.look_ahead; if (plannerFields.ignoreObstacles) plannerFields.ignoreObstacles.checked = s.ignore_obstacles; if (plannerFields.pathDeviation) plannerFields.pathDeviation.value = String(s.path_deviation); if (plannerFields.pathTimeout) plannerFields.pathTimeout.value = String(s.path_timeout); plannerDialogEl?.showModal(); return; } if (zone.type === obj.TYPES.io) { const s = adv.normalizeIoSettings(zone); if (ioFields.module) ioFields.module.value = s.io_module || ""; if (ioFields.plcRegister) { ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register); } if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value); if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set"; ioDialogEl?.showModal(); } } function cancelAdvancedDialog() { const pending = state.pendingAdvanced; state.pendingAdvanced = null; if (pending?.mode === "create" && pending.zoneId) { state.zones = state.zones.filter((z) => z.id !== pending.zoneId); if (state.selectedZoneId === pending.zoneId) state.selectedZoneId = null; renderObjects(); setDirty(true); } directionalDialogEl?.close(); plannerDialogEl?.close(); ioDialogEl?.close(); } function commitDirectionalFromDialog() { const obj = Objects(); const adv = Advanced(); if (!obj || !adv || !state.pendingAdvanced?.zoneId) return false; const zoneId = state.pendingAdvanced.zoneId; const zone = state.zones.find((z) => z.id === zoneId); if (!zone) return false; if (state.pendingAdvanced.mode === "edit") pushUndo(); if (zone.type === obj.TYPES.directional) { const direction_deg = adv.normalizeDirectionDeg(Number(directionalFields.direction?.value)); state.zones = state.zones.map((z) => z.id === zoneId ? { ...z, direction_deg } : z, ); } else if (zone.type === obj.TYPES.directional_line) { const line_width_cm = Number(directionalFields.lineWidth?.value); const minCm = obj.minLineWidthCm(obj.TYPES.directional_line); if (!Number.isFinite(line_width_cm) || line_width_cm < minCm) { alert(t("maps.editor.directional.lineWidthInvalid")); return false; } state.zones = state.zones.map((z) => z.id === zoneId ? { ...z, reversed: !!directionalFields.reversed?.checked, line_width_cm } : z, ); } state.selectedZoneId = zoneId; state.pendingAdvanced = null; directionalDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } function commitPlannerFromDialog() { const obj = Objects(); const adv = Advanced(); if (!obj || !adv || !state.pendingAdvanced?.zoneId) return false; const zoneId = state.pendingAdvanced.zoneId; if (state.pendingAdvanced.mode === "edit") pushUndo(); const settings = adv.normalizePlannerSettings({ no_localization: !!plannerFields.noLocalization?.checked, look_ahead: !!plannerFields.lookAhead?.checked, ignore_obstacles: !!plannerFields.ignoreObstacles?.checked, path_deviation: Number(plannerFields.pathDeviation?.value), path_timeout: Number(plannerFields.pathTimeout?.value), }); state.zones = state.zones.map((z) => z.id === zoneId && z.type === obj.TYPES.planner ? { ...z, ...settings } : z, ); state.selectedZoneId = zoneId; state.pendingAdvanced = null; plannerDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } function commitIoFromDialog() { const obj = Objects(); const adv = Advanced(); if (!obj || !adv || !state.pendingAdvanced?.zoneId) return false; const io_module = ioFields.module?.value.trim() || ""; if (!io_module) { alert(t("maps.editor.io.moduleRequired")); return false; } const zoneId = state.pendingAdvanced.zoneId; if (state.pendingAdvanced.mode === "edit") pushUndo(); const settings = adv.normalizeIoSettings({ io_module, plc_register: ioFields.plcRegister?.value, plc_value: ioFields.plcValue?.value, plc_mode: ioFields.plcMode?.value, }); state.zones = state.zones.map((z) => z.id === zoneId && z.type === obj.TYPES.io ? { ...z, ...settings } : z, ); state.selectedZoneId = zoneId; state.pendingAdvanced = null; ioDialogEl?.close(); renderObjects(); setDirty(true); updateUndoUi(); return true; } function onCanvasClick(evt) { if (!hasFloorPlan() || state.readOnly || state.pointerMoved) return; const pt = imagePointFromEvent(evt); if (!pt) return; dismissCanvasTip(); const obj = Objects(); const { width, height } = floorPlanSize(); const mapMeta = mapMetaForEditor(); if (!obj) return; if ( isDrawTool(state.activeTool) && (obj.isFloorPlanType(state.objectType) || obj.isPlannerZoneType(state.objectType) || obj.isBehaviorZoneType(state.objectType) || obj.isAdvancedZoneType(state.objectType)) ) { const drawMode = state.activeTool === "drawLine" ? "line" : "shape"; if (!state.draft || state.draft.kind !== "shape" || state.draft.drawMode !== drawMode) { state.draft = { kind: "shape", type: state.objectType, drawMode, line_width_cm: state.lineDrawWidthCm ?? obj.defaultLineWidthCm(state.objectType), points: [], hover: null, }; updateUndoUi(); } let next = { x: pt.x, y: pt.y }; const pts = state.draft.points; if (evt.shiftKey && pts.length) { next = obj.constrainAxis(pts[pts.length - 1], next); } if (obj.isDraftPolyline(state.draft)) { if (pts.length && obj.nearPoint(next, pts[pts.length - 1])) return; pts.push(next); renderObjects(); updateDraftUi(); return; } if (obj.isPolygonType(state.draft.type) && pts.length >= 3 && obj.nearPoint(next, pts[0])) { commitDraft(); return; } if (pts.length && obj.nearPoint(next, pts[pts.length - 1])) return; pts.push(next); renderObjects(); updateDraftUi(); return; } if (state.activeTool === "select") { const hit = obj.hitTestAny(state.zones, pt.x, pt.y, mapMeta, width, height); state.selectedZoneId = hit?.id || null; renderObjects(); return; } if (state.activeTool === "eraseShape") { const hit = obj.hitTestAny(state.zones, pt.x, pt.y, mapMeta, width, height); if (hit) removeZone(hit.id); } } function onCanvasDblClick(evt) { if (!hasFloorPlan() || state.readOnly) return; const obj = Objects(); const { width, height } = floorPlanSize(); const pt = imagePointFromEvent(evt); if (!pt || !obj) return; const hit = obj.hitTestAny( state.zones, pt.x, pt.y, mapMetaForEditor(), width, height, ); if (hit?.type === obj.TYPES.position) { state.selectedZoneId = hit.id; openPositionDialogFromDraft(pt.x, pt.y, hit.yaw, hit.id); renderObjects(); return; } if (obj.isBehaviorZoneType(hit?.type)) { state.selectedZoneId = hit.id; openBehaviorDialog(hit.id, "edit"); renderObjects(); return; } if (obj.isAdvancedZoneType(hit?.type)) { state.selectedZoneId = hit.id; openAdvancedDialog(hit.id, "edit"); renderObjects(); } } function beginEraserStroke(evt) { if (state.activeTool !== "eraser" || !state.objectType || state.readOnly) return false; const pt = imagePointFromEvent(evt); if (!pt) return false; evt.preventDefault(); dismissCanvasTip(); pushUndo(); const edit = OccEdit(); edit?.paintBrush(getBaseCanvas(), pt.x, pt.y, state.objectType); rebakeComposite(); state.eraserStroke = { lastX: pt.x, lastY: pt.y }; state.baseDirty = true; state.rasterDirty = true; setDirty(true); updateUndoUi(); return true; } function continueEraserStroke(evt) { if (!state.eraserStroke || state.activeTool !== "eraser") return; const pt = imagePointFromEvent(evt); const edit = OccEdit(); if (!pt || !edit) return; edit.paintBrushStroke( getBaseCanvas(), state.eraserStroke.lastX, state.eraserStroke.lastY, pt.x, pt.y, state.objectType, ); state.eraserStroke.lastX = pt.x; state.eraserStroke.lastY = pt.y; rebakeComposite(); } function endEraserStroke() { if (!state.eraserStroke) return; state.eraserStroke = null; updateUndoUi(); } /** 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(); renderObjects(); updateObjectToolsUi(); 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 originPixelOnSheet() { const geo = Geo(); const { width, height } = floorPlanSize(); if (!state.showOrigin || !geo || !hasFloorPlan() || !width || !height) return null; return geo.worldToPixel(mapMetaForOriginDisplay(), width, height, 0, 0); } /** Show origin label when pointer is near world (0,0) — without blocking map tools. */ function updateOriginLabelFromPointer(evt) { if (!state.showOrigin || originEl?.hidden) { setOriginLabelVisible(false); return; } const originPt = originPixelOnSheet(); const imgPt = imagePointFromEvent(evt); if (!originPt || !imgPt) { setOriginLabelVisible(false); return; } const dist = Math.hypot(imgPt.x - originPt.x, imgPt.y - originPt.y); setOriginLabelVisible(dist <= 14); } 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(opts = {}) { const shouldFit = opts.fitToView !== false; const url = mapBaseImageUrl(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(); if (shouldFit) fitToView(); }, { once: true }, ); if (url && imageEl?.complete && imageEl.naturalWidth) { paintOccupancyFromImage(); updateImageLayer(); } 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(); updateObjectToolsUi(); updateUndoUi(); } async function reloadMap() { if (!state.mapId) return; state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`); await loadYamlMeta(); syncZonesFromMap(); 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.objectType = ""; state.zones = []; state.draft = null; state.selectedZoneId = null; state.undoStack = []; state.rasterDirty = false; state.baseDirty = false; state.bakedZoneIds = new Set(); state.eraserStroke = null; state.vertexDrag = null; state.selectionDrag = null; state.selectionRect = null; state.positionDrag = null; state.pendingPosition = null; updateObjectTypePickerUi(); 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; state.zones = []; state.draft = null; state.selectedZoneId = null; state.undoStack = []; state.objectType = ""; state.rasterDirty = false; state.baseDirty = false; state.bakedZoneIds = new Set(); state.eraserStroke = null; state.vertexDrag = null; state.selectionDrag = null; state.selectionRect = null; state.positionDrag = null; state.pendingPosition = null; drawLineDialogEl?.close(); menuDialogEl?.close(); settingsDialogEl?.close(); positionDialogEl?.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 uploadMapImageBlob(blob, kind) { if (!state.map || !blob) return null; const pngName = state.map.image_file || "map.png"; const filename = /\.png$/i.test(pngName) ? pngName : `${pngName}.png`; const form = new FormData(); form.append("file", blob, filename); const path = kind === "base" ? `/api/maps/${encodeURIComponent(state.map.id)}/image/base` : `/api/maps/${encodeURIComponent(state.map.id)}/image/composite`; const res = await fetch(path, { 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); } return res.json(); } async function saveMap() { if (!state.map || state.readOnly) return; const payload = readSettingsPayload(); if (!payload.name) { alert(t("maps.error.nameEmpty")); return; } rebakeComposite(); const edit = OccEdit(); if (state.baseDirty && edit) { const baseBlob = await edit.exportPngBlob(getBaseCanvas()); const baseUpdated = await uploadMapImageBlob(baseBlob, "base"); if (baseUpdated) state.map = { ...state.map, ...baseUpdated }; state.baseDirty = false; } if (state.rasterDirty && edit) { const compositeBlob = await edit.exportPngBlob(getSourceCanvas()); const imageUpdated = await uploadMapImageBlob(compositeBlob, "composite"); if (imageUpdated) state.map = { ...state.map, ...imageUpdated }; state.rasterDirty = false; } const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, zones: state.zones }), }); state.map = updated; syncZonesFromMap(); state.callbacks.onMapUpdated?.(updated); setDirty(false); updateHeader(); renderMapImage({ fitToView: false }); 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 }); updateOriginLabelFromPointer(evt); if (state.draft?.kind === "shape" && isDrawTool(state.activeTool)) { const pt = imagePointFromEvent(evt); const obj = Objects(); if (pt && obj) { let hover = { x: pt.x, y: pt.y }; const pts = state.draft.points; if (evt.shiftKey && pts.length) { hover = obj.constrainAxis(pts[pts.length - 1], hover); } state.draft.hover = hover; renderObjects(); } } }); viewportEl?.addEventListener("click", (evt) => { if (state.activeTool !== "pan") onCanvasClick(evt); }); viewportEl?.addEventListener("dblclick", (evt) => { onCanvasDblClick(evt); }); viewportEl?.addEventListener("mouseleave", () => { updateStatusBar(); setOriginLabelVisible(false); }); viewportEl?.addEventListener("mousedown", (evt) => { if (evt.button !== 0) return; state.pointerMoved = false; if (beginEraserStroke(evt)) return; if (beginVertexDrag(evt)) return; if (beginSelectionDrag(evt)) return; if (beginPositionDrag(evt)) return; if (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.eraserStroke) { continueEraserStroke(evt); return; } if (state.vertexDrag) { continueVertexDrag(evt); return; } if (state.selectionDrag) { continueSelectionDrag(evt); return; } if (state.positionDrag) { continuePositionDrag(evt); return; } if (!state.panning) return; state.pointerMoved = true; 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", () => { endEraserStroke(); endVertexDrag(); endSelectionDrag(); endPositionDrag(); if (!state.panning) return; state.panning = null; updateCanvasCursor(); }); window.addEventListener("resize", () => { if (!state.mapId) return; applyViewTransform(); positionObjectTypeMenu(); }); } function bindEvents() { 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("mapEditorDrawLineBtn")?.addEventListener("click", () => beginDrawLine()); el("mapEditorDrawShapeBtn")?.addEventListener("click", () => setActiveTool("drawShape")); el("mapEditorSelectBtn")?.addEventListener("click", () => setActiveTool("select")); el("mapEditorEraserBtn")?.addEventListener("click", () => setActiveTool("eraser")); el("mapEditorEraseShapeBtn")?.addEventListener("click", () => setActiveTool("eraseShape")); el("mapEditorEraseSelectionBtn")?.addEventListener("click", () => setActiveTool("eraseSelection")); el("mapEditorConfirmDrawBtn")?.addEventListener("click", () => { if (!commitDraft()) { alert(t("maps.editor.drawNeedMorePoints")); } }); el("mapEditorUndoBtn")?.addEventListener("click", () => undoZones()); objectTypeBtnEl?.addEventListener("click", (evt) => { evt.preventDefault(); evt.stopPropagation(); toggleObjectTypeMenu(); }); objectTypeMenuEl?.addEventListener("click", (evt) => { evt.stopPropagation(); const option = evt.target?.closest?.(".mapEditorObjectTypeOption"); if (!option) return; setObjectType(option.dataset.value ?? ""); }); document.addEventListener("click", (evt) => { if (evt.target?.closest?.("#mapEditorObjectTypePicker")) return; if (evt.target?.closest?.("#mapEditorObjectTypeMenu")) return; closeObjectTypeMenu(); }); el("mapEditorMappingBar")?.addEventListener("scroll", () => positionObjectTypeMenu(), { passive: true }); el("mapPositionCancelBtn")?.addEventListener("click", () => { state.pendingPosition = null; positionDialogEl?.close(); }); positionDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); state.pendingPosition = null; positionDialogEl?.close(); }); positionFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); if (!commitPositionFromDialog()) return; }); el("mapSpeedCancelBtn")?.addEventListener("click", () => cancelBehaviorDialog()); speedDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelBehaviorDialog(); }); speedFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitSpeedFromDialog(); }); el("mapSoundCancelBtn")?.addEventListener("click", () => cancelBehaviorDialog()); soundDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelBehaviorDialog(); }); soundFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitSoundFromDialog(); }); soundFields.manageLink?.addEventListener("click", (evt) => { evt.preventDefault(); soundDialogEl?.close(); state.pendingBehavior = null; window.LmApp?.setActivePage?.("sounds"); }); el("mapDrawLineCancelBtn")?.addEventListener("click", () => cancelDrawLineDialog()); drawLineDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelDrawLineDialog(); }); drawLineFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitDrawLineDialog(); }); el("mapDirectionalCancelBtn")?.addEventListener("click", () => cancelAdvancedDialog()); directionalDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelAdvancedDialog(); }); directionalFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitDirectionalFromDialog(); }); el("mapPlannerCancelBtn")?.addEventListener("click", () => cancelAdvancedDialog()); plannerDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelAdvancedDialog(); }); plannerFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitPlannerFromDialog(); }); el("mapIoCancelBtn")?.addEventListener("click", () => cancelAdvancedDialog()); ioDialogEl?.addEventListener("cancel", (evt) => { evt.preventDefault(); cancelAdvancedDialog(); }); ioFormEl?.addEventListener("submit", (evt) => { evt.preventDefault(); commitIoFromDialog(); }); 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("keydown", (evt) => { if (!state.mapId || state.readOnly) return; if (evt.key === "Enter" && state.draft?.kind === "shape") { if (commitDraft()) evt.preventDefault(); else alert(t("maps.editor.drawNeedMorePoints")); } if (evt.key === "Escape" && state.draft) { cancelDraft(); evt.preventDefault(); } }); window.addEventListener("lm:locale-change", () => { if (state.tipVisible && tipEl) tipEl.textContent = t("maps.editor.canvasTip"); updateHeader(); updateStatusBar(); updateOriginMarker(); updateObjectTypePickerUi(); }); } bindCanvasPanZoom(); bindEvents(); window.MapEditorApp = { open, close, reloadMap, paintOccupancyGrid, paintOccupancyFromImage, getZones: () => JSON.parse(JSON.stringify(state.zones)), getPlannerZones: () => Planner()?.filterPlannerZones(state.zones) || [], getBehaviorZones: () => Behavior()?.filterBehaviorZones(state.zones) || [], getAdvancedZones: () => Advanced()?.filterAdvancedZones(state.zones) || [], classifyImagePoint: (px, py) => Planner()?.classifyPoint(state.zones, px, py) || null, classifyBehavior: (px, py) => Behavior()?.classifyPoint(state.zones, px, py) || null, classifyAdvanced: (px, py, headingRad) => Advanced()?.classifyPoint(state.zones, px, py, headingRad) || null, getSpeedLimit: (px, py) => Behavior()?.getSpeedLimit(state.zones, px, py) ?? null, getSoundAtPoint: (px, py) => Behavior()?.getSoundAtPoint(state.zones, px, py) ?? null, getDirectionalConstraint: (px, py, headingRad) => Advanced()?.getDirectionalConstraint(state.zones, px, py, headingRad) ?? null, getPlannerSettings: (px, py) => Advanced()?.getPlannerSettings(state.zones, px, py) ?? null, getIoActivation: (px, py) => Advanced()?.getIoActivation(state.zones, px, py) ?? null, pathCost: (points) => Planner()?.pathCost(state.zones, points) ?? Infinity, isPathBlocked: (points) => Planner()?.isPathBlocked(state.zones, points) ?? false, }; })();