/** * MapViewer.jsx — SLAM occupancy grid visualization with robot position overlay * * Features: * - Subscribes to /map (nav_msgs/OccupancyGrid) for SLAM occupancy data * - Real-time robot position from /odom (nav_msgs/Odometry) * - Canvas-based rendering with efficient grid visualization * - Pan/zoom controls with mouse/touch support * - Costmap color coding: white=free, gray=unknown, black=occupied * - Robot position overlay with heading indicator * - Trail visualization of recent robot positions * - Grid statistics (resolution, size, robot location) */ import { useEffect, useRef, useState } from 'react'; // Zoom and pan constants const MIN_ZOOM = 0.5; const MAX_ZOOM = 10; const ZOOM_SPEED = 0.1; const MAX_TRAIL_LENGTH = 300; // ~30 seconds at 10Hz function MapViewer({ subscribe }) { const canvasRef = useRef(null); const containerRef = useRef(null); // Map data const [mapData, setMapData] = useState(null); const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 }); const [robotTrail, setRobotTrail] = useState([]); // View controls const [zoom, setZoom] = useState(1); const [panX, setPanX] = useState(0); const [panY, setPanY] = useState(0); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef({ x: 0, y: 0 }); // Refs for tracking state const mapDataRef = useRef(null); const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 }); const robotTrailRef = useRef([]); // Subscribe to occupancy grid (SLAM map) useEffect(() => { const unsubMap = subscribe( '/map', 'nav_msgs/OccupancyGrid', (msg) => { try { const mapInfo = { width: msg.info.width, height: msg.info.height, resolution: msg.info.resolution, origin: msg.info.origin, data: msg.data || new Uint8Array(msg.info.width * msg.info.height), }; setMapData(mapInfo); mapDataRef.current = mapInfo; } catch (e) { console.error('Error parsing map data:', e); } } ); return unsubMap; }, [subscribe]); // Subscribe to robot odometry (pose) useEffect(() => { const unsubOdom = subscribe( '/odom', 'nav_msgs/Odometry', (msg) => { try { const pos = msg.pose.pose.position; const ori = msg.pose.pose.orientation; // Convert quaternion to yaw angle const siny_cosp = 2 * (ori.w * ori.z + ori.x * ori.y); const cosy_cosp = 1 - 2 * (ori.y * ori.y + ori.z * ori.z); const theta = Math.atan2(siny_cosp, cosy_cosp); const newPose = { x: pos.x, y: pos.y, theta }; setRobotPose(newPose); robotPoseRef.current = newPose; // Add to trail setRobotTrail((prev) => { const updated = [...prev, { x: pos.x, y: pos.y }]; if (updated.length > MAX_TRAIL_LENGTH) { updated.shift(); } robotTrailRef.current = updated; return updated; }); } catch (e) { console.error('Error parsing odometry data:', e); } } ); return unsubOdom; }, [subscribe]); // Canvas rendering useEffect(() => { const canvas = canvasRef.current; if (!canvas || !mapDataRef.current) return; const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; // Clear canvas ctx.fillStyle = '#1f2937'; ctx.fillRect(0, 0, width, height); const map = mapDataRef.current; const cellSize = Math.max(1, map.resolution * zoom); // Calculate offset for centering and panning const originX = map.origin.position.x; const originY = map.origin.position.y; const centerX = width / 2 + panX; const centerY = height / 2 + panY; // Draw occupancy grid for (let y = 0; y < map.height; y++) { for (let x = 0; x < map.width; x++) { const idx = y * map.width + x; const cell = map.data[idx] ?? -1; // Convert grid coordinates to world coordinates const worldX = originX + x * map.resolution; const worldY = originY + y * map.resolution; // Project to canvas const canvasX = centerX + (worldX - robotPoseRef.current.x) * zoom; const canvasY = centerY - (worldY - robotPoseRef.current.y) * zoom; // Only draw cells on screen if (canvasX >= -cellSize && canvasX <= width && canvasY >= -cellSize && canvasY <= height) { // Color based on occupancy if (cell >= 0 && cell < 25) { ctx.fillStyle = '#f5f5f5'; // Free space (white) } else if (cell >= 25 && cell < 65) { ctx.fillStyle = '#a0a0a0'; // Unknown/gray } else if (cell >= 65) { ctx.fillStyle = '#000000'; // Occupied (black) } else { ctx.fillStyle = '#4b5563'; // Unknown (-1) } ctx.fillRect(canvasX, canvasY, cellSize, cellSize); } } } // Draw robot trail if (robotTrailRef.current.length > 1) { ctx.strokeStyle = '#10b981'; ctx.lineWidth = 2; ctx.globalAlpha = 0.6; ctx.beginPath(); const startPoint = robotTrailRef.current[0]; const sx = centerX + (startPoint.x - robotPoseRef.current.x) * zoom; const sy = centerY - (startPoint.y - robotPoseRef.current.y) * zoom; ctx.moveTo(sx, sy); for (let i = 1; i < robotTrailRef.current.length; i++) { const point = robotTrailRef.current[i]; const px = centerX + (point.x - robotPoseRef.current.x) * zoom; const py = centerY - (point.y - robotPoseRef.current.y) * zoom; ctx.lineTo(px, py); } ctx.stroke(); ctx.globalAlpha = 1.0; } // Draw robot position (at center, always) const robotRadius = Math.max(8, 0.2 * zoom); // ~0.2m radius at zoom=1 ctx.fillStyle = '#06b6d4'; ctx.beginPath(); ctx.arc(centerX, centerY, robotRadius, 0, Math.PI * 2); ctx.fill(); // Draw robot heading indicator const headingLen = robotRadius * 2.5; const headX = centerX + Math.cos(robotPoseRef.current.theta) * headingLen; const headY = centerY - Math.sin(robotPoseRef.current.theta) * headingLen; ctx.strokeStyle = '#06b6d4'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.lineTo(headX, headY); ctx.stroke(); // Draw grid overlay (every 1 meter) ctx.strokeStyle = '#374151'; ctx.lineWidth = 0.5; ctx.globalAlpha = 0.3; const gridSpacing = 1 * zoom; // 1 meter grid for (let i = -100; i <= 100; i++) { const x = centerX + i * gridSpacing; const y = centerY + i * gridSpacing; // Vertical lines if (x > 0 && x < width) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } // Horizontal lines if (y > 0 && y < height) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } } ctx.globalAlpha = 1.0; }, [zoom, panX, panY]); // Mouse wheel zoom const handleWheel = (e) => { e.preventDefault(); const delta = e.deltaY > 0 ? -ZOOM_SPEED : ZOOM_SPEED; setZoom((prev) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, prev + delta))); }; // Mouse dragging for pan const handleMouseDown = (e) => { setIsDragging(true); dragStartRef.current = { x: e.clientX, y: e.clientY }; }; const handleMouseMove = (e) => { if (!isDragging) return; const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; setPanX((prev) => prev + dx); setPanY((prev) => prev + dy); dragStartRef.current = { x: e.clientX, y: e.clientY }; }; const handleMouseUp = () => { setIsDragging(false); }; // Touch support for mobile const handleTouchStart = (e) => { if (e.touches.length === 1) { setIsDragging(true); dragStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; } }; const handleTouchMove = (e) => { if (!isDragging || e.touches.length !== 1) return; const dx = e.touches[0].clientX - dragStartRef.current.x; const dy = e.touches[0].clientY - dragStartRef.current.y; setPanX((prev) => prev + dx); setPanY((prev) => prev + dy); dragStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }; const handleTouchEnd = () => { setIsDragging(false); }; // Setup event listeners useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; canvas.addEventListener('wheel', handleWheel, { passive: false }); canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('touchstart', handleTouchStart); canvas.addEventListener('touchmove', handleTouchMove); canvas.addEventListener('touchend', handleTouchEnd); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('mousedown', handleMouseDown); canvas.removeEventListener('touchstart', handleTouchStart); canvas.removeEventListener('touchmove', handleTouchMove); canvas.removeEventListener('touchend', handleTouchEnd); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging]); const resetView = () => { setZoom(1); setPanX(0); setPanY(0); }; const clearTrail = () => { setRobotTrail([]); robotTrailRef.current = []; }; const mapInfo = mapData ? { width: mapData.width, height: mapData.height, area: (mapData.width * mapData.height * mapData.resolution * mapData.resolution).toFixed(1), resolution: mapData.resolution.toFixed(3), } : null; const robotInfo = robotPose ? { x: robotPose.x.toFixed(2), y: robotPose.y.toFixed(2), theta: (robotPose.theta * 180 / Math.PI).toFixed(1), } : null; return (
{/* Controls */}
MAP VIEWER (SLAM)
Zoom: {zoom.toFixed(2)}× | Trail: {robotTrail.length} points
{/* Control buttons */}
Scroll: Zoom | Drag: Pan
{/* Canvas */}
{/* Info panels */}
{/* Map info */}
MAP
{mapInfo ? ( <>
Size: {mapInfo.width} × {mapInfo.height} cells
Resolution: {mapInfo.resolution} m/cell
Area: {mapInfo.area} m²
) : (
Waiting for map...
)}
{/* Robot info */}
ROBOT POSE
{robotInfo ? ( <>
X: {robotInfo.x} m
Y: {robotInfo.y} m
Heading: {robotInfo.theta}°
) : (
Waiting for odometry...
)}
{/* Topic info */}
Map Topic: /map (nav_msgs/OccupancyGrid)
Odometry Topic: /odom (nav_msgs/Odometry)
Legend: ⬜ Free | 🟦 Unknown | ⬛ Occupied | 🔵 Robot
); } export { MapViewer };