// XLOC Map Renderer - Canvas-based 2D visualization for robot localization
// Renders occupancy grid maps, robot pose, and LIDAR scans
(function () {
window.robotnet = window.robotnet || {};
if (!window.robotnet.triggerFileDownload) {
window.robotnet.triggerFileDownload = function (url) {
const a = document.createElement("a");
a.href = url;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}
if (!window.robotnet.clickElementById) {
window.robotnet.clickElementById = function (elementId) {
const el = document.getElementById(elementId);
if (el) el.click();
};
}
})();
window.xlocMapRenderer = {
canvas: null,
ctx: null,
gridMap: null,
onlineMapOverlay: null, // Online map overlay for update map mode
mapCacheCanvas: null,
mapCacheCtx: null,
mapCacheReady: false,
globalPathData: null,
showGlobalPath: false,
localPathData: null,
showLocalPath: true,
costMapData: null,
costMapCacheCanvas: null,
costMapCacheCtx: null,
costMapCacheReady: false,
costMapPoseAnchor: null,
costMapOdomAnchor: null,
costMapFixedYaw: null,
costMapMapFromOdomLock: null,
costMapLocalizationActive: false,
costMapLastMatchingScore: -1,
showCostMap: false,
showRobotPose: true,
robotFootprint: null, // Array of {x, y} points defining robot footprint
showRobotFootprint: true,
robotPose: null,
laserScanData: null,
lidarDisplayOptions: {
lidar1: { visible: true, mode: 'minimal' },
lidar2: { visible: true, mode: 'minimal' },
lidar3: { visible: true, mode: 'minimal' }
},
cursorTooltipEl: null,
cursorStyle: "url('data:image/svg+xml;utf8,') 12 12, crosshair",
initialPoseCursor: "url('data:image/svg+xml;utf8,') 12 12, crosshair",
resizeObserver: null,
renderPending: false,
dotNetHelper: null,
// Camera/view parameters
offsetX: 0,
offsetY: 0,
scale: 20, // pixels per meter (zoom level)
// LOCKED values - NEVER change after map is loaded
lockedMapOrigin: null, // {x, y, z} - Map origin is FIXED
lockedOffsetX: null, // View offset X is FIXED after initial pose
lockedOffsetY: null, // View offset Y is FIXED after initial pose
// Drag state
isDragging: false,
dragStartX: 0,
dragStartY: 0,
lastOffsetX: 0,
lastOffsetY: 0,
// Initial pose selection state
initialPose: null,
initialPoseMode: false,
initialPoseDragging: false,
initialPoseDragBase: null,
/**
* Initialize the map renderer with a canvas element
*/
init: function(canvasElement) {
this.canvas = canvasElement;
if (!this.canvas) {
console.error('Canvas element not found');
return;
}
this.ctx = this.canvas.getContext('2d');
if (!this.ctx) {
console.error('Failed to get 2D context');
return;
}
this.cursorTooltipEl = document.getElementById('xloc-map-cursor-tooltip');
this.setupResizeObserver();
// Set canvas size to match container
this.resize();
// Setup mouse event handlers for pan/zoom
this.setupEventHandlers();
// Initial render
this.render();
},
/**
* Setup mouse event handlers for panning
*/
setupEventHandlers: function() {
if (!this.canvas) return;
// Mouse down - start dragging
this.canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // Only handle left mouse button
// CRITICAL: Check initial pose mode FIRST to prevent panning conflict
if (this.initialPoseMode) {
console.log("Initial pose mode: mouse down at", e.offsetX, e.offsetY);
const mapCoords = this.getMapCoordsFromEvent(e);
console.log("Map coordinates:", mapCoords.mapX, mapCoords.mapY);
this.initialPoseDragging = true;
this.initialPoseDragBase = { x: mapCoords.mapX, y: mapCoords.mapY };
// Initialize pose at click position with default yaw (0 = pointing right/+X)
const existingYaw = this.initialPose ? this.initialPose.yaw : 0;
this.initialPose = { x: mapCoords.mapX, y: mapCoords.mapY, yaw: existingYaw };
// Update pose from current mouse position (will set yaw based on drag)
this.updateInitialPoseFromEvent(e);
this.requestRender();
e.preventDefault(); // Prevent default behavior
e.stopPropagation(); // Stop event propagation
return;
}
// Normal panning mode
this.isDragging = true;
this.dragStartX = e.offsetX;
this.dragStartY = e.offsetY;
this.lastOffsetX = this.offsetX;
this.lastOffsetY = this.offsetY;
this.canvas.style.cursor = this.cursorStyle;
});
// Mouse move - pan view
this.canvas.addEventListener('mousemove', (e) => {
this.updateCursorTooltip(e);
// CRITICAL: Check initial pose dragging FIRST to prevent panning conflict
if (this.initialPoseDragging) {
this.updateInitialPoseFromEvent(e);
this.requestRender();
e.preventDefault(); // Prevent default behavior during drag
e.stopPropagation(); // Stop event propagation
return;
}
// Normal panning mode
if (this.isDragging) {
const dx = e.offsetX - this.dragStartX;
const dy = e.offsetY - this.dragStartY;
this.offsetX = this.lastOffsetX + dx;
this.offsetY = this.lastOffsetY + dy;
// CRITICAL: Update locked offsets when user pans (user-initiated action)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
this.lockedOffsetX = this.offsetX;
this.lockedOffsetY = this.offsetY;
}
this.render();
}
});
// Mouse up - stop dragging
this.canvas.addEventListener('mouseup', (e) => {
if (this.initialPoseDragging) {
console.log("Initial pose drag ended");
// Update pose one final time to ensure it's current
this.updateInitialPoseFromEvent(e);
console.log("Final initial pose:", this.initialPose);
this.initialPoseDragging = false;
this.notifyInitialPoseSelected();
e.preventDefault();
e.stopPropagation();
return;
}
this.isDragging = false;
this.canvas.style.cursor = this.cursorStyle;
});
// Mouse leave - stop dragging
this.canvas.addEventListener('mouseleave', () => {
if (this.initialPoseDragging) {
this.initialPoseDragging = false;
this.notifyInitialPoseSelected();
}
this.isDragging = false;
this.canvas.style.cursor = this.cursorStyle;
this.hideCursorTooltip();
});
// Mouse wheel - zoom
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
// Zoom factor
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = this.scale * zoomFactor;
// Clamp zoom level
if (newScale >= 1 && newScale <= 100) {
// Zoom towards mouse position
const mouseX = e.offsetX - this.canvas.width / 2 - this.offsetX;
const mouseY = e.offsetY - this.canvas.height / 2 - this.offsetY;
this.scale = newScale;
// Adjust offset to zoom towards mouse
this.offsetX -= mouseX * (zoomFactor - 1);
this.offsetY -= mouseY * (zoomFactor - 1);
// CRITICAL: Update locked offsets when user zooms (user-initiated action)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
this.lockedOffsetX = this.offsetX;
this.lockedOffsetY = this.offsetY;
}
this.render();
}
});
// Set initial cursor
this.canvas.style.cursor = this.cursorStyle;
},
setupResizeObserver: function() {
if (!this.canvas) return;
const container = this.canvas.parentElement;
if (!container || typeof ResizeObserver === 'undefined') return;
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.resizeObserver = new ResizeObserver(() => {
this.resize();
});
this.resizeObserver.observe(container);
},
updateCursorTooltip: function(event) {
if (!this.canvas || !this.cursorTooltipEl) return;
const { cssX, cssY, mapX, mapY } = this.getMapCoordsFromEvent(event);
this.cursorTooltipEl.textContent = `Map: (${mapX.toFixed(3)}, ${mapY.toFixed(3)})m`;
this.cursorTooltipEl.style.left = `${cssX + 12}px`;
this.cursorTooltipEl.style.top = `${cssY + 12}px`;
this.cursorTooltipEl.style.display = 'block';
},
hideCursorTooltip: function() {
if (!this.cursorTooltipEl) return;
this.cursorTooltipEl.style.display = 'none';
},
getMapCoordsFromEvent: function(event) {
const rect = this.canvas.getBoundingClientRect();
const cssX = event.clientX - rect.left;
const cssY = event.clientY - rect.top;
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const canvasX = cssX * scaleX;
const canvasY = cssY * scaleY;
const { mapFrameX, mapFrameY } = this.getFramePositions();
const mapX = (canvasX - mapFrameX) / this.scale;
const mapY = (mapFrameY - canvasY) / this.scale;
return { cssX, cssY, canvasX, canvasY, mapX, mapY };
},
setDotNetHelper: function(dotNetHelper) {
this.dotNetHelper = dotNetHelper;
},
setInitialPoseMode: function(enabled) {
const wasEnabled = this.initialPoseMode;
this.initialPoseMode = !!enabled;
console.log("Initial pose mode changed:", wasEnabled ? "ON" : "OFF", "→", this.initialPoseMode ? "ON" : "OFF");
if (!this.initialPoseMode) {
// Mode disabled - stop any dragging and reset cursor
this.initialPoseDragging = false;
this.initialPoseDragBase = null;
if (this.canvas) {
this.canvas.style.cursor = this.cursorStyle;
}
console.log("Initial pose mode disabled - cursor reset");
} else {
// Mode enabled - set crosshair cursor (black)
if (this.canvas) {
this.canvas.style.cursor = this.initialPoseCursor;
console.log("Initial pose mode enabled - cursor set to crosshair");
}
// Ensure initial pose is visible when mode is enabled
// If no initial pose exists, create a default one at origin
if (!this.initialPose) {
this.initialPose = { x: 0, y: 0, yaw: 0 };
console.log("Created default initial pose at origin:", this.initialPose);
} else {
console.log("Using existing initial pose:", this.initialPose);
}
}
this.requestRender();
},
setInitialPose: function(pose) {
if (!pose) {
console.warn('setInitialPose called with null/undefined pose');
return;
}
const x = Number(pose.x ?? pose.X ?? 0);
const y = Number(pose.y ?? pose.Y ?? 0);
const yaw = Number(pose.yaw ?? pose.Yaw ?? 0);
this.initialPose = { x, y, yaw };
// CRITICAL: Use locked view offsets if available (prevents map frame from moving)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
// Force use locked offsets - map frame MUST stay fixed
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
console.error('⚠️ CRITICAL ERROR: View offsets changed when setting initial pose!');
console.error(' Locked:', { offsetX: this.lockedOffsetX, offsetY: this.lockedOffsetY });
console.error(' Current:', { offsetX: this.offsetX, offsetY: this.offsetY });
console.error(' → Restoring locked offsets to prevent map frame movement!');
// Restore locked offsets
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
}
// CRITICAL: Verify map origin has NOT changed
if (this.lockedMapOrigin) {
const currentOrigin = this.gridMap?.origin;
if (currentOrigin) {
const dx = Math.abs(currentOrigin.x - this.lockedMapOrigin.x);
const dy = Math.abs(currentOrigin.y - this.lockedMapOrigin.y);
const dz = Math.abs(currentOrigin.z - this.lockedMapOrigin.z);
if (dx > 0.001 || dy > 0.001 || dz > 0.001) {
console.error('⚠️ CRITICAL ERROR: Map origin changed when setting initial pose!');
console.error(' Locked:', this.lockedMapOrigin);
console.error(' Current:', currentOrigin);
console.error(' → Forcing use of locked origin!');
// Force use locked origin
if (this.gridMap) {
this.gridMap.origin = {
x: this.lockedMapOrigin.x,
y: this.lockedMapOrigin.y,
z: this.lockedMapOrigin.z
};
}
}
}
}
// IMPORTANT: Only update initial pose visualization, DO NOT reload map!
// Map origin and view offsets are locked and will not change.
this.requestRender();
},
updateInitialPoseFromEvent: function(event) {
if (!this.initialPoseDragging || !this.initialPoseDragBase) return;
const mapCoords = this.getMapCoordsFromEvent(event);
const dx = mapCoords.mapX - this.initialPoseDragBase.x;
const dy = mapCoords.mapY - this.initialPoseDragBase.y;
const distance = Math.hypot(dx, dy);
// Calculate yaw from drag direction: atan2(dy, dx) gives angle from +X axis
// If distance is too small, keep previous yaw or default to 0
const yaw = distance > 0.01 ? Math.atan2(dy, dx) : (this.initialPose ? this.initialPose.yaw : 0);
// Position stays at click point, yaw follows drag direction
this.initialPose = { x: this.initialPoseDragBase.x, y: this.initialPoseDragBase.y, yaw: yaw };
},
notifyInitialPoseSelected: function() {
if (!this.dotNetHelper || !this.initialPose) {
console.warn("Cannot notify initial pose: dotNetHelper or initialPose is null");
return;
}
console.log("Notifying initial pose selected:", {
x: this.initialPose.x,
y: this.initialPose.y,
yaw: this.initialPose.yaw,
yawDeg: this.initialPose.yaw * 180 / Math.PI
});
this.dotNetHelper.invokeMethodAsync("OnInitialPoseSelected", {
x: this.initialPose.x,
y: this.initialPose.y,
yaw: this.initialPose.yaw
}).catch((error) => {
console.error("Failed to notify initial pose selection:", error);
});
},
/**
* Get map origin x,y in meters (supports camelCase and PascalCase from C# serialization).
* Map (0,0) = World (0,0) + (originX, originY). When negative, Map is bottom-left of World.
*
* CRITICAL: Always use LOCKED origin if available to prevent map frame from moving.
*/
getMapOriginXY: function() {
// Always use locked origin if available (prevents map frame from moving)
if (this.lockedMapOrigin) {
return {
x: this.lockedMapOrigin.x,
y: this.lockedMapOrigin.y,
z: this.lockedMapOrigin.z
};
}
// Fallback to gridMap origin if locked origin not available yet
if (!this.gridMap) return { x: 0, y: 0 };
const o = this.gridMap.origin != null ? this.gridMap.origin : this.gridMap.Origin;
if (o == null) return { x: 0, y: 0 };
const x = typeof o.x === 'number' ? o.x : (typeof o.X === 'number' ? o.X : (Number(o.x) || Number(o.X) || 0));
const y = typeof o.y === 'number' ? o.y : (typeof o.Y === 'number' ? o.Y : (Number(o.y) || Number(o.Y) || 0));
return { x: x, y: y };
},
/**
* Map frame = canvas center (single origin). Robot and occupancy are computed in Map frame.
*
* Map frame position = canvas center + view offsets (for panning/zooming)
* Map origin (in world coordinates) is LOCKED and NEVER changes.
*
* CRITICAL: Always use LOCKED view offsets if available to prevent map frame from moving!
*/
getFramePositions: function() {
// CRITICAL: Use locked view offsets if available (prevents map frame from moving)
let offsetX = this.offsetX;
let offsetY = this.offsetY;
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
// Force use locked offsets - map frame MUST stay fixed
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
console.error('⚠️ CRITICAL ERROR: View offsets changed! Restoring locked offsets in getFramePositions().');
console.error(' Locked:', { offsetX: this.lockedOffsetX, offsetY: this.lockedOffsetY });
console.error(' Current:', { offsetX: this.offsetX, offsetY: this.offsetY });
// Restore locked offsets
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
// Always use locked offsets for frame position calculation
offsetX = this.lockedOffsetX;
offsetY = this.lockedOffsetY;
}
const mapFrameX = this.canvas.width / 2 + offsetX;
const mapFrameY = this.canvas.height / 2 + offsetY;
return { mapFrameX, mapFrameY };
},
/**
* Set the occupancy grid map data (object - may have origin serialization issues)
*/
setGridMap: function(gridMapData) {
if (!gridMapData) return;
var o = gridMapData.origin != null ? gridMapData.origin : gridMapData.Origin;
if (gridMapData.dataBase64 && o == null) {
console.warn('xlocMapRenderer: setGridMap received data but no origin - map will render at World frame');
}
this.gridMap = gridMapData;
this.buildMapCache();
this.requestRender();
},
/**
* Set grid map with explicit origin (avoids C#->JS object serialization issues).
* Call this from C# with numeric originX, originY so map renders at Map frame.
*
* CRITICAL: Map origin is LOCKED on first call and NEVER changes!
* This ensures that when robot pose changes, the map frame itself stays fixed.
*/
setGridMapWithOrigin: function(width, height, resolution, originX, originY, originZ, dataBase64, isMapping) {
if (width == null || height == null || !dataBase64) {
console.warn('[setGridMapWithOrigin] Invalid parameters', { width, height, hasData: !!dataBase64, dataLength: dataBase64 ? dataBase64.length : 0 });
return;
}
// Only log when map changes significantly
const isNewMap = this.lockedMapOrigin === null ||
this.gridMap === null ||
this.gridMap.width !== width ||
this.gridMap.height !== height;
if (isNewMap) {
console.log(`[setGridMapWithOrigin] Setting map - Width: ${width}, Height: ${height}, Resolution: ${resolution}, Origin: (${originX}, ${originY}, ${originZ}), Data length: ${dataBase64.length}`);
}
const newOrigin = {
x: Number(originX),
y: Number(originY),
z: Number(originZ)
};
// When mapping, allow origin to update from online map to ensure alignment with LIDAR
// When not mapping, lock origin to prevent map frame movement
const isMappingMode = isMapping === true; // Check if currently mapping
if (this.lockedMapOrigin === null) {
// First time: lock origin
this.lockedMapOrigin = newOrigin;
console.log(`[setGridMapWithOrigin] Locked map origin at (${newOrigin.x}, ${newOrigin.y}, ${newOrigin.z})`);
// CRITICAL: Also lock view offsets when map is first loaded
// This ensures map frame position stays fixed
if (this.lockedOffsetX === null && this.lockedOffsetY === null) {
this.lockedOffsetX = this.offsetX;
this.lockedOffsetY = this.offsetY;
}
} else {
// Check if origin changed
const dx = Math.abs(newOrigin.x - this.lockedMapOrigin.x);
const dy = Math.abs(newOrigin.y - this.lockedMapOrigin.y);
const dz = Math.abs(newOrigin.z - this.lockedMapOrigin.z);
if (dx > 0.001 || dy > 0.001 || dz > 0.001) {
// Origin changed - during mapping, use new origin to align with LIDAR
// During localization, keep locked origin to prevent map frame movement
if (isMappingMode) {
console.log(`[setGridMapWithOrigin] Origin updated during mapping: (${newOrigin.x.toFixed(3)}, ${newOrigin.y.toFixed(3)}, ${newOrigin.z.toFixed(3)})`);
console.log(`[setGridMapWithOrigin] Previous: (${this.lockedMapOrigin.x.toFixed(3)}, ${this.lockedMapOrigin.y.toFixed(3)}, ${this.lockedMapOrigin.z.toFixed(3)})`);
// Update locked origin to match online map (ensures alignment with LIDAR)
this.lockedMapOrigin = newOrigin;
} else {
console.warn('[setGridMapWithOrigin] Origin changed but not mapping - keeping locked origin');
// Use locked origin
originX = this.lockedMapOrigin.x;
originY = this.lockedMapOrigin.y;
originZ = this.lockedMapOrigin.z;
}
}
}
// Use current origin (may be updated during mapping)
this.gridMap = {
width: width,
height: height,
resolution: resolution,
origin: { x: this.lockedMapOrigin.x, y: this.lockedMapOrigin.y, z: this.lockedMapOrigin.z },
dataBase64: dataBase64
};
if (isNewMap) {
console.log('[setGridMapWithOrigin] Building map cache...');
}
this.buildMapCache();
if (isNewMap) {
console.log('[setGridMapWithOrigin] Requesting render...');
}
this.requestRender();
if (isNewMap) {
console.log('[setGridMapWithOrigin] Map set and render requested');
}
},
setGridMapWithMerge: function(width, height, resolution, originX, originY, originZ, dataBase64) {
if (width == null || height == null || !dataBase64) {
console.warn('[setGridMapWithMerge] Invalid parameters', { width, height, hasData: !!dataBase64 });
return;
}
// If no existing map, just set it normally
if (!this.gridMap || !this.gridMap.dataBase64) {
console.log('[setGridMapWithMerge] No existing map, setting new map');
this.setGridMapWithOrigin(width, height, resolution, originX, originY, originZ, dataBase64, false);
return;
}
const newOrigin = {
x: Number(originX),
y: Number(originY),
z: Number(originZ)
};
// Use locked origin (don't allow origin to change during update map)
const originToUse = this.lockedMapOrigin || newOrigin;
// Decode existing map data
const existingBytes = Uint8Array.from(atob(this.gridMap.dataBase64), c => c.charCodeAt(0));
const existingWidth = this.gridMap.width;
const existingHeight = this.gridMap.height;
const existingResolution = this.gridMap.resolution;
// Decode new online map data
const newBytes = Uint8Array.from(atob(dataBase64), c => c.charCodeAt(0));
// Calculate bounds for merged map
// Both maps should use the same origin, so we need to calculate pixel offsets
const resolutionToUse = resolution;
const existingOrigin = this.gridMap.origin || originToUse;
const existingOffsetX = (existingOrigin.x - originToUse.x) / resolutionToUse;
const existingOffsetY = (existingOrigin.y - originToUse.y) / resolutionToUse;
const newOffsetX = 0; // New map uses the same origin
const newOffsetY = 0;
// Calculate bounds
const existingMinX = Math.round(existingOffsetX);
const existingMinY = Math.round(existingOffsetY);
const existingMaxX = existingMinX + existingWidth;
const existingMaxY = existingMinY + existingHeight;
const newMinX = Math.round(newOffsetX);
const newMinY = Math.round(newOffsetY);
const newMaxX = newMinX + width;
const newMaxY = newMinY + height;
const mergedMinX = Math.min(existingMinX, newMinX);
const mergedMinY = Math.min(existingMinY, newMinY);
const mergedMaxX = Math.max(existingMaxX, newMaxX);
const mergedMaxY = Math.max(existingMaxY, newMaxY);
const mergedWidth = mergedMaxX - mergedMinX;
const mergedHeight = mergedMaxY - mergedMinY;
// Create merged map data
const mergedBytes = new Uint8Array(mergedWidth * mergedHeight);
// Initialize with unknown (255)
mergedBytes.fill(255);
// Copy existing map data
for (let y = 0; y < existingHeight; y++) {
for (let x = 0; x < existingWidth; x++) {
const existingIdx = y * existingWidth + x;
const mergedX = x + existingMinX - mergedMinX;
const mergedY = y + existingMinY - mergedMinY;
if (mergedX >= 0 && mergedX < mergedWidth && mergedY >= 0 && mergedY < mergedHeight) {
const mergedIdx = mergedY * mergedWidth + mergedX;
const existingValue = existingBytes[existingIdx];
// Copy existing value (including unknown = 255)
mergedBytes[mergedIdx] = existingValue;
}
}
}
// Merge new online map data (overwrite unknown areas and add new data)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const newIdx = y * width + x;
const mergedX = x + newMinX - mergedMinX;
const mergedY = y + newMinY - mergedMinY;
if (mergedX >= 0 && mergedX < mergedWidth && mergedY >= 0 && mergedY < mergedHeight) {
const mergedIdx = mergedY * mergedWidth + mergedX;
const newValue = newBytes[newIdx];
// Merge: use new value if it's not unknown, otherwise keep existing
if (newValue !== 255) {
mergedBytes[mergedIdx] = newValue;
}
}
}
}
// Convert merged bytes to base64
let mergedBase64 = '';
for (let i = 0; i < mergedBytes.length; i++) {
mergedBase64 += String.fromCharCode(mergedBytes[i]);
}
mergedBase64 = btoa(mergedBase64);
// Update grid map with merged data
this.gridMap = {
width: mergedWidth,
height: mergedHeight,
resolution: resolution, // Use new map resolution (should be same)
origin: originToUse,
dataBase64: mergedBase64
};
console.log(`[setGridMapWithMerge] Merged map - Width: ${mergedWidth}, Height: ${mergedHeight}, Resolution: ${resolution}`);
this.buildMapCache();
this.requestRender();
},
/**
* Set grid map with overlay: static map as base, online map as overlay (for update map mode)
*/
setGridMapWithOverlay: function(staticWidth, staticHeight, staticResolution, originX, originY, originZ, staticDataBase64, onlineWidth, onlineHeight, onlineResolution, onlineDataBase64) {
if (staticWidth == null || staticHeight == null || !staticDataBase64 || onlineWidth == null || onlineHeight == null || !onlineDataBase64) {
console.warn('[setGridMapWithOverlay] Invalid parameters');
return;
}
const newOrigin = {
x: Number(originX),
y: Number(originY),
z: Number(originZ)
};
// Lock origin if not already locked
if (this.lockedMapOrigin === null) {
this.lockedMapOrigin = newOrigin;
console.log(`[setGridMapWithOverlay] Locked map origin at (${newOrigin.x}, ${newOrigin.y}, ${newOrigin.z})`);
if (this.lockedOffsetX === null && this.lockedOffsetY === null) {
this.lockedOffsetX = this.offsetX;
this.lockedOffsetY = this.offsetY;
}
}
// Decode static map data
const staticBytes = Uint8Array.from(atob(staticDataBase64), c => c.charCodeAt(0));
// Decode online map data
const onlineBytes = Uint8Array.from(atob(onlineDataBase64), c => c.charCodeAt(0));
// Create static map object
const staticMap = {
width: staticWidth,
height: staticHeight,
resolution: staticResolution,
origin: newOrigin,
data: staticBytes
};
// Create online map object (overlay)
const onlineMap = {
width: onlineWidth,
height: onlineHeight,
resolution: onlineResolution,
origin: newOrigin,
data: onlineBytes
};
// Store both maps
this.gridMap = staticMap;
this.onlineMapOverlay = onlineMap; // Store online map as overlay
// Build map cache with overlay
this.buildMapCacheWithOverlay();
this.requestRender();
},
/**
* Build map cache with overlay: static map as base, online map overlaid on top
*/
buildMapCacheWithOverlay: function() {
if (!this.mapCacheCanvas || !this.gridMap) return;
try {
const staticMap = this.gridMap;
const onlineMap = this.onlineMapOverlay;
if (!onlineMap) {
// Fallback to regular map cache if no overlay
this.buildMapCache();
return;
}
// Use the larger dimensions
const maxWidth = Math.max(staticMap.width, onlineMap.width);
const maxHeight = Math.max(staticMap.height, onlineMap.height);
this.mapCacheCanvas.width = maxWidth;
this.mapCacheCanvas.height = maxHeight;
const imageData = this.mapCacheCtx.createImageData(maxWidth, maxHeight);
const pixels = imageData.data;
// First, render static map as base
for (let y = 0; y < staticMap.height; y++) {
for (let x = 0; x < staticMap.width; x++) {
const idx = y * staticMap.width + x;
const byte = staticMap.data[idx];
const pixelIdx = (y * maxWidth + x) * 4;
// Convert occupancy value to color
if (byte === 0) {
// Free space - white
pixels[pixelIdx] = 255;
pixels[pixelIdx + 1] = 255;
pixels[pixelIdx + 2] = 255;
pixels[pixelIdx + 3] = 255;
} else if (byte === 100) {
// Occupied - black
pixels[pixelIdx] = 0;
pixels[pixelIdx + 1] = 0;
pixels[pixelIdx + 2] = 0;
pixels[pixelIdx + 3] = 255;
} else {
// Unknown - gray
pixels[pixelIdx] = 128;
pixels[pixelIdx + 1] = 128;
pixels[pixelIdx + 2] = 128;
pixels[pixelIdx + 3] = 255;
}
}
}
// Then, overlay online map on top (only where online map has data)
for (let y = 0; y < onlineMap.height; y++) {
for (let x = 0; x < onlineMap.width; x++) {
const idx = y * onlineMap.width + x;
const byte = onlineMap.data[idx];
// Only overlay if online map has valid data (not unknown)
if (byte !== 205) { // 205 = unknown in occupancy grid
const pixelIdx = (y * maxWidth + x) * 4;
// Overlay online map data (with slight transparency to show both)
if (byte === 0) {
// Free space - light blue tint
pixels[pixelIdx] = Math.min(255, pixels[pixelIdx] * 0.7 + 200 * 0.3);
pixels[pixelIdx + 1] = Math.min(255, pixels[pixelIdx + 1] * 0.7 + 220 * 0.3);
pixels[pixelIdx + 2] = Math.min(255, pixels[pixelIdx + 2] * 0.7 + 255 * 0.3);
} else if (byte === 100) {
// Occupied - red tint
pixels[pixelIdx] = Math.min(255, pixels[pixelIdx] * 0.5 + 255 * 0.5);
pixels[pixelIdx + 1] = Math.min(255, pixels[pixelIdx + 1] * 0.5 + 0 * 0.5);
pixels[pixelIdx + 2] = Math.min(255, pixels[pixelIdx + 2] * 0.5 + 0 * 0.5);
}
}
}
}
this.mapCacheCtx.putImageData(imageData, 0, 0);
this.mapCacheReady = true;
console.log('[buildMapCacheWithOverlay] Map cache with overlay built successfully');
} catch (error) {
console.error('[buildMapCacheWithOverlay] Error building map cache with overlay:', error);
this.mapCacheReady = false;
}
},
/**
* Set the robot pose
* CRITICAL: This should NEVER change view offsets or map origin!
* Only robot pose visualization changes - map frame stays FIXED!
*/
setRobotPose: function(poseData) {
// CRITICAL: Use locked view offsets if available (prevents map frame from moving)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
// Force use locked offsets - map frame MUST stay fixed
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
console.error('⚠️ CRITICAL ERROR: View offsets changed! Restoring locked offsets.');
console.error(' Locked:', { offsetX: this.lockedOffsetX, offsetY: this.lockedOffsetY });
console.error(' Current:', { offsetX: this.offsetX, offsetY: this.offsetY });
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
} else {
// Even if they match, force use locked offsets to be absolutely sure
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
}
// Update robot pose (ONLY this changes - map frame stays fixed)
this.robotPose = poseData;
// CRITICAL: Verify map origin has NOT changed
if (this.lockedMapOrigin && this.gridMap?.origin) {
const dx = Math.abs(this.gridMap.origin.x - this.lockedMapOrigin.x);
const dy = Math.abs(this.gridMap.origin.y - this.lockedMapOrigin.y);
const dz = Math.abs(this.gridMap.origin.z - this.lockedMapOrigin.z);
if (dx > 0.001 || dy > 0.001 || dz > 0.001) {
console.error('⚠️ CRITICAL ERROR: Map origin changed when setting robot pose!');
console.error(' Locked:', this.lockedMapOrigin);
console.error(' Current:', this.gridMap.origin);
console.error(' → Forcing use of locked origin!');
// Force use locked origin
this.gridMap.origin = {
x: this.lockedMapOrigin.x,
y: this.lockedMapOrigin.y,
z: this.lockedMapOrigin.z
};
}
}
this.requestRender();
},
/**
* Set laser scan data for all LIDARs
*/
setLaserScanData: function(allLidarData) {
this.laserScanData = allLidarData;
this.requestRender();
},
/**
* Batch update pose + laser in one call to reduce round-trips and render once (smoother updates).
*/
updatePoseAndLaser: function(poseData, allLidarData) {
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
}
if (poseData) this.robotPose = poseData;
if (allLidarData) this.laserScanData = allLidarData;
if (this.lockedMapOrigin && this.gridMap && this.gridMap.origin) {
const dx = Math.abs(this.gridMap.origin.x - this.lockedMapOrigin.x);
const dy = Math.abs(this.gridMap.origin.y - this.lockedMapOrigin.y);
const dz = Math.abs(this.gridMap.origin.z - this.lockedMapOrigin.z);
if (dx > 0.001 || dy > 0.001 || dz > 0.001) {
this.gridMap.origin = { x: this.lockedMapOrigin.x, y: this.lockedMapOrigin.y, z: this.lockedMapOrigin.z };
}
}
this.requestRender();
},
setLidarDisplayOptions: function(options) {
if (!options || typeof options !== 'object') {
return;
}
const normalizeMode = (mode) => (mode === 'full' ? 'full' : 'minimal');
const normalizeOne = (source, fallback) => ({
visible: source && typeof source.visible === 'boolean' ? source.visible : fallback.visible,
mode: normalizeMode(source && source.mode)
});
this.lidarDisplayOptions = {
lidar1: normalizeOne(options.lidar1, this.lidarDisplayOptions.lidar1),
lidar2: normalizeOne(options.lidar2, this.lidarDisplayOptions.lidar2),
lidar3: normalizeOne(options.lidar3, this.lidarDisplayOptions.lidar3)
};
this.requestRender();
},
setGlobalPathVisible: function(visible) {
this.showGlobalPath = !!visible;
console.log(`🔄 Global Path visibility: ${this.showGlobalPath ? 'ENABLED' : 'DISABLED'}`);
this.requestRender();
},
/**
* Explicitly override the locked map origin (used when the user changes
* the map origin via the Change Map Origin UI). This bypasses the
* "locked origin" guard so that the next map reload can apply the new origin.
*/
setMapOrigin: function(originX, originY, originZ) {
const newOrigin = {
x: Number(originX),
y: Number(originY),
z: Number(originZ)
};
if (!Number.isFinite(newOrigin.x) || !Number.isFinite(newOrigin.y) || !Number.isFinite(newOrigin.z)) {
console.warn('[setMapOrigin] Invalid origin values, ignored', newOrigin);
return;
}
console.log(`[setMapOrigin] Updating locked origin -> (${newOrigin.x.toFixed(3)}, ${newOrigin.y.toFixed(3)}, ${newOrigin.z.toFixed(3)})`);
this.lockedMapOrigin = newOrigin;
if (this.gridMap) {
this.gridMap.origin = { x: newOrigin.x, y: newOrigin.y, z: newOrigin.z };
}
this.requestRender();
},
/**
* Clear the locked origin so the next setGridMapWithOrigin call re-locks it.
*/
clearMapOriginLock: function() {
console.log('[clearMapOriginLock] Locked origin cleared; next map load will relock.');
this.lockedMapOrigin = null;
},
setLocalPathVisible: function(visible) {
this.showLocalPath = !!visible;
console.log(`🔄 Local Path visibility: ${this.showLocalPath ? 'ENABLED' : 'DISABLED'}`);
this.requestRender();
},
setGlobalPathDataJson: function(jsonString) {
console.log('🔍 setGlobalPathDataJson called with JSON string of length:', jsonString ? jsonString.length : 'null');
if (!jsonString) {
console.warn('⚠️ Global Path: jsonString is null/undefined');
this.globalPathData = [];
this.requestRender();
return;
}
try {
const pathPoints = JSON.parse(jsonString);
console.log('✅ JSON parsed successfully:', pathPoints);
this.setGlobalPathData(pathPoints);
} catch (error) {
console.error('❌ Error parsing JSON:', error);
console.error(' Raw string:', jsonString);
this.globalPathData = [];
this.requestRender();
}
},
setLocalPathDataJson: function(jsonString) {
if (!jsonString) {
this.localPathData = [];
this.requestRender();
return;
}
try {
const pathPoints = JSON.parse(jsonString);
this.setLocalPathData(pathPoints);
} catch (error) {
console.error('❌ Local Path: Error parsing JSON:', error);
this.localPathData = [];
this.requestRender();
}
},
setGlobalPathData: function(pathPoints) {
console.log('\ud83d\udd0d setGlobalPathData called with:', pathPoints);
console.log(' Type:', typeof pathPoints);
console.log(' Is Array:', Array.isArray(pathPoints));
console.log(' Length:', pathPoints ? pathPoints.length : 'N/A');
if (!pathPoints) {
console.warn('\u274c Global Path: pathPoints is null/undefined');
this.globalPathData = [];
this.requestRender();
return;
}
if (!Array.isArray(pathPoints)) {
console.warn('\u274c Global Path: pathPoints is not an array, type:', typeof pathPoints);
console.warn(' Value:', pathPoints);
this.globalPathData = [];
this.requestRender();
return;
}
if (pathPoints.length === 0) {
console.warn('\u26a0\ufe0f Global Path: Received empty array (0 points)');
this.globalPathData = [];
this.requestRender();
return;
}
console.log(`\ud83d\udd0d Raw data sample (first 3 points):`);
for (let i = 0; i < Math.min(3, pathPoints.length); i++) {
console.log(` Point ${i}:`, pathPoints[i]);
}
const mappedPoints = pathPoints.map((point, idx) => {
const mapped = {
x: Number(point?.x ?? point?.X ?? 0),
y: Number(point?.y ?? point?.Y ?? 0),
theta: Number(point?.theta ?? point?.Theta ?? 0)
};
if (idx < 3) {
console.log(` Mapped ${idx}:`, mapped, '(finite:', Number.isFinite(mapped.x) && Number.isFinite(mapped.y), ')');
}
return mapped;
});
this.globalPathData = mappedPoints.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y));
console.log(`\u2705 Global Path: Processed ${this.globalPathData.length}/${pathPoints.length} valid points`);
if (this.globalPathData.length > 0) {
const first = this.globalPathData[0];
const last = this.globalPathData[this.globalPathData.length - 1];
console.log(` First point: (${first.x.toFixed(2)}, ${first.y.toFixed(2)})`);
console.log(` Last point: (${last.x.toFixed(2)}, ${last.y.toFixed(2)})`);
} else {
console.warn('\u26a0\ufe0f All points were filtered out! Check if coordinates are valid.');
}
this.requestRender();
},
setLocalPathData: function(pathPoints) {
if (!Array.isArray(pathPoints) || pathPoints.length === 0) {
this.localPathData = [];
this.requestRender();
return;
}
const mappedPoints = pathPoints.map((point) => ({
x: Number(point?.x ?? point?.X ?? 0),
y: Number(point?.y ?? point?.Y ?? 0),
theta: Number(point?.theta ?? point?.Theta ?? 0)
}));
this.localPathData = mappedPoints.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y));
this.requestRender();
},
/**
* Debug helper: Get current path rendering status
*/
getPathDebugInfo: function() {
const info = {
showGlobalPath: this.showGlobalPath,
pathDataExists: !!this.globalPathData,
pathPointCount: this.globalPathData ? this.globalPathData.length : 0,
scale: this.scale,
offsetX: this.offsetX,
offsetY: this.offsetY,
canvasSize: this.canvas ? `${this.canvas.width}x${this.canvas.height}` : 'no canvas'
};
if (this.globalPathData && this.globalPathData.length > 0) {
info.firstPoint = this.globalPathData[0];
info.lastPoint = this.globalPathData[this.globalPathData.length - 1];
}
console.log('🔍 Path Debug Info:', info);
return info;
},
/**
* Debug helper: Force path render with test data
*/
testPathRender: function() {
console.log('🧪 Testing path render with sample data...');
// Use current robot position as starting point if available
const startX = this.robotPose ? this.robotPose.x : 0;
const startY = this.robotPose ? this.robotPose.y : 0;
// Create a simple test path (square pattern)
const testPath = [
{ x: startX, y: startY, theta: 0 },
{ x: startX + 1, y: startY, theta: 0 },
{ x: startX + 1, y: startY + 1, theta: 1.57 },
{ x: startX, y: startY + 1, theta: 3.14 },
{ x: startX, y: startY, theta: -1.57 }
];
this.showGlobalPath = true;
this.setGlobalPathData(testPath);
console.log('✅ Test path set. Should see green square path on canvas.');
},
/**
* Resize canvas to match container
*/
resize: function() {
if (!this.canvas) return;
const container = this.canvas.parentElement;
if (container) {
this.canvas.width = container.clientWidth;
this.canvas.height = container.clientHeight;
this.requestRender();
}
},
requestRender: function() {
if (this.renderPending) return;
this.renderPending = true;
requestAnimationFrame(() => {
this.renderPending = false;
this.render();
});
},
/**
* Force immediate render (for debugging)
*/
forceRender: function() {
this.renderPending = false;
this.render();
},
buildMapCache: function() {
if (!this.gridMap || !this.gridMap.dataBase64) {
console.warn('[buildMapCache] No grid map data or dataBase64 missing');
this.mapCacheReady = false;
return;
}
try {
// Decode base64 data once and cache as a canvas
const binaryString = atob(this.gridMap.dataBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const width = this.gridMap.width;
const height = this.gridMap.height;
// Only log first time or when significant changes
const shouldLog = !this.mapCacheReady || (width !== this.mapCacheCanvas.width || height !== this.mapCacheCanvas.height);
if (shouldLog) {
console.log(`[buildMapCache] Building map cache - Width: ${width}, Height: ${height}, Data length: ${bytes.length}, Resolution: ${this.gridMap.resolution}`);
}
if (!this.mapCacheCanvas) {
this.mapCacheCanvas = document.createElement('canvas');
this.mapCacheCtx = this.mapCacheCanvas.getContext('2d');
}
this.mapCacheCanvas.width = width;
this.mapCacheCanvas.height = height;
const imageData = this.mapCacheCtx.createImageData(width, height);
// Count occupancy values for debugging (only when logging)
let occupiedCount = 0;
let freeCount = 0;
let unknownCount = 0;
// Optimize: process in batches for better performance
for (let i = 0; i < bytes.length; i++) {
// Occupancy values: int8_t from C API
// -1 (0xFF as unsigned) = Unknown
// 0 = Free
// 1-99 = Probability of occupancy (higher = more likely occupied)
// 100 = Occupied
// When read as unsigned byte: -1 becomes 255
const occupancy = bytes[i];
let r, g, b, a = 0;
// Render occupied cells: 100 (definitely occupied) or >= 50 (likely occupied)
// This ensures we see boundaries even if probability is not exactly 100
if (occupancy === 100 || (occupancy >= 50 && occupancy < 100)) {
r = g = b = 0; // Black
a = 255; // Fully opaque
if (shouldLog) {
if (occupancy === 100) {
occupiedCount++;
} else {
occupiedCount++; // Count probability cells as occupied too
}
}
} else {
r = g = b = 0;
a = 0; // Transparent
if (shouldLog) {
if (occupancy === 0) {
freeCount++;
} else if (occupancy === 255) {
unknownCount++;
} else {
unknownCount++; // Other values treated as unknown
}
}
}
const idx = i * 4;
imageData.data[idx] = r;
imageData.data[idx + 1] = g;
imageData.data[idx + 2] = b;
imageData.data[idx + 3] = a;
}
if (shouldLog) {
console.log(`[buildMapCache] Occupancy stats - Occupied: ${occupiedCount}, Free: ${freeCount}, Unknown: ${unknownCount}`);
}
this.mapCacheCtx.putImageData(imageData, 0, 0);
this.mapCacheReady = true;
console.log('[buildMapCache] Map cache built successfully');
} catch (error) {
console.error('[buildMapCache] Error building map cache:', error);
this.mapCacheReady = false;
}
},
/**
* Main render function - draws everything
*
* CRITICAL: This function should NEVER change map origin or view offsets.
* Map frame position is calculated from locked origin + current view offsets.
*/
render: function() {
if (!this.ctx || !this.canvas) return;
// CRITICAL: Use locked view offsets if available (prevents map frame from moving)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
// Force use locked offsets - map frame MUST stay fixed
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
console.error('⚠️ CRITICAL ERROR: View offsets changed at start of render! Restoring locked offsets.');
console.error(' Locked:', { offsetX: this.lockedOffsetX, offsetY: this.lockedOffsetY });
console.error(' Current:', { offsetX: this.offsetX, offsetY: this.offsetY });
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
} else {
// Even if they match, force use locked offsets to be absolutely sure
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
}
// Save view state at start of render
const renderStartOffsetX = this.offsetX;
const renderStartOffsetY = this.offsetY;
const renderStartScale = this.scale;
// Verify map origin hasn't changed during render
if (this.lockedMapOrigin && this.gridMap?.origin) {
const dx = Math.abs(this.gridMap.origin.x - this.lockedMapOrigin.x);
const dy = Math.abs(this.gridMap.origin.y - this.lockedMapOrigin.y);
const dz = Math.abs(this.gridMap.origin.z - this.lockedMapOrigin.z);
if (dx > 0.001 || dy > 0.001 || dz > 0.001) {
console.error('⚠️ CRITICAL: Map origin changed during render! Restoring locked origin.');
console.error(' Locked:', this.lockedMapOrigin);
console.error(' Current:', this.gridMap.origin);
this.gridMap.origin = {
x: this.lockedMapOrigin.x,
y: this.lockedMapOrigin.y,
z: this.lockedMapOrigin.z
};
}
}
// Clear canvas - NO white background, only grid will show
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw in order: grid -> grid map -> map frame axes -> cost map -> global path -> laser scans -> initial pose -> robot pose
// Cost map and path should be drawn BEFORE laser scans so they're visible underneath
this.drawGrid();
this.drawGridMap();
this.drawCoordinateAxes(); // Draw map frame axes (X=red, Y=green) at origin
this.drawWorldCoordinateAxes(); // Draw world coordinate axes (X=red, Y=green) at origin
this.drawCostMap(); // Draw cost map overlay
this.drawGlobalPath(); // Draw path BEFORE laser scans
this.drawLocalPath(); // Draw local path BEFORE laser scans
this.drawLaserScans();
this.drawInitialPose();
if (this.showRobotPose) {
this.drawRobotPose();
}
this.drawRobotAxes();
this.drawRobotFootprint();
// CRITICAL: Use locked view offsets if available (prevents map frame from moving)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
// Force use locked offsets - map frame MUST stay fixed
if (Math.abs(this.offsetX - this.lockedOffsetX) > 0.1 ||
Math.abs(this.offsetY - this.lockedOffsetY) > 0.1) {
console.error('⚠️ CRITICAL ERROR: View offsets changed during render!');
console.error(' Locked:', { offsetX: this.lockedOffsetX, offsetY: this.lockedOffsetY });
console.error(' Current:', { offsetX: this.offsetX, offsetY: this.offsetY });
console.error(' Robot pose:', this.robotPose);
console.error(' → Restoring locked offsets!');
// Restore locked offsets
this.offsetX = this.lockedOffsetX;
this.offsetY = this.lockedOffsetY;
}
}
},
/**
* Draw background grid (1 meter squares). Grid origin = Map frame = canvas center.
*/
drawGrid: function() {
const { mapFrameX, mapFrameY } = this.getFramePositions();
const gridSpacing = this.scale;
const snapToPixel = (value) => Math.round(value) + 0.5;
// Calculate visible range in map frame coordinates
// For X: canvas X = mapFrameX + (mapX * scale), so mapX = (canvasX - mapFrameX) / scale
const minX = (-mapFrameX) / this.scale;
const maxX = (this.canvas.width - mapFrameX) / this.scale;
// For Y: canvas Y = mapFrameY - (mapY * scale), so mapY = (mapFrameY - canvasY) / scale
// Top of canvas (y=0) corresponds to: mapY = (mapFrameY - 0) / scale = mapFrameY / scale
// Bottom of canvas (y=height) corresponds to: mapY = (mapFrameY - height) / scale
const minY = (mapFrameY - this.canvas.height) / this.scale;
const maxY = mapFrameY / this.scale;
// Add margin to ensure all visible grid lines are drawn
const margin = 3; // meters - increased to ensure coverage
const startGridX = Math.floor(minX - margin);
const endGridX = Math.ceil(maxX + margin);
const startGridY = Math.floor(minY - margin);
const endGridY = Math.ceil(maxY + margin);
// Adjust line width based on zoom level for better visibility
const baseLineWidth = 1;
const adjustedLineWidth = Math.max(0.5, Math.min(2, baseLineWidth / (this.scale / 20)));
// Draw vertical lines (1 meter spacing) - relative to World frame
this.ctx.strokeStyle = '#d0d0d0'; // Darker gray
this.ctx.lineWidth = adjustedLineWidth;
this.ctx.globalAlpha = 0.6; // More opaque
for (let x = startGridX; x <= endGridX; x += 1) {
const canvasX = snapToPixel(mapFrameX + (x * gridSpacing));
// Use larger margin to ensure lines are drawn even when slightly outside viewport
if (canvasX >= -100 && canvasX <= this.canvas.width + 100) {
this.ctx.beginPath();
this.ctx.moveTo(canvasX, 0);
this.ctx.lineTo(canvasX, this.canvas.height);
this.ctx.stroke();
}
}
// Draw horizontal lines (1 meter spacing) - relative to World frame
// Important: y increases upward in map frame, but canvas Y increases downward
// So for map frame y, canvas Y = mapFrameY - (y * gridSpacing)
for (let y = startGridY; y <= endGridY; y += 1) {
const canvasY = snapToPixel(mapFrameY - (y * gridSpacing));
// Use much larger margin and remove strict bounds checking to ensure all lines are drawn
// The key is to draw all lines in the calculated range, regardless of exact canvas position
if (!isNaN(canvasY) && isFinite(canvasY)) {
this.ctx.beginPath();
this.ctx.moveTo(0, canvasY);
this.ctx.lineTo(this.canvas.width, canvasY);
this.ctx.stroke();
}
}
// Draw 5-meter grid lines (darker and more visible)
this.ctx.strokeStyle = '#b0b0b0'; // Even darker
this.ctx.lineWidth = Math.max(1, adjustedLineWidth * 2);
this.ctx.globalAlpha = 0.7;
for (let x = startGridX; x <= endGridX; x += 1) {
if (x % 5 === 0) {
const canvasX = snapToPixel(mapFrameX + (x * gridSpacing));
if (canvasX >= -100 && canvasX <= this.canvas.width + 100) {
this.ctx.beginPath();
this.ctx.moveTo(canvasX, 0);
this.ctx.lineTo(canvasX, this.canvas.height);
this.ctx.stroke();
}
}
}
for (let y = startGridY; y <= endGridY; y += 1) {
if (y % 5 === 0) {
const canvasY = snapToPixel(mapFrameY - (y * gridSpacing));
// Remove bounds checking for 5-meter lines too - just check for valid number
if (!isNaN(canvasY) && isFinite(canvasY)) {
this.ctx.beginPath();
this.ctx.moveTo(0, canvasY);
this.ctx.lineTo(this.canvas.width, canvasY);
this.ctx.stroke();
}
}
}
// Reset alpha for other drawing
this.ctx.globalAlpha = 1.0;
},
/**
* Draw the occupancy grid map - only renders occupied cells (100) as black boundaries
*/
drawGridMap: function() {
if (!this.gridMap) {
console.debug('[drawGridMap] No gridMap data');
return;
}
if (!this.mapCacheReady) {
console.debug('[drawGridMap] Map cache not ready');
return;
}
if (!this.mapCacheCanvas) {
console.debug('[drawGridMap] Map cache canvas not available');
return;
}
try {
const width = this.gridMap.width;
const height = this.gridMap.height;
const resolution = this.gridMap.resolution;
// Map frame (0,0) = canvas center
// Origin is the position of cell [0,0] (bottom-left corner) in map frame coordinates
// In ROS occupancy grid: origin is the world position of the bottom-left corner of cell [0,0]
const { mapFrameX, mapFrameY } = this.getFramePositions();
const originXY = this.getMapOriginXY();
// Calculate where cell [0,0] should be drawn in canvas coordinates
// Map frame origin (0,0) is at (mapFrameX, mapFrameY) in canvas
// Cell [0,0] is at (origin.x, origin.y) in map frame
// Canvas coordinates: mapFrameX + origin.x * scale, mapFrameY - origin.y * scale (flip Y)
const cell00CanvasX = mapFrameX + (originXY.x * this.scale);
const cell00CanvasY = mapFrameY - (originXY.y * this.scale);
const mapWidthPixels = width * resolution * this.scale;
const mapHeightPixels = height * resolution * this.scale;
this.ctx.save();
// Translate to where cell [0,0] should be drawn
this.ctx.translate(cell00CanvasX, cell00CanvasY);
// Flip Y axis (map Y increases upward, canvas Y increases downward)
// After flip, cell [0,0] is at bottom-left, map extends right and up
this.ctx.scale(1, -1);
// Draw map: cell (0,0) will be at the translate point after flip
// Map extends rightward (positive X) and upward (positive Y in map frame, downward in canvas after flip)
this.ctx.drawImage(this.mapCacheCanvas, 0, 0, mapWidthPixels, mapHeightPixels);
this.ctx.restore();
} catch (error) {
console.error('[drawGridMap] Error drawing grid map:', error);
}
},
/**
* Draw robot pose icon (position and orientation) as a translucent purple arrowhead.
*/
drawRobotPose: function() {
if (!this.robotPose) return;
const { mapFrameX, mapFrameY } = this.getFramePositions();
// Robot pose in MAP coordinates -> canvas = mapFrame + (robot.x*scale, -robot.y*scale)
const robotX = mapFrameX + (this.robotPose.x * this.scale);
const robotY = mapFrameY - (this.robotPose.y * this.scale);
const arrowLength = 35;
const headAngle = Math.PI / 6;
const angle = -this.robotPose.yaw;
const arrowTipX = robotX + arrowLength * Math.cos(angle);
const arrowTipY = robotY + arrowLength * Math.sin(angle);
this.ctx.fillStyle = 'rgba(156, 39, 176, 0.45)';
this.ctx.strokeStyle = 'rgba(106, 27, 154, 0.85)';
this.ctx.lineWidth = 3;
this.ctx.beginPath();
this.ctx.moveTo(arrowTipX, arrowTipY);
this.ctx.lineTo(
robotX - arrowLength * 0.5 * Math.cos(angle - headAngle),
robotY - arrowLength * 0.5 * Math.sin(angle - headAngle)
);
this.ctx.lineTo(
robotX - arrowLength * 0.5 * Math.cos(angle + headAngle),
robotY - arrowLength * 0.5 * Math.sin(angle + headAngle)
);
this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
},
/**
* Draw robot-local coordinate axes independently from the robot icon toggle.
*/
drawRobotAxes: function() {
if (!this.robotPose) return;
const { mapFrameX, mapFrameY } = this.getFramePositions();
const robotX = mapFrameX + (this.robotPose.x * this.scale);
const robotY = mapFrameY - (this.robotPose.y * this.scale);
this.drawRobotCoordinateAxes(robotX, robotY, this.robotPose.yaw || 0);
},
/**
* Draw robot-local coordinate axes (x: red, y: green) at robot center.
*/
drawRobotCoordinateAxes: function(robotX, robotY, yaw) {
const axisLength = 42;
const arrowHeadSize = 8;
// Convert robot yaw (map convention) to canvas angle convention.
const xAxisAngle = -yaw;
const yAxisAngle = xAxisAngle - Math.PI / 2;
const xEndX = robotX + axisLength * Math.cos(xAxisAngle);
const xEndY = robotY + axisLength * Math.sin(xAxisAngle);
const yEndX = robotX + axisLength * Math.cos(yAxisAngle);
const yEndY = robotY + axisLength * Math.sin(yAxisAngle);
// X axis
this.ctx.strokeStyle = '#F44336';
this.ctx.lineWidth = 2.5;
this.ctx.beginPath();
this.ctx.moveTo(robotX, robotY);
this.ctx.lineTo(xEndX, xEndY);
this.ctx.stroke();
this.ctx.fillStyle = '#F44336';
this.ctx.beginPath();
this.ctx.moveTo(xEndX, xEndY);
this.ctx.lineTo(
xEndX - arrowHeadSize * Math.cos(xAxisAngle - Math.PI / 6),
xEndY - arrowHeadSize * Math.sin(xAxisAngle - Math.PI / 6)
);
this.ctx.lineTo(
xEndX - arrowHeadSize * Math.cos(xAxisAngle + Math.PI / 6),
xEndY - arrowHeadSize * Math.sin(xAxisAngle + Math.PI / 6)
);
this.ctx.closePath();
this.ctx.fill();
// Y axis
this.ctx.strokeStyle = '#4CAF50';
this.ctx.lineWidth = 2.5;
this.ctx.beginPath();
this.ctx.moveTo(robotX, robotY);
this.ctx.lineTo(yEndX, yEndY);
this.ctx.stroke();
this.ctx.fillStyle = '#4CAF50';
this.ctx.beginPath();
this.ctx.moveTo(yEndX, yEndY);
this.ctx.lineTo(
yEndX - arrowHeadSize * Math.cos(yAxisAngle - Math.PI / 6),
yEndY - arrowHeadSize * Math.sin(yAxisAngle - Math.PI / 6)
);
this.ctx.lineTo(
yEndX - arrowHeadSize * Math.cos(yAxisAngle + Math.PI / 6),
yEndY - arrowHeadSize * Math.sin(yAxisAngle + Math.PI / 6)
);
this.ctx.closePath();
this.ctx.fill();
// Axis labels
this.ctx.font = 'bold 12px Arial';
this.ctx.fillStyle = '#F44336';
this.ctx.fillText('x', xEndX + 6, xEndY + 4);
this.ctx.fillStyle = '#4CAF50';
this.ctx.fillText('y', yEndX + 6, yEndY + 4);
},
/**
* Draw robot footprint (base outline) at current robot pose.
* Transforms footprint points from robot frame to map frame.
*/
drawRobotFootprint: function() {
if (!this.showRobotFootprint || !this.robotPose || !this.robotFootprint || this.robotFootprint.length === 0) {
return;
}
const { mapFrameX, mapFrameY } = this.getFramePositions();
const robotX = this.robotPose.x;
const robotY = this.robotPose.y;
const yaw = this.robotPose.yaw || 0;
const cos = Math.cos(yaw);
const sin = Math.sin(yaw);
// Transform footprint points from robot frame to map frame
const transformedPoints = this.robotFootprint.map(point => {
// Rotate and translate point
const rotatedX = point.x * cos - point.y * sin;
const rotatedY = point.x * sin + point.y * cos;
const mapX = robotX + rotatedX;
const mapY = robotY + rotatedY;
// Convert to canvas coordinates
const canvasX = mapFrameX + (mapX * this.scale);
const canvasY = mapFrameY - (mapY * this.scale);
return { x: canvasX, y: canvasY, origX: point.x, origY: point.y };
});
// Sort points by angle from center to ensure correct square drawing order
if (transformedPoints.length >= 4) {
// Calculate center of footprint in robot frame
const centerX = this.robotFootprint.reduce((sum, p) => sum + p.x, 0) / this.robotFootprint.length;
const centerY = this.robotFootprint.reduce((sum, p) => sum + p.y, 0) / this.robotFootprint.length;
// Sort by angle from center (counterclockwise)
transformedPoints.sort((a, b) => {
const angleA = Math.atan2(a.origY - centerY, a.origX - centerX);
const angleB = Math.atan2(b.origY - centerY, b.origX - centerX);
return angleA - angleB;
});
}
// Draw footprint as a square
this.ctx.fillStyle = 'rgba(0, 188, 212, 0.2)'; // Cyan with transparency
this.ctx.strokeStyle = '#00bcd4'; // Cyan color
this.ctx.lineWidth = 2;
this.ctx.beginPath();
// Draw square by connecting all points in sorted order
if (transformedPoints.length >= 4) {
this.ctx.moveTo(transformedPoints[0].x, transformedPoints[0].y);
for (let i = 1; i < transformedPoints.length; i++) {
this.ctx.lineTo(transformedPoints[i].x, transformedPoints[i].y);
}
this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
}
},
/**
* Draw initial pose (position + heading) as a bright blue arrow
*/
drawInitialPose: function() {
// Only draw if mode is enabled
if (!this.initialPoseMode) return;
// If no initial pose exists, don't draw (should not happen if mode is enabled)
if (!this.initialPose) {
console.warn("Initial pose mode is ON but no initial pose exists");
return;
}
const { mapFrameX, mapFrameY } = this.getFramePositions();
const baseX = mapFrameX + (this.initialPose.x * this.scale);
const baseY = mapFrameY - (this.initialPose.y * this.scale);
const arrowLength = 50; // Larger arrow
const headAngle = Math.PI / 6;
const angle = -this.initialPose.yaw;
const tipX = baseX + arrowLength * Math.cos(angle);
const tipY = baseY + arrowLength * Math.sin(angle);
// Draw a glowing effect (outer glow) - more visible when dragging
if (this.initialPoseDragging) {
this.ctx.shadowColor = 'rgba(33, 150, 243, 1.0)';
this.ctx.shadowBlur = 20;
} else {
this.ctx.shadowColor = 'rgba(33, 150, 243, 0.8)';
this.ctx.shadowBlur = 15;
}
// Base marker (larger and bright blue)
this.ctx.fillStyle = '#2196F3'; // Material Blue
this.ctx.strokeStyle = '#1976D2';
this.ctx.lineWidth = this.initialPoseDragging ? 4 : 3;
this.ctx.beginPath();
this.ctx.arc(baseX, baseY, this.initialPoseDragging ? 10 : 8, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.stroke();
// Arrow shaft (thicker)
this.ctx.strokeStyle = '#2196F3';
this.ctx.lineWidth = this.initialPoseDragging ? 5 : 4;
this.ctx.beginPath();
this.ctx.moveTo(baseX, baseY);
this.ctx.lineTo(tipX, tipY);
this.ctx.stroke();
// Arrow head (larger)
this.ctx.fillStyle = '#2196F3';
this.ctx.strokeStyle = '#1976D2';
this.ctx.lineWidth = this.initialPoseDragging ? 3 : 2;
this.ctx.beginPath();
this.ctx.moveTo(tipX, tipY);
this.ctx.lineTo(
tipX - arrowLength * 0.5 * Math.cos(angle - headAngle),
tipY - arrowLength * 0.5 * Math.sin(angle - headAngle)
);
this.ctx.lineTo(
tipX - arrowLength * 0.5 * Math.cos(angle + headAngle),
tipY - arrowLength * 0.5 * Math.sin(angle + headAngle)
);
this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
// Reset shadow
this.ctx.shadowBlur = 0;
},
/**
* Draw laser scan points from all LIDARs
*/
drawLaserScans: function() {
if (!this.laserScanData) return;
const lidar1Options = this.lidarDisplayOptions?.lidar1 || { visible: true, mode: 'minimal' };
const lidar2Options = this.lidarDisplayOptions?.lidar2 || { visible: true, mode: 'minimal' };
const lidar3Options = this.lidarDisplayOptions?.lidar3 || { visible: true, mode: 'minimal' };
// Draw each LIDAR's scan with different colors (darker for white background)
if (lidar1Options.visible && this.laserScanData.lidar1) {
this.drawSingleLidarScan(this.laserScanData.lidar1, '#cc0000', this.getLidarTransform('scan_1'), lidar1Options.mode); // Dark Red
}
if (lidar2Options.visible && this.laserScanData.lidar2) {
this.drawSingleLidarScan(this.laserScanData.lidar2, '#00aa00', this.getLidarTransform('scan_2'), lidar2Options.mode); // Dark Green
}
if (lidar3Options.visible && this.laserScanData.lidar3) {
this.drawSingleLidarScan(this.laserScanData.lidar3, '#0000cc', this.getLidarTransform('scan_3'), lidar3Options.mode); // Dark Blue
}
},
drawGlobalPath: function() {
if (!this.showGlobalPath) return;
if (!this.globalPathData || this.globalPathData.length === 0) return;
const { mapFrameX, mapFrameY } = this.getFramePositions();
const pathPoints = this.globalPathData.map(point => ({
x: mapFrameX + (point.x * this.scale),
y: mapFrameY - (point.y * this.scale)
}));
// Check if any points are visible
const visiblePoints = pathPoints.filter(p =>
p.x >= 0 && p.x <= this.canvas.width &&
p.y >= 0 && p.y <= this.canvas.height
);
if (visiblePoints.length === 0) {
console.warn('Global path: no points visible in current view (pan/zoom or path off-screen).');
}
this.ctx.save();
// Draw path line - RViz style with green color
if (pathPoints.length > 1) {
// Background white outline for visibility
this.ctx.beginPath();
this.ctx.moveTo(pathPoints[0].x, pathPoints[0].y);
for (let i = 1; i < pathPoints.length; i++) {
this.ctx.lineTo(pathPoints[i].x, pathPoints[i].y);
}
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 8;
this.ctx.lineJoin = 'round';
this.ctx.lineCap = 'round';
this.ctx.globalAlpha = 0.9;
this.ctx.stroke();
// Main path line - bright green like RViz
this.ctx.beginPath();
this.ctx.moveTo(pathPoints[0].x, pathPoints[0].y);
for (let i = 1; i < pathPoints.length; i++) {
this.ctx.lineTo(pathPoints[i].x, pathPoints[i].y);
}
this.ctx.strokeStyle = '#00FF00'; // Bright green like RViz
this.ctx.lineWidth = 4;
this.ctx.globalAlpha = 0.85;
this.ctx.stroke();
}
// Draw path points for short paths or better visibility
this.ctx.globalAlpha = 0.8;
const pointStep = Math.max(1, Math.floor(pathPoints.length / 50));
this.ctx.fillStyle = '#00FF00';
for (let i = 0; i < pathPoints.length; i += pointStep) {
const point = pathPoints[i];
this.ctx.beginPath();
this.ctx.arc(point.x, point.y, 3, 0, 2 * Math.PI);
this.ctx.fill();
}
// Directional arrows along the path (heading indicators)
if (pathPoints.length > 1) {
const arrowSpacingPx = Math.max(30, this.scale * 0.8); // ~1 arrow per 0.8 m (min 30px)
const arrowSize = Math.max(5, Math.min(12, this.scale * 0.18));
this.ctx.globalAlpha = 0.95;
this.ctx.fillStyle = '#FF0000';
this.ctx.strokeStyle = '#FF0000';
this.ctx.lineWidth = 1;
let distAccum = arrowSpacingPx * 0.5; // first arrow offset
for (let i = 0; i < pathPoints.length - 1; i++) {
const p0 = pathPoints[i];
const p1 = pathPoints[i + 1];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const segLen = Math.sqrt(dx * dx + dy * dy);
if (segLen < 1e-6) continue;
let remaining = segLen;
let traveled = 0;
while (distAccum <= remaining) {
const t = (traveled + distAccum) / segLen;
const px = p0.x + dx * t;
const py = p0.y + dy * t;
// Prefer theta from path data (world frame -> flip Y for screen)
const worldTheta = this.globalPathData[i]?.theta;
const angle = (worldTheta !== undefined && isFinite(worldTheta))
? -worldTheta
: Math.atan2(dy, dx);
const L = arrowSize;
const W = arrowSize * 0.55;
this.ctx.save();
this.ctx.translate(px, py);
this.ctx.rotate(angle);
this.ctx.beginPath();
this.ctx.moveTo( L, 0); // tip
this.ctx.lineTo(-L * 0.6, -W); // left wing
this.ctx.lineTo(-L * 0.25, 0); // tail notch
this.ctx.lineTo(-L * 0.6, W); // right wing
this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
this.ctx.restore();
traveled += distAccum;
remaining -= distAccum;
distAccum = arrowSpacingPx;
}
distAccum -= remaining;
}
}
// Start marker - bright green circle
const firstPoint = pathPoints[0];
this.ctx.globalAlpha = 1.0;
this.ctx.fillStyle = '#FFFFFF';
this.ctx.beginPath();
this.ctx.arc(firstPoint.x, firstPoint.y, 8, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.fillStyle = '#00FF00';
this.ctx.beginPath();
this.ctx.arc(firstPoint.x, firstPoint.y, 6, 0, 2 * Math.PI);
this.ctx.fill();
// End marker - bright red circle
const lastPoint = pathPoints[pathPoints.length - 1];
this.ctx.fillStyle = '#FFFFFF';
this.ctx.beginPath();
this.ctx.arc(lastPoint.x, lastPoint.y, 8, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.fillStyle = '#FF0000';
this.ctx.beginPath();
this.ctx.arc(lastPoint.x, lastPoint.y, 6, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.restore();
},
drawLocalPath: function() {
if (!this.showLocalPath) return;
if (!this.localPathData || this.localPathData.length < 2) return;
const { mapFrameX, mapFrameY } = this.getFramePositions();
const pathPoints = this.localPathData.map(point => ({
x: mapFrameX + (point.x * this.scale),
y: mapFrameY - (point.y * this.scale)
}));
this.ctx.save();
// White outline for contrast
this.ctx.beginPath();
this.ctx.moveTo(pathPoints[0].x, pathPoints[0].y);
for (let i = 1; i < pathPoints.length; i++) {
this.ctx.lineTo(pathPoints[i].x, pathPoints[i].y);
}
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 6;
this.ctx.lineJoin = 'round';
this.ctx.lineCap = 'round';
this.ctx.globalAlpha = 0.85;
this.ctx.stroke();
// Main local path line (orange)
this.ctx.beginPath();
this.ctx.moveTo(pathPoints[0].x, pathPoints[0].y);
for (let i = 1; i < pathPoints.length; i++) {
this.ctx.lineTo(pathPoints[i].x, pathPoints[i].y);
}
this.ctx.strokeStyle = '#ff9800';
this.ctx.lineWidth = 3;
this.ctx.globalAlpha = 0.9;
this.ctx.stroke();
// Directional arrows along the local path
if (pathPoints.length > 1) {
const arrowSpacingPx = Math.max(25, this.scale * 0.5);
const arrowSize = Math.max(4, Math.min(10, this.scale * 0.15));
this.ctx.globalAlpha = 0.95;
this.ctx.fillStyle = '#ff9800';
this.ctx.strokeStyle = '#b26a00';
this.ctx.lineWidth = 1;
let distAccum = arrowSpacingPx * 0.5;
for (let i = 0; i < pathPoints.length - 1; i++) {
const p0 = pathPoints[i];
const p1 = pathPoints[i + 1];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const segLen = Math.sqrt(dx * dx + dy * dy);
if (segLen < 1e-6) continue;
let remaining = segLen;
let traveled = 0;
while (distAccum <= remaining) {
const t = (traveled + distAccum) / segLen;
const px = p0.x + dx * t;
const py = p0.y + dy * t;
const worldTheta = this.localPathData[i]?.theta;
const angle = (worldTheta !== undefined && isFinite(worldTheta))
? -worldTheta
: Math.atan2(dy, dx);
const L = arrowSize;
const W = arrowSize * 0.55;
this.ctx.save();
this.ctx.translate(px, py);
this.ctx.rotate(angle);
this.ctx.beginPath();
this.ctx.moveTo( L, 0);
this.ctx.lineTo(-L * 0.6, -W);
this.ctx.lineTo(-L * 0.25, 0);
this.ctx.lineTo(-L * 0.6, W);
this.ctx.closePath();
this.ctx.fill();
this.ctx.stroke();
this.ctx.restore();
traveled += distAccum;
remaining -= distAccum;
distAccum = arrowSpacingPx;
}
distAccum -= remaining;
}
}
this.ctx.restore();
},
// ==================== COST MAP FUNCTIONS ====================
setCostMapVisible: function(visible) {
this.showCostMap = !!visible;
console.log(`🔄 Cost Map visibility: ${this.showCostMap ? 'ENABLED' : 'DISABLED'}`);
this.requestRender();
},
setRobotPoseVisible: function(visible) {
this.showRobotPose = !!visible;
console.log(`🔄 Robot icon visibility: ${this.showRobotPose ? 'ENABLED' : 'DISABLED'}`);
this.requestRender();
},
setRobotFootprint: function(footprintJson) {
try {
const footprintData = typeof footprintJson === 'string' ? JSON.parse(footprintJson) : footprintJson;
this.robotFootprint = footprintData;
console.log('📐 Robot footprint set:', this.robotFootprint);
this.requestRender();
} catch (error) {
console.error('❌ Failed to parse robot footprint:', error);
this.robotFootprint = null;
}
},
setRobotFootprintVisible: function(visible) {
this.showRobotFootprint = !!visible;
console.log(`🔄 Robot footprint visibility: ${this.showRobotFootprint ? 'ENABLED' : 'DISABLED'}`);
this.requestRender();
},
setCostMapData: function(data) {
if (!data) {
return;
}
try {
const hasFullMap = data.hasFullMap === true || (!!data.dataBase64 && data.width > 0 && data.height > 0);
const isCostmapUpdated = data.isCostmapUpdated === true || !!data.costmapUpdate;
if (!hasFullMap && !isCostmapUpdated) {
return;
}
if (hasFullMap) {
const width = Number(data.width) || 0;
const height = Number(data.height) || 0;
const resolution = Number(data.resolution) || 0;
const originX = Number(data.originX) || 0;
const originY = Number(data.originY) || 0;
const originTheta = Number(data.originTheta) || 0;
if (width <= 0 || height <= 0 || resolution <= 0) {
console.warn('[setCostMapData] Invalid full costmap payload');
return;
}
const fullData = this.decodeCostMapBase64(data.dataBase64);
if (!fullData || fullData.length === 0) {
console.warn('[setCostMapData] Missing full costmap bytes');
return;
}
const hasExistingSnapshot = !!(this.costMapData && this.costMapData.data);
const dimensionsChanged = !hasExistingSnapshot ||
this.costMapData.width !== width ||
this.costMapData.height !== height ||
Math.abs(this.costMapData.resolution - resolution) > 1e-9;
// Always update costmap data for continuous updates (lidar frequency)
this.costMapData = {
frameId: data.frameId || 'map',
width: width,
height: height,
resolution: resolution,
originX: originX,
originY: originY,
originTheta: originTheta,
poseX: dimensionsChanged ? originX : this.costMapData.poseX,
poseY: dimensionsChanged ? originY : this.costMapData.poseY,
fixedYaw: dimensionsChanged
? (Number.isFinite(this.costMapFixedYaw) ? this.costMapFixedYaw : originTheta)
: this.costMapData.fixedYaw,
data: fullData
};
if (dimensionsChanged) {
this.costMapPoseAnchor = { x: originX, y: originY };
this.costMapOdomAnchor = null;
}
this.rebuildCostMapCache();
}
if (this.costMapData && isCostmapUpdated) {
this.applyCostMapUpdate(this.costMapData, data.costmapUpdate);
}
this.updateCostMapPoseFromOdometry(
data.odometry,
data.localizationActive === true,
data.xlocPose,
Number(data.matchingScore)
);
if (this.costMapData && Number.isFinite(this.costMapFixedYaw)) {
this.costMapData.fixedYaw = this.costMapFixedYaw;
}
if (data.xlocPose && Number(data.matchingScore) !== -1) {
this.setRobotPose(data.xlocPose);
}
this.requestRender();
} catch (error) {
console.error('❌ Error processing cost map data:', error);
}
},
decodeCostMapBase64: function(dataBase64) {
if (!dataBase64) {
return null;
}
const binaryString = atob(dataBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
},
rebuildCostMapCache: function() {
if (!this.costMapData || !this.costMapData.data) {
this.costMapCacheReady = false;
return;
}
const { width, height, data } = this.costMapData;
if (!this.costMapCacheCanvas) {
this.costMapCacheCanvas = document.createElement('canvas');
this.costMapCacheCtx = this.costMapCacheCanvas.getContext('2d');
}
// Rotate 90° clockwise: swap width and height
this.costMapCacheCanvas.width = height;
this.costMapCacheCanvas.height = width;
const imageData = this.costMapCacheCtx.createImageData(height, width);
const pixels = imageData.data;
// Rotate 90° clockwise: original (x, y) -> rotated (height - 1 - y, x)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = y * width + x;
const costValue = data[srcIdx];
// Rotated coordinates
const rotX = height - 1 - y;
const rotY = x;
const dstIdx = rotY * height + rotX;
const pxIdx = dstIdx * 4;
// Unknown and free space should stay transparent so inflation gradient is visible.
if (costValue === 255 || costValue === 0) {
pixels[pxIdx] = 0;
pixels[pxIdx + 1] = 0;
pixels[pxIdx + 2] = 0;
pixels[pxIdx + 3] = 0;
continue;
}
let r = 0;
let g = 0;
let b = 0;
let a = 0;
if (costValue >= 253) {
// Inscribed/lethal obstacles.
r = 255;
g = 30;
b = 30;
a = 235;
} else {
// Inflation area (1..252): green/yellow -> orange -> red with increasing alpha.
const t = Math.max(0, Math.min(1, costValue / 252));
r = Math.round(60 + 195 * t);
g = Math.round(255 - 220 * t);
b = Math.round(40 * (1 - t));
a = Math.round(35 + 190 * Math.pow(t, 0.65));
}
pixels[pxIdx] = r;
pixels[pxIdx + 1] = g;
pixels[pxIdx + 2] = b;
pixels[pxIdx + 3] = a;
}
}
this.costMapCacheCtx.putImageData(imageData, 0, 0);
this.costMapCacheReady = true;
},
applyCostMapUpdate: function(costMapData, costmapUpdate) {
if (!costMapData || !costMapData.data || !costmapUpdate) {
return;
}
const patchWidth = Number(costmapUpdate.width) || 0;
const patchHeight = Number(costmapUpdate.height) || 0;
const patchX = Number(costmapUpdate.x) || 0;
const patchY = Number(costmapUpdate.y) || 0;
if (patchWidth <= 0 || patchHeight <= 0) {
return;
}
const patchBytes = this.decodeCostMapBase64(costmapUpdate.dataBase64 || costmapUpdate.data);
if (!patchBytes || patchBytes.length === 0) {
return;
}
const maxPatchCells = patchWidth * patchHeight;
const patchCellCount = Math.min(maxPatchCells, patchBytes.length);
let applied = 0;
for (let i = 0; i < patchCellCount; i++) {
const localX = i % patchWidth;
const localY = Math.floor(i / patchWidth);
const mapX = patchX + localX;
const mapY = patchY + localY;
if (mapX < 0 || mapX >= costMapData.width || mapY < 0 || mapY >= costMapData.height) {
continue;
}
const mapIdx = mapY * costMapData.width + mapX;
costMapData.data[mapIdx] = patchBytes[i];
applied++;
}
if (applied > 0) {
this.rebuildCostMapCache();
}
},
normalizeFrameId: function(frameId) {
return (frameId || '').toString().replace(/^\/+/, '').toLowerCase();
},
lockMapFromOdomTransform: function(odometry, xlocPose) {
const odomX = Number(odometry?.x);
const odomY = Number(odometry?.y);
const odomYaw = Number(odometry?.yaw);
const mapX = Number(xlocPose?.x);
const mapY = Number(xlocPose?.y);
const mapYaw = Number(xlocPose?.yaw);
const hasOdom = Number.isFinite(odomX) && Number.isFinite(odomY) && Number.isFinite(odomYaw);
const hasMapPose = Number.isFinite(mapX) && Number.isFinite(mapY) && Number.isFinite(mapYaw);
if (!hasOdom || !hasMapPose) {
return false;
}
// map<-odom = map<-base * inverse(odom<-base)
const c = Math.cos(mapYaw);
const s = Math.sin(mapYaw);
const tx = mapX - (c * odomX - s * odomY);
const ty = mapY - (s * odomX + c * odomY);
this.costMapMapFromOdomLock = {
tx: tx,
ty: ty,
yaw: mapYaw - odomYaw
};
return true;
},
transformPointFromOdomToMap: function(x, y) {
if (!this.costMapMapFromOdomLock) {
return { x: x, y: y };
}
const t = this.costMapMapFromOdomLock;
const c = Math.cos(t.yaw);
const s = Math.sin(t.yaw);
return {
x: t.tx + c * x - s * y,
y: t.ty + s * x + c * y
};
},
updateCostMapPoseFromOdometry: function(odometry, localizationActive, xlocPose, matchingScore) {
if (!this.costMapData) {
return;
}
const odom = odometry || null;
const odomX = Number(odom?.x);
const odomY = Number(odom?.y);
const odomYaw = Number(odom?.yaw);
const hasOdom = Number.isFinite(odomX) && Number.isFinite(odomY);
const frameId = this.normalizeFrameId(this.costMapData.frameId);
const isOdomFrame = frameId === 'odom';
const hasValidLocalization = Number.isFinite(matchingScore) && matchingScore !== -1;
const localizationJustSucceeded = hasValidLocalization && this.costMapLastMatchingScore === -1;
// Lock costmap orientation and map<-odom transform when localization starts.
if (localizationActive && !this.costMapLocalizationActive) {
if (isOdomFrame && hasValidLocalization) {
this.lockMapFromOdomTransform(odom, xlocPose);
}
}
// Update costmap yaw to match robot yaw when localization successfully calculates (matching score != -1)
if (hasValidLocalization && (localizationJustSucceeded || localizationActive && !this.costMapLocalizationActive)) {
const oldYaw = this.costMapFixedYaw;
let baseYaw = 0;
if (xlocPose && Number.isFinite(Number(xlocPose.yaw))) {
baseYaw = Number(xlocPose.yaw);
} else if (Number.isFinite(odomYaw)) {
baseYaw = odomYaw;
} else {
baseYaw = Number(this.costMapData.originTheta) || 0;
}
// Rotate costmap 45° clockwise to align inflation with wall boundaries
const offsetDegrees = 190; // Negative = clockwise
const offsetRadians = offsetDegrees * Math.PI / 180;
this.costMapFixedYaw = baseYaw + offsetRadians;
if (Number.isFinite(this.costMapFixedYaw)) {
const baseYawDeg = baseYaw * 180 / Math.PI;
const finalYawDeg = this.costMapFixedYaw * 180 / Math.PI;
const oldYawDeg = Number.isFinite(oldYaw) ? oldYaw * 180 / Math.PI : null;
const offsetLabel = offsetDegrees > 0 ? `+${offsetDegrees}° CCW` : `${Math.abs(offsetDegrees)}° CW`;
console.log(`[Costmap] Locked yaw: Robot=${baseYawDeg.toFixed(1)}° ${offsetLabel} = ${finalYawDeg.toFixed(1)}° (matching score: ${matchingScore.toFixed(2)})` +
(oldYawDeg !== null ? `, was ${oldYawDeg.toFixed(1)}°` : ''));
}
}
this.costMapLocalizationActive = localizationActive;
this.costMapLastMatchingScore = matchingScore;
if (!Number.isFinite(this.costMapFixedYaw)) {
this.costMapFixedYaw = Number(this.costMapData.originTheta) || 0;
}
if (isOdomFrame) {
if (!this.costMapMapFromOdomLock && hasValidLocalization) {
this.lockMapFromOdomTransform(odom, xlocPose);
}
const transformed = this.transformPointFromOdomToMap(
Number(this.costMapData.originX) || 0,
Number(this.costMapData.originY) || 0
);
this.costMapData.poseX = transformed.x;
this.costMapData.poseY = transformed.y;
} else if (hasOdom && this.costMapPoseAnchor && this.costMapOdomAnchor) {
// Fallback for non-odom frame sources.
this.costMapData.poseX = this.costMapPoseAnchor.x + (odomX - this.costMapOdomAnchor.x);
this.costMapData.poseY = this.costMapPoseAnchor.y + (odomY - this.costMapOdomAnchor.y);
} else {
this.costMapData.poseX = Number(this.costMapData.originX) || 0;
this.costMapData.poseY = Number(this.costMapData.originY) || 0;
}
this.costMapData.fixedYaw = this.costMapFixedYaw;
},
drawCostMap: function() {
if (!this.showCostMap || !this.costMapData || !this.costMapData.data || !this.costMapCacheReady) {
return;
}
const { mapFrameX, mapFrameY } = this.getFramePositions();
const { width, height, resolution, poseX, poseY, fixedYaw } = this.costMapData;
// After 90° CW rotation, dimensions are swapped in the cache canvas
const mapWidthPixels = height * resolution * this.scale;
const mapHeightPixels = width * resolution * this.scale;
// Calculate angle difference between costmap and robot
if (this.robotPose && Number.isFinite(this.robotPose.yaw) && Number.isFinite(fixedYaw)) {
const angleDiffRad = fixedYaw - this.robotPose.yaw;
const angleDiffDeg = angleDiffRad * 180 / Math.PI;
// Log once every 2 seconds to avoid spam
if (!this._lastCostMapAngleLog || Date.now() - this._lastCostMapAngleLog > 2000) {
console.log(`[Costmap] Offset from robot: ${angleDiffDeg.toFixed(1)}° (Robot: ${(this.robotPose.yaw * 180 / Math.PI).toFixed(1)}°, Costmap: ${(fixedYaw * 180 / Math.PI).toFixed(1)}°)`);
this._lastCostMapAngleLog = Date.now();
}
}
this.ctx.save();
this.ctx.globalAlpha = 0.75;
// Use robot position as center of rotation (robot should be at center of costmap)
let centerX, centerY;
if (this.robotPose) {
// Robot is the center of costmap
centerX = mapFrameX + (this.robotPose.x * this.scale);
centerY = mapFrameY - (this.robotPose.y * this.scale);
} else {
// Fallback to costmap origin + half dimensions
const halfWidthMeters = (height * resolution) / 2; // After 90° rotation
const halfHeightMeters = (width * resolution) / 2;
centerX = mapFrameX + ((poseX + halfWidthMeters) * this.scale);
centerY = mapFrameY - ((poseY + halfHeightMeters) * this.scale);
}
// Translate to center (robot position), rotate, then draw costmap centered
this.ctx.translate(centerX, centerY);
this.ctx.rotate(-(fixedYaw || 0));
// Translate costmap in robot frame: X=forward, Y=left
const xOffsetMeters = -0.285; // Forward offset
const yOffsetMeters = -0.707; // Left offset
this.ctx.translate(xOffsetMeters * this.scale, -yOffsetMeters * this.scale);
this.ctx.scale(1, -1);
// Draw costmap centered at origin (robot at center + offsets)
this.ctx.drawImage(this.costMapCacheCanvas, -mapWidthPixels / 2, -mapHeightPixels / 2, mapWidthPixels, mapHeightPixels);
// Draw costmap frame so its boundary is always visible.
this.ctx.globalAlpha = 1.0;
this.ctx.strokeStyle = 'rgba(0, 188, 212, 0.95)';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(-mapWidthPixels / 2, -mapHeightPixels / 2, mapWidthPixels, mapHeightPixels);
this.ctx.restore();
},
/**
* Get LIDAR transform relative to base_link (from XlocClient.cs static transforms)
*/
getLidarTransform: function(frameId) {
// Defaults to identity if unknown
const defaultTransform = { x: 0, y: 0, yaw: 0 };
switch (frameId) {
case 'scan_1': {
// base_link -> scan_1
return {
x: 0.2985,
y: 0.0,
yaw: this.quaternionToYaw(0.0, 0.0, 0.0, 1.0)
};
}
case 'scan_2': {
// base_link -> scan_2
return {
x: -0.2985,
y: -0.0,
yaw: this.quaternionToYaw(0.0, 0.0, 1.0, 0.0)
};
}
case 'scan_3': {
// base_link -> scan_3
return {
x: 0.707,
y: 0.2825,
yaw: this.quaternionToYaw(0.0, 0.0, 0.3826834, 0.9238795)
};
}
default:
return defaultTransform;
}
},
/**
* Convert quaternion to yaw (radians) for Z rotation only.
*/
quaternionToYaw: function(qx, qy, qz, qw) {
const sinyCosp = 2.0 * (qw * qz + qx * qy);
const cosyCosp = 1.0 - 2.0 * (qy * qy + qz * qz);
return Math.atan2(sinyCosp, cosyCosp);
},
/**
* Draw a single LIDAR scan
* LIDAR points are transformed from robot frame -> map frame -> canvas
*/
drawSingleLidarScan: function(scanData, color, lidarTransform, renderMode) {
if (!scanData || !scanData.points || scanData.points.length === 0) return;
const { mapFrameX, mapFrameY } = this.getFramePositions();
// Get robot pose in MAP coordinates for transformation
const robotX = this.robotPose ? this.robotPose.x : 0;
const robotY = this.robotPose ? this.robotPose.y : 0;
const robotYaw = this.robotPose ? this.robotPose.yaw : 0;
this.ctx.fillStyle = color;
const lidar = lidarTransform || { x: 0, y: 0, yaw: 0 };
const cosLidarYaw = Math.cos(lidar.yaw);
const sinLidarYaw = Math.sin(lidar.yaw);
// Backend now handles sampling based on mode: minimal = 120 points, full = all points
// Frontend just draws all points received from backend
for (let i = 0; i < scanData.points.length; i++) {
const point = scanData.points[i];
let pointX, pointY;
// Check if point has x,y (cartesian) or angle,range (polar)
if (point.x !== undefined && point.y !== undefined) {
// Already in cartesian coordinates (robot frame)
pointX = point.x;
pointY = point.y;
} else if (point.angle !== undefined && point.range !== undefined) {
// Convert from polar to cartesian (robot frame)
// LIDAR convention: angle 0 = forward (positive X)
pointX = point.range * Math.cos(point.angle);
pointY = point.range * Math.sin(point.angle);
} else {
continue; // Skip invalid point
}
// Transform from LIDAR frame -> robot frame
const robotPointX = lidar.x + (pointX * cosLidarYaw - pointY * sinLidarYaw);
const robotPointY = lidar.y + (pointX * sinLidarYaw + pointY * cosLidarYaw);
// Transform from robot frame to MAP frame
const cosYaw = Math.cos(robotYaw);
const sinYaw = Math.sin(robotYaw);
const mapX = robotX + (robotPointX * cosYaw - robotPointY * sinYaw);
const mapY = robotY + (robotPointX * sinYaw + robotPointY * cosYaw);
// Convert MAP coordinates to canvas coordinates
// Same transform as map: mapFrameX + mapX * scale, mapFrameY - mapY * scale (flip Y)
const canvasX = mapFrameX + (mapX * this.scale);
const canvasY = mapFrameY - (mapY * this.scale);
// Draw point (larger size: 3px radius)
this.ctx.beginPath();
this.ctx.arc(canvasX, canvasY, 3, 0, 2 * Math.PI);
this.ctx.fill();
}
},
/**
* Draw map frame coordinate axes (fixed at origin)
*/
drawCoordinateAxes: function() {
const { mapFrameX, mapFrameY } = this.getFramePositions();
const axisLength = 60;
// Origin marker
this.ctx.fillStyle = '#FF9800';
this.ctx.strokeStyle = '#F57C00';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.arc(mapFrameX, mapFrameY, 6, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.stroke();
// X axis (red)
this.ctx.strokeStyle = '#F44336';
this.ctx.lineWidth = 3;
this.ctx.beginPath();
this.ctx.moveTo(mapFrameX, mapFrameY);
this.ctx.lineTo(mapFrameX + axisLength, mapFrameY);
this.ctx.stroke();
// X arrow
this.ctx.fillStyle = '#F44336';
this.ctx.beginPath();
this.ctx.moveTo(mapFrameX + axisLength, mapFrameY);
this.ctx.lineTo(mapFrameX + axisLength - 8, mapFrameY - 5);
this.ctx.lineTo(mapFrameX + axisLength - 8, mapFrameY + 5);
this.ctx.closePath();
this.ctx.fill();
// X label
this.ctx.fillStyle = '#F44336';
this.ctx.font = 'bold 12px Arial';
this.ctx.fillText('X', mapFrameX + axisLength + 8, mapFrameY + 5);
// Y axis (green)
this.ctx.strokeStyle = '#4CAF50';
this.ctx.lineWidth = 3;
this.ctx.beginPath();
this.ctx.moveTo(mapFrameX, mapFrameY);
this.ctx.lineTo(mapFrameX, mapFrameY - axisLength);
this.ctx.stroke();
// Y arrow
this.ctx.fillStyle = '#4CAF50';
this.ctx.beginPath();
this.ctx.moveTo(mapFrameX, mapFrameY - axisLength);
this.ctx.lineTo(mapFrameX - 5, mapFrameY - axisLength + 8);
this.ctx.lineTo(mapFrameX + 5, mapFrameY - axisLength + 8);
this.ctx.closePath();
this.ctx.fill();
// Y label
this.ctx.fillStyle = '#4CAF50';
this.ctx.font = 'bold 12px Arial';
this.ctx.fillText('Y', mapFrameX + 8, mapFrameY - axisLength - 5);
},
drawWorldCoordinateAxes: function() {
if (!this.lockedMapOrigin) return; // Only draw if map origin is known
const { mapFrameX, mapFrameY } = this.getFramePositions();
const originXY = this.getMapOriginXY();
// Calculate world (0,0) position in canvas coordinates
// Map origin is the world position of map (0,0), so world (0,0) = map (0,0) - origin
// In canvas: worldX = mapFrameX + (-originX) * scale, worldY = mapFrameY - (-originY) * scale
const worldOriginX = mapFrameX - (originXY.x * this.scale);
const worldOriginY = mapFrameY + (originXY.y * this.scale);
const axisLength = 50;
// Origin marker (blue for world frame)
this.ctx.fillStyle = '#2196F3';
this.ctx.strokeStyle = '#1976D2';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.arc(worldOriginX, worldOriginY, 5, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.stroke();
// X axis (red, dashed for world frame)
this.ctx.strokeStyle = '#F44336';
this.ctx.lineWidth = 2.5;
this.ctx.setLineDash([5, 5]);
this.ctx.beginPath();
this.ctx.moveTo(worldOriginX, worldOriginY);
this.ctx.lineTo(worldOriginX + axisLength, worldOriginY);
this.ctx.stroke();
this.ctx.setLineDash([]);
// X arrow
this.ctx.fillStyle = '#F44336';
this.ctx.beginPath();
this.ctx.moveTo(worldOriginX + axisLength, worldOriginY);
this.ctx.lineTo(worldOriginX + axisLength - 8, worldOriginY - 5);
this.ctx.lineTo(worldOriginX + axisLength - 8, worldOriginY + 5);
this.ctx.closePath();
this.ctx.fill();
// X label
this.ctx.fillStyle = '#F44336';
this.ctx.font = 'bold 11px Arial';
this.ctx.fillText('X_world', worldOriginX + axisLength + 8, worldOriginY + 5);
// Y axis (green, dashed for world frame)
this.ctx.strokeStyle = '#4CAF50';
this.ctx.lineWidth = 2.5;
this.ctx.setLineDash([5, 5]);
this.ctx.beginPath();
this.ctx.moveTo(worldOriginX, worldOriginY);
this.ctx.lineTo(worldOriginX, worldOriginY - axisLength);
this.ctx.stroke();
this.ctx.setLineDash([]);
// Y arrow
this.ctx.fillStyle = '#4CAF50';
this.ctx.beginPath();
this.ctx.moveTo(worldOriginX, worldOriginY - axisLength);
this.ctx.lineTo(worldOriginX - 5, worldOriginY - axisLength + 8);
this.ctx.lineTo(worldOriginX + 5, worldOriginY - axisLength + 8);
this.ctx.closePath();
this.ctx.fill();
// Y label
this.ctx.fillStyle = '#4CAF50';
this.ctx.font = 'bold 11px Arial';
this.ctx.fillText('Y_world', worldOriginX + 8, worldOriginY - axisLength - 5);
},
/**
* Set zoom level (scale)
*/
setZoom: function(newScale) {
this.scale = Math.max(1, Math.min(100, newScale)); // Clamp between 1 and 100
this.render();
},
/**
* Zoom in (increase scale by 20%)
*/
zoomIn: function() {
const newScale = this.scale * 1.2;
if (newScale <= 100) {
this.scale = newScale;
this.render();
}
},
/**
* Zoom out (decrease scale by 20%)
*/
zoomOut: function() {
const newScale = this.scale / 1.2;
if (newScale >= 1) {
this.scale = newScale;
this.render();
}
},
/**
* Reset zoom to default
*/
resetZoom: function() {
this.scale = 20;
this.render();
},
/**
* Reset view to default (zoom + pan)
*/
resetView: function() {
this.offsetX = 0;
this.offsetY = 0;
this.scale = 20;
this.render();
},
/**
* Reset locked values (call when loading a new map)
*/
resetLocks: function() {
this.lockedMapOrigin = null;
this.lockedOffsetX = null;
this.lockedOffsetY = null;
this.costMapData = null;
this.costMapCacheReady = false;
this.costMapPoseAnchor = null;
this.costMapOdomAnchor = null;
this.costMapFixedYaw = null;
this.costMapMapFromOdomLock = null;
this.costMapLocalizationActive = false;
this.costMapLastMatchingScore = -1;
},
/**
* Get current view state (for debugging/verification)
* Returns JSON string for C# deserialization
*/
getViewState: function() {
const state = {
offsetX: this.offsetX,
offsetY: this.offsetY,
scale: this.scale,
lockedMapOrigin: this.lockedMapOrigin,
gridMapOrigin: this.gridMap?.origin
};
return JSON.stringify(state);
},
/**
* Set view offsets (for restoring after verification)
*/
setViewOffsets: function(offsetX, offsetY) {
this.offsetX = Number(offsetX);
this.offsetY = Number(offsetY);
// CRITICAL: If locked offsets exist, update them to match (for consistency)
if (this.lockedOffsetX !== null && this.lockedOffsetY !== null) {
this.lockedOffsetX = this.offsetX;
this.lockedOffsetY = this.offsetY;
}
this.requestRender();
}
};
// Global wrapper functions for JSInterop debugging
function debugSetGlobalPathData(pathPoints) {
console.log('🎯 Global wrapper function called: debugSetGlobalPathData');
console.log(' Received:', pathPoints);
if (window.xlocMapRenderer) {
console.log(' ✅ xlocMapRenderer exists, calling setGlobalPathData...');
return window.xlocMapRenderer.setGlobalPathData(pathPoints);
} else {
console.error(' ❌ xlocMapRenderer NOT FOUND!');
return null;
}
}