(() => { /** * MiR-style map coordinate model (3 layers): * * 1. View space — screen pixels in the viewport; pan + zoom (UI only). * 2. Image space — floor plan pixels (1 px image = 1 px on sheet; 20 px/m at res 0.05). * 3. World space — map coordinates in metres + yaw (robot, positions). * * view --scale+translate--> image --resolution+origin--> world */ function meta(map) { return { resolution: Number(map?.resolution) || 0.05, originX: Number(map?.origin_x) || 0, originY: Number(map?.origin_y) || 0, originYaw: Number(map?.origin_yaw) || 0, width: Number(map?.width) || 0, height: Number(map?.height) || 0, }; } function pixelsPerMeter(map) { const res = meta(map).resolution; return res > 0 ? 1 / res : 20; } function imageSize(map, imageEl) { const w = imageEl?.naturalWidth || meta(map).width || 0; const h = imageEl?.naturalHeight || meta(map).height || 0; return { width: w, height: h }; } /** --- View space (layer 1) --- */ function createView(scale = 1, panX = 0, panY = 0) { return { scale, panX, panY }; } function applyViewTransform(el, view) { if (!el || !view) return; el.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.scale})`; } function fitViewToImage(viewportW, viewportH, imageW, imageH, pad = 48) { if (!imageW || !imageH) { return createView(1, Math.max(40, pad), Math.max(40, pad)); } const scale = Math.min((viewportW - pad) / imageW, (viewportH - pad) / imageH, 4); const s = Math.max(0.1, scale); return { scale: s, panX: (viewportW - imageW * s) / 2, panY: (viewportH - imageH * s) / 2, }; } function centerViewOnImage(viewportW, viewportH, imageW, imageH, view) { const s = view?.scale || 1; return { scale: s, panX: Math.max(40, (viewportW - imageW * s) / 2), panY: Math.max(40, (viewportH - imageH * s) / 2), }; } /** Viewport-local px → image px (inverse of translate+scale). */ function viewportToImage(view, vx, vy) { const s = view.scale || 1; return { x: (vx - view.panX) / s, y: (vy - view.panY) / s, }; } /** Image px → viewport-local px. */ function imageToViewport(view, ix, iy) { return { x: view.panX + ix * view.scale, y: view.panY + iy * view.scale, }; } /** Zoom toward a viewport anchor; keeps image point under cursor fixed. */ function zoomViewAt(view, anchorVx, anchorVy, factor, minScale = 0.1, maxScale = 8) { const img = viewportToImage(view, anchorVx, anchorVy); const nextScale = Math.min(maxScale, Math.max(minScale, view.scale * factor)); return { scale: nextScale, panX: anchorVx - img.x * nextScale, panY: anchorVy - img.y * nextScale, }; } /** --- Image space (layer 2) --- */ function gridSteps(map) { const ppm = pixelsPerMeter(map); return { minor: Math.max(1, Math.round(ppm)), major: Math.max(1, Math.round(ppm * 5)), }; } function clampImagePoint(px, py, imageW, imageH) { if (px < 0 || py < 0 || px > imageW || py > imageH) return null; return { x: px, y: py }; } /** Screen/client coords → image px using the transformed sheet rect. */ function clientToImage(clientX, clientY, sheetRect, imageW, imageH) { if (!sheetRect?.width || !sheetRect?.height || !imageW || !imageH) return null; const px = ((clientX - sheetRect.left) / sheetRect.width) * imageW; const py = ((clientY - sheetRect.top) / sheetRect.height) * imageH; return clampImagePoint(px, py, imageW, imageH); } /** --- World space (layer 3) — ROS map_server convention --- */ function worldToPixel(map, imgW, imgH, wx, wy) { const { resolution, originX, originY } = meta(map); return { x: (wx - originX) / resolution, y: imgH - (wy - originY) / resolution, }; } function pixelToWorld(map, imgW, imgH, px, py) { const { resolution, originX, originY } = meta(map); return { x: originX + px * resolution, y: originY + (imgH - py) * resolution, }; } /** Viewport pointer → world (chains view→image→world). */ function clientToWorld(map, clientX, clientY, sheetRect, imageW, imageH) { const img = clientToImage(clientX, clientY, sheetRect, imageW, imageH); if (!img) return null; return pixelToWorld(map, imageW, imageH, img.x, img.y); } window.MapGeo = { meta, pixelsPerMeter, imageSize, createView, applyViewTransform, fitViewToImage, centerViewOnImage, viewportToImage, imageToViewport, zoomViewAt, gridSteps, clientToImage, clientToWorld, worldToPixel, pixelToWorld, }; })();