Files
App/www/map-editor.js
HiepLM 90e8e9d252
Some checks failed
Test / test (push) Has been cancelled
Xong phần map viewer
2026-06-20 09:18:19 +02:00

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