2355 lines
82 KiB
JavaScript
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,
|
|
};
|
|
})();
|