Files
App/www/map-editor.js
HiepLM 365a15c32a
Some checks are pending
Test / test (push) Waiting to run
update full objects type
2026-06-20 11:43:48 +02:00

2355 lines
82 KiB
JavaScript

(() => {
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,
/** 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 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)) 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",
state.activeTool === "draw" ||
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 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", "draw", "select", "eraser", "eraseShape", "eraseSelection"];
if (!allowed.includes(tool)) return;
if (tool !== "pan" && !state.objectType) return;
if (tool !== "draw") cancelDraft();
state.eraserStroke = null;
clearTransientDrag();
state.activeTool = tool;
toolBtnEls().forEach((btn) => {
btn.classList.toggle("is-active", btn.dataset.tool === tool);
});
updateCanvasCursor();
renderObjects();
}
function syncZonesFromMap() {
state.zones = Objects()?.parseZones(state.map?.zones) || [];
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 extra = {};
if (state.draft.type === obj.TYPES.speed) {
extra.speed_mps = Behavior()?.DEFAULT_SPEED_MPS ?? 0.8;
}
if (state.draft.type === obj.TYPES.directional) {
extra.direction_deg = 0;
}
if (state.draft.type === obj.TYPES.directional_line) {
extra.line_width = 8;
}
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(state.draft.type, 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.minPoints(shapeDraft.type);
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 `<svg width="18" height="18" viewBox="0 0 18 18"><path d="M3 14L15 4" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>`;
}
if (type === "floor") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
}
if (type === "position") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><circle cx="4.5" cy="9" r="2.2" fill="currentColor"/><path d="M7 9h8M13 9l-2.5-2.2M13 9l-2.5 2.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
if (type === "forbidden") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.22" stroke="currentColor" stroke-width="1.5"/><path d="M5 13L13 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`;
}
if (type === "preferred") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.28" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
}
if (type === "unpreferred") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 2" stroke-linejoin="round"/></svg>`;
}
if (type === "speed") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 11h6M9 8v3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
}
if (type === "sound") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.18" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 10.5v-3h2l2.5-1.5v7L8 11.5H6z" fill="currentColor" fill-opacity="0.5"/></svg>`;
}
if (type === "directional") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.16" stroke="currentColor" stroke-width="1.5"/><path d="M6 9h6M10 9l-2-2M10 9l-2 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
}
if (type === "directional_line") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><path d="M3 14L15 4" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M11 4h4v4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
}
if (type === "planner") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.14" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 2"/><circle cx="9" cy="9" r="2" fill="currentColor"/></svg>`;
}
if (type === "io") {
return `<svg width="18" height="18" viewBox="0 0 18 18"><polygon points="3,14 9,4 15,14" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 10h5M8 8v4M10 8v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;
}
return `<svg width="18" height="18" viewBox="0 0 18 18"><rect x="3" y="3" width="12" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-dasharray="2.5 2"/></svg>`;
}
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);
objectTypeBtnEl?.toggleAttribute("disabled", !canEdit);
if (!canEdit) closeObjectTypeMenu();
el("mapEditorDrawBtn")?.toggleAttribute("disabled", !hasType);
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();
if (state.objectType) setActiveTool("draw");
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 !== "draw" ||
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(zone.line_width ?? 8);
}
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 = Number(directionalFields.lineWidth?.value);
if (!Number.isFinite(line_width) || line_width < 2) {
alert(t("maps.editor.directional.lineWidthInvalid"));
return false;
}
state.zones = state.zones.map((z) =>
z.id === zoneId
? { ...z, reversed: !!directionalFields.reversed?.checked, line_width }
: 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;
if (evt.target?.closest?.(".mapEditorOriginHit")) return;
const pt = imagePointFromEvent(evt);
if (!pt) return;
dismissCanvasTip();
const obj = Objects();
const { width, height } = floorPlanSize();
const mapMeta = mapMetaForEditor();
if (!obj) return;
if (
state.activeTool === "draw" &&
(obj.isFloorPlanType(state.objectType) ||
obj.isPlannerZoneType(state.objectType) ||
obj.isBehaviorZoneType(state.objectType) ||
obj.isAdvancedZoneType(state.objectType))
) {
if (!state.draft || state.draft.kind !== "shape") {
state.draft = { kind: "shape", type: 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.isDirectionalLineType(state.draft.type)) {
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 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;
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 });
if (state.draft?.kind === "shape" && state.activeTool === "draw") {
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();
});
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("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("mapEditorDrawBtn")?.addEventListener("click", () => setActiveTool("draw"));
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("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,
};
})();