840 lines
27 KiB
JavaScript
840 lines
27 KiB
JavaScript
(() => {
|
|
const el = (id) => document.getElementById(id);
|
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
|
const Geo = () => window.MapGeo;
|
|
|
|
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,
|
|
/** 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 originEl = el("mapEditorOrigin");
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (originEl) originEl.hidden = !has;
|
|
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 updateOriginMarker() {
|
|
if (!originEl) return;
|
|
const geo = Geo();
|
|
const { width, height } = floorPlanSize();
|
|
if (!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)`;
|
|
|
|
const labelEl = el("mapEditorOriginLabel");
|
|
if (labelEl) {
|
|
labelEl.textContent = t("maps.editor.originLabelShort", {
|
|
x: ox.toFixed(2),
|
|
y: oy.toFixed(2),
|
|
});
|
|
}
|
|
originEl.title = t("maps.editor.originTooltip", {
|
|
x: ox.toFixed(3),
|
|
y: oy.toFixed(3),
|
|
yaw: ((oyaw * 180) / Math.PI).toFixed(1),
|
|
});
|
|
}
|
|
|
|
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;
|
|
} else {
|
|
if (imageEl) {
|
|
imageEl.hidden = true;
|
|
imageEl.removeAttribute("src");
|
|
}
|
|
if (emptyEl) emptyEl.hidden = false;
|
|
}
|
|
updateMenuActionsUi();
|
|
updateImageLayer();
|
|
imageEl?.addEventListener(
|
|
"load",
|
|
() => {
|
|
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)}`);
|
|
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.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");
|
|
setDirty(false);
|
|
applyReadOnlyUi();
|
|
reloadMap().catch((e) => alert(e.message));
|
|
}
|
|
|
|
function close() {
|
|
state.mapId = null;
|
|
state.map = null;
|
|
state.callbacks = {};
|
|
state.uploadMeta = 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();
|
|
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() {
|
|
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("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 (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 };
|
|
})();
|