Compare commits

..

No commits in common. "03999e7f91e47293c22e84f8254e9fd6c0210b4e" and "5e405042972a6cb08bddd9848cf70ef5b321d38d" have entirely different histories.

View File

@ -1,446 +1,298 @@
/** /**
* MapViewer.jsx SLAM occupancy grid visualization with robot position overlay * MapViewer.jsx 2D occupancy grid + robot pose + Nav2 path overlay.
* *
* Features: * Topics:
* - Subscribes to /map (nav_msgs/OccupancyGrid) for SLAM occupancy data * /map (nav_msgs/OccupancyGrid) SLAM/static map
* - Real-time robot position from /odom (nav_msgs/Odometry) * /odom (nav_msgs/Odometry) robot position & heading
* - Canvas-based rendering with efficient grid visualization * /outdoor/route (nav_msgs/Path) Nav2 / OSM route path
* - Pan/zoom controls with mouse/touch support *
* - Costmap color coding: white=free, gray=unknown, black=occupied * NOTE: OccupancyGrid data can be large (384×384 = 150K cells).
* - Robot position overlay with heading indicator * We decode on a worker-free canvas; map refreshes at topic rate
* - Trail visualization of recent robot positions * (typically 0.11 Hz from SLAM), odom at ~10 Hz.
* - Grid statistics (resolution, size, robot location)
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
// Zoom and pan constants const CELL_COLORS = {
const MIN_ZOOM = 0.5; unknown: '#1a1a2e',
const MAX_ZOOM = 10; free: '#0a1020',
const ZOOM_SPEED = 0.1; occ: '#00ffff33',
const MAX_TRAIL_LENGTH = 300; // ~30 seconds at 10Hz occFull: '#00b8d9',
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) function quatToYaw(o) {
useEffect(() => { return Math.atan2(2 * (o.w * o.z + o.x * o.y), 1 - 2 * (o.y * o.y + o.z * o.z));
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 export function MapViewer({ subscribe }) {
useEffect(() => { const canvasRef = useRef(null);
const mapRef = useRef(null); // last OccupancyGrid info
const odomRef = useRef(null); // last robot pose {x,y,yaw}
const pathRef = useRef([]); // [{x,y}] path points in map coords
const [mapInfo, setMapInfo] = useState(null);
const [odomPose, setOdomPose] = useState(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragging = useRef(null);
// Render
const render = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || !mapDataRef.current) return; const map = mapRef.current;
if (!canvas || !map) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const width = canvas.width; const W = canvas.width, H = canvas.height;
const height = canvas.height;
// Clear canvas ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1f2937'; ctx.fillStyle = CELL_COLORS.unknown;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, W, H);
const map = mapDataRef.current; const { data, info } = map;
const cellSize = Math.max(1, map.resolution * zoom); const mW = info.width, mH = info.height;
const res = info.resolution; // m/cell
const cellPx = zoom; // 1 map cell = zoom pixels
// Calculate offset for centering and panning // Canvas centre
const originX = map.origin.position.x; const cx = W / 2 + pan.x;
const originY = map.origin.position.y; const cy = H / 2 + pan.y;
const centerX = width / 2 + panX; // Map origin (bottom-left in world) we flip y for canvas
const centerY = height / 2 + panY; const ox = info.origin.position.x;
const oy = info.origin.position.y;
// Draw occupancy grid // Draw map cells in chunks
for (let y = 0; y < map.height; y++) { const img = ctx.createImageData(W, H);
for (let x = 0; x < map.width; x++) { const imgData = img.data;
const idx = y * map.width + x;
const cell = map.data[idx] ?? -1;
// Convert grid coordinates to world coordinates for (let r = 0; r < mH; r++) {
const worldX = originX + x * map.resolution; for (let c = 0; c < mW; c++) {
const worldY = originY + y * map.resolution; const val = data[r * mW + c];
// World coords of this cell centre
const wx = ox + (c + 0.5) * res;
const wy = oy + (r + 0.5) * res;
// Canvas coords (flip y)
const px = Math.round(cx + wx * cellPx / res);
const py = Math.round(cy - wy * cellPx / res);
// Project to canvas if (px < 0 || px >= W || py < 0 || py >= H) continue;
const canvasX = centerX + (worldX - robotPoseRef.current.x) * zoom;
const canvasY = centerY - (worldY - robotPoseRef.current.y) * zoom;
// Only draw cells on screen let ro, go, bo, ao;
if (canvasX >= -cellSize && canvasX <= width && if (val < 0) { ro=26; go=26; bo=46; ao=255; } // unknown
canvasY >= -cellSize && canvasY <= height) { else if (val === 0) { ro=10; go=16; bo=32; ao=255; } // free
else if (val < 60) { ro=0; go=100; bo=120; ao=120; } // low occ
else { ro=0; go=184; bo=217; ao=220; } // occupied
// Color based on occupancy const i = (py * W + px) * 4;
if (cell >= 0 && cell < 25) { imgData[i] = ro; imgData[i+1] = go;
ctx.fillStyle = '#f5f5f5'; // Free space (white) imgData[i+2] = bo; imgData[i+3] = ao;
} 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);
}
} }
} }
ctx.putImageData(img, 0, 0);
// Draw robot trail // Nav2 path
if (robotTrailRef.current.length > 1) { const path = pathRef.current;
ctx.strokeStyle = '#10b981'; if (path.length >= 2) {
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.globalAlpha = 0.6; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.beginPath();
path.forEach(({ x, y }, i) => {
const startPoint = robotTrailRef.current[0]; const px = cx + x * cellPx / res;
const sx = centerX + (startPoint.x - robotPoseRef.current.x) * zoom; const py = cy - y * cellPx / res;
const sy = centerY - (startPoint.y - robotPoseRef.current.y) * zoom; i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
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.stroke();
ctx.globalAlpha = 1.0; ctx.setLineDash([]);
} }
// Draw robot position (at center, always) // Robot
const robotRadius = Math.max(8, 0.2 * zoom); // ~0.2m radius at zoom=1 const odom = odomRef.current;
ctx.fillStyle = '#06b6d4'; if (odom) {
const rx = cx + odom.x * cellPx / res;
const ry = cy - odom.y * cellPx / res;
// Heading arrow
const arrowLen = Math.max(12, cellPx * 1.5);
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, robotRadius, 0, Math.PI * 2); ctx.moveTo(rx, ry);
ctx.lineTo(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen);
ctx.stroke();
// Robot circle
ctx.fillStyle = '#f97316';
ctx.shadowBlur = 8;
ctx.shadowColor = '#f97316';
ctx.beginPath();
ctx.arc(rx, ry, 6, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0;
// Draw robot heading indicator // Heading dot
const headingLen = robotRadius * 2.5; ctx.fillStyle = '#ffffff';
const headX = centerX + Math.cos(robotPoseRef.current.theta) * headingLen; ctx.beginPath();
const headY = centerY - Math.sin(robotPoseRef.current.theta) * headingLen; ctx.arc(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen, 3, 0, Math.PI * 2);
ctx.fill();
}
// Scale bar
if (mapInfo) {
const scaleM = 2; // 2-metre scale bar
const scalePx = scaleM * cellPx / res;
const bx = 12, by = H - 12;
ctx.strokeStyle = '#06b6d4'; ctx.strokeStyle = '#06b6d4';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(centerX, centerY); ctx.moveTo(bx, by); ctx.lineTo(bx + scalePx, by);
ctx.lineTo(headX, headY); ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
ctx.stroke(); ctx.moveTo(bx + scalePx, by - 4); ctx.lineTo(bx + scalePx, by + 4);
// 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(); ctx.stroke();
ctx.fillStyle = '#06b6d4';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText(`${scaleM}m`, bx, by - 6);
} }
}, [zoom, pan, mapInfo]);
// Horizontal lines // Subscribe /map
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(() => { useEffect(() => {
const canvas = canvasRef.current; const unsub = subscribe('/map', 'nav_msgs/OccupancyGrid', (msg) => {
if (!canvas) return; mapRef.current = msg;
setMapInfo(msg.info);
render();
});
return unsub;
}, [subscribe, render]);
canvas.addEventListener('wheel', handleWheel, { passive: false }); // Subscribe /odom
canvas.addEventListener('mousedown', handleMouseDown); useEffect(() => {
canvas.addEventListener('touchstart', handleTouchStart); const unsub = subscribe('/odom', 'nav_msgs/Odometry', (msg) => {
canvas.addEventListener('touchmove', handleTouchMove); const p = msg.pose.pose.position;
canvas.addEventListener('touchend', handleTouchEnd); const o = msg.pose.pose.orientation;
const pose = { x: p.x, y: p.y, yaw: quatToYaw(o) };
odomRef.current = pose;
setOdomPose(pose);
render();
});
return unsub;
}, [subscribe, render]);
window.addEventListener('mousemove', handleMouseMove); // Subscribe /outdoor/route (Nav2 / OSM path)
window.addEventListener('mouseup', handleMouseUp); useEffect(() => {
const unsub = subscribe('/outdoor/route', 'nav_msgs/Path', (msg) => {
pathRef.current = (msg.poses ?? []).map(p => ({
x: p.pose.position.x,
y: p.pose.position.y,
}));
render();
});
return unsub;
}, [subscribe, render]);
return () => { // Re-render when zoom/pan changes
canvas.removeEventListener('wheel', handleWheel); useEffect(() => { render(); }, [zoom, pan, render]);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('touchstart', handleTouchStart); // Mouse pan
canvas.removeEventListener('touchmove', handleTouchMove); const onMouseDown = (e) => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; };
canvas.removeEventListener('touchend', handleTouchEnd); const onMouseMove = (e) => {
window.removeEventListener('mousemove', handleMouseMove); if (!dragging.current) return;
window.removeEventListener('mouseup', handleMouseUp); setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
}; };
}, [isDragging]); const onMouseUp = () => { dragging.current = null; };
const resetView = () => { // Touch pan
setZoom(1); const touchRef = useRef(null);
setPanX(0); const onTouchStart = (e) => {
setPanY(0); const t = e.touches[0];
touchRef.current = { x: t.clientX - pan.x, y: t.clientY - pan.y };
}; };
const onTouchMove = (e) => {
const clearTrail = () => { if (!touchRef.current) return;
setRobotTrail([]); const t = e.touches[0];
robotTrailRef.current = []; setPan({ x: t.clientX - touchRef.current.x, y: t.clientY - touchRef.current.y });
}; };
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 ( return (
<div className="flex flex-col h-full space-y-3"> <div className="space-y-3">
{/* Controls */} {/* Toolbar */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3"> <div className="flex items-center gap-2 flex-wrap">
<div className="flex justify-between items-center flex-wrap gap-2"> <div className="text-cyan-700 text-xs font-bold tracking-widest">MAP VIEWER</div>
<div className="text-cyan-700 text-xs font-bold tracking-widest"> <div className="flex items-center gap-1 ml-auto">
MAP VIEWER (SLAM) <button onClick={() => setZoom(z => Math.min(z * 1.5, 20))}
</div> className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+</button>
<div className="text-gray-600 text-xs"> <span className="text-gray-500 text-xs w-10 text-center">{zoom.toFixed(1)}x</span>
Zoom: {zoom.toFixed(2)}× | Trail: {robotTrail.length} points <button onClick={() => setZoom(z => Math.max(z / 1.5, 0.2))}
</div> className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm"></button>
</div> <button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
className="px-2 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs ml-2">Reset</button>
{/* Control buttons */}
<div className="flex gap-2 flex-wrap">
<button
onClick={resetView}
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-cyan-800 bg-cyan-950 text-cyan-400 hover:bg-cyan-900 transition-colors"
>
RESET VIEW
</button>
<button
onClick={clearTrail}
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-green-800 bg-green-950 text-green-400 hover:bg-green-900 transition-colors"
>
CLEAR TRAIL
</button>
<div className="text-gray-600 text-xs flex items-center">
Scroll: Zoom | Drag: Pan
</div>
</div> </div>
</div> </div>
{/* Canvas */} {/* Canvas */}
<div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden"> <div className="bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={800} width={600}
height={600} height={400}
className="w-full h-full cursor-grab active:cursor-grabbing" className="w-full cursor-grab active:cursor-grabbing block"
onWheel={handleWheel} onMouseDown={onMouseDown}
style={{ userSelect: 'none' }} onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={() => { touchRef.current = null; }}
/> />
</div> </div>
{/* Info panels */} {/* Info bar */}
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
{/* Map info */} <div className="bg-gray-950 rounded border border-gray-800 p-2">
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1"> <div className="text-gray-600">MAP SIZE</div>
<div className="text-cyan-700 font-bold">MAP</div> <div className="text-cyan-400 font-bold">
{mapInfo ? ( {mapInfo ? `${mapInfo.width}×${mapInfo.height}` : '—'}
<>
<div className="flex justify-between">
<span>Size:</span>
<span className="text-gray-400">{mapInfo.width} × {mapInfo.height} cells</span>
</div> </div>
<div className="flex justify-between">
<span>Resolution:</span>
<span className="text-gray-400">{mapInfo.resolution} m/cell</span>
</div> </div>
<div className="flex justify-between"> <div className="bg-gray-950 rounded border border-gray-800 p-2">
<span>Area:</span> <div className="text-gray-600">RESOLUTION</div>
<span className="text-gray-400">{mapInfo.area} </span> <div className="text-cyan-400 font-bold">
{mapInfo ? `${mapInfo.resolution.toFixed(2)}m/cell` : '—'}
</div> </div>
</>
) : (
<div className="text-gray-700">Waiting for map...</div>
)}
</div> </div>
<div className="bg-gray-950 rounded border border-gray-800 p-2">
{/* Robot info */} <div className="text-gray-600">ROBOT POS</div>
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1"> <div className="text-orange-400 font-bold">
<div className="text-cyan-700 font-bold">ROBOT POSE</div> {odomPose ? `${odomPose.x.toFixed(2)}, ${odomPose.y.toFixed(2)}` : '—'}
{robotInfo ? (
<>
<div className="flex justify-between">
<span>X:</span>
<span className="text-cyan-400">{robotInfo.x} m</span>
</div> </div>
<div className="flex justify-between">
<span>Y:</span>
<span className="text-cyan-400">{robotInfo.y} m</span>
</div> </div>
<div className="flex justify-between"> <div className="bg-gray-950 rounded border border-gray-800 p-2">
<span>Heading:</span> <div className="text-gray-600">HEADING</div>
<span className="text-cyan-400">{robotInfo.theta}°</span> <div className="text-orange-400 font-bold">
{odomPose ? `${(odomPose.yaw * 180 / Math.PI).toFixed(1)}°` : '—'}
</div> </div>
</>
) : (
<div className="text-gray-700">Waiting for odometry...</div>
)}
</div> </div>
</div> </div>
{/* Topic info */} {/* Legend */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1"> <div className="flex gap-4 text-xs text-gray-600">
<div className="flex justify-between"> <div className="flex items-center gap-1">
<span>Map Topic:</span> <div className="w-3 h-3 rounded-sm" style={{ background: '#1a1a2e' }} />Unknown
<span className="text-gray-500">/map (nav_msgs/OccupancyGrid)</span>
</div> </div>
<div className="flex justify-between"> <div className="flex items-center gap-1">
<span>Odometry Topic:</span> <div className="w-3 h-3 rounded-sm" style={{ background: '#0a1020' }} />Free
<span className="text-gray-500">/odom (nav_msgs/Odometry)</span>
</div> </div>
<div className="flex justify-between"> <div className="flex items-center gap-1">
<span>Legend:</span> <div className="w-3 h-3 rounded-sm" style={{ background: '#00b8d9' }} />Occupied
<span className="text-gray-500"> Free | 🟦 Unknown | Occupied | 🔵 Robot</span> </div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm" style={{ background: '#f59e0b' }} />Path
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-full" style={{ background: '#f97316' }} />Robot
</div> </div>
</div> </div>
</div> </div>
); );
} }
export { MapViewer };