feat(webui): SLAM map viewer with occupancy grid (#250) #259
@ -1,298 +1,446 @@
|
||||
/**
|
||||
* MapViewer.jsx — 2D occupancy grid + robot pose + Nav2 path overlay.
|
||||
* MapViewer.jsx — SLAM occupancy grid visualization with robot position overlay
|
||||
*
|
||||
* Topics:
|
||||
* /map (nav_msgs/OccupancyGrid) — SLAM/static map
|
||||
* /odom (nav_msgs/Odometry) — robot position & heading
|
||||
* /outdoor/route (nav_msgs/Path) — Nav2 / OSM route path
|
||||
*
|
||||
* NOTE: OccupancyGrid data can be large (384×384 = 150K cells).
|
||||
* We decode on a worker-free canvas; map refreshes at topic rate
|
||||
* (typically 0.1–1 Hz from SLAM), odom at ~10 Hz.
|
||||
* 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, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const CELL_COLORS = {
|
||||
unknown: '#1a1a2e',
|
||||
free: '#0a1020',
|
||||
occ: '#00ffff33',
|
||||
occFull: '#00b8d9',
|
||||
};
|
||||
// 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 quatToYaw(o) {
|
||||
return Math.atan2(2 * (o.w * o.z + o.x * o.y), 1 - 2 * (o.y * o.y + o.z * o.z));
|
||||
}
|
||||
|
||||
export function MapViewer({ subscribe }) {
|
||||
function MapViewer({ subscribe }) {
|
||||
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);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
const render = useCallback(() => {
|
||||
// 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;
|
||||
const map = mapRef.current;
|
||||
if (!canvas || !map) return;
|
||||
if (!canvas || !mapDataRef.current) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = CELL_COLORS.unknown;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#1f2937';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const { data, info } = map;
|
||||
const mW = info.width, mH = info.height;
|
||||
const res = info.resolution; // m/cell
|
||||
const cellPx = zoom; // 1 map cell = zoom pixels
|
||||
const map = mapDataRef.current;
|
||||
const cellSize = Math.max(1, map.resolution * zoom);
|
||||
|
||||
// Canvas centre
|
||||
const cx = W / 2 + pan.x;
|
||||
const cy = H / 2 + pan.y;
|
||||
// Calculate offset for centering and panning
|
||||
const originX = map.origin.position.x;
|
||||
const originY = map.origin.position.y;
|
||||
|
||||
// Map origin (bottom-left in world) → we flip y for canvas
|
||||
const ox = info.origin.position.x;
|
||||
const oy = info.origin.position.y;
|
||||
const centerX = width / 2 + panX;
|
||||
const centerY = height / 2 + panY;
|
||||
|
||||
// Draw map cells in chunks
|
||||
const img = ctx.createImageData(W, H);
|
||||
const imgData = img.data;
|
||||
// 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;
|
||||
|
||||
for (let r = 0; r < mH; r++) {
|
||||
for (let c = 0; c < mW; c++) {
|
||||
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);
|
||||
// Convert grid coordinates to world coordinates
|
||||
const worldX = originX + x * map.resolution;
|
||||
const worldY = originY + y * map.resolution;
|
||||
|
||||
if (px < 0 || px >= W || py < 0 || py >= H) continue;
|
||||
// Project to canvas
|
||||
const canvasX = centerX + (worldX - robotPoseRef.current.x) * zoom;
|
||||
const canvasY = centerY - (worldY - robotPoseRef.current.y) * zoom;
|
||||
|
||||
let ro, go, bo, ao;
|
||||
if (val < 0) { ro=26; go=26; bo=46; ao=255; } // unknown
|
||||
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
|
||||
// Only draw cells on screen
|
||||
if (canvasX >= -cellSize && canvasX <= width &&
|
||||
canvasY >= -cellSize && canvasY <= height) {
|
||||
|
||||
const i = (py * W + px) * 4;
|
||||
imgData[i] = ro; imgData[i+1] = go;
|
||||
imgData[i+2] = bo; imgData[i+3] = ao;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
|
||||
// ── Nav2 path ─────────────────────────────────────────────────────────
|
||||
const path = pathRef.current;
|
||||
if (path.length >= 2) {
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
// Draw robot trail
|
||||
if (robotTrailRef.current.length > 1) {
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.beginPath();
|
||||
path.forEach(({ x, y }, i) => {
|
||||
const px = cx + x * cellPx / res;
|
||||
const py = cy - y * cellPx / res;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
});
|
||||
|
||||
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.setLineDash([]);
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
// ── Robot ────────────────────────────────────────────────────────────
|
||||
const odom = odomRef.current;
|
||||
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;
|
||||
// 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.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.arc(centerX, centerY, robotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Heading dot
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen, 3, 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;
|
||||
|
||||
// ── 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.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by); ctx.lineTo(bx + scalePx, by);
|
||||
ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
|
||||
ctx.moveTo(bx + scalePx, by - 4); ctx.lineTo(bx + scalePx, by + 4);
|
||||
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();
|
||||
ctx.fillStyle = '#06b6d4';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`${scaleM}m`, bx, by - 6);
|
||||
}
|
||||
}, [zoom, pan, mapInfo]);
|
||||
|
||||
// ── Subscribe /map ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const unsub = subscribe('/map', 'nav_msgs/OccupancyGrid', (msg) => {
|
||||
mapRef.current = msg;
|
||||
setMapInfo(msg.info);
|
||||
render();
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribe, render]);
|
||||
// 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]);
|
||||
|
||||
// ── Subscribe /odom ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const unsub = subscribe('/odom', 'nav_msgs/Odometry', (msg) => {
|
||||
const p = msg.pose.pose.position;
|
||||
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]);
|
||||
|
||||
// ── Subscribe /outdoor/route (Nav2 / OSM path) ────────────────────────────
|
||||
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]);
|
||||
|
||||
// Re-render when zoom/pan changes
|
||||
useEffect(() => { render(); }, [zoom, pan, render]);
|
||||
|
||||
// ── Mouse pan ─────────────────────────────────────────────────────────────
|
||||
const onMouseDown = (e) => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; };
|
||||
const onMouseMove = (e) => {
|
||||
if (!dragging.current) return;
|
||||
setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
|
||||
// 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)));
|
||||
};
|
||||
const onMouseUp = () => { dragging.current = null; };
|
||||
|
||||
// Touch pan
|
||||
const touchRef = useRef(null);
|
||||
const onTouchStart = (e) => {
|
||||
const t = e.touches[0];
|
||||
touchRef.current = { x: t.clientX - pan.x, y: t.clientY - pan.y };
|
||||
// Mouse dragging for pan
|
||||
const handleMouseDown = (e) => {
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
const onTouchMove = (e) => {
|
||||
if (!touchRef.current) return;
|
||||
const t = e.touches[0];
|
||||
setPan({ x: t.clientX - touchRef.current.x, y: t.clientY - touchRef.current.y });
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">MAP VIEWER</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<button onClick={() => setZoom(z => Math.min(z * 1.5, 20))}
|
||||
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+</button>
|
||||
<span className="text-gray-500 text-xs w-10 text-center">{zoom.toFixed(1)}x</span>
|
||||
<button onClick={() => setZoom(z => Math.max(z / 1.5, 0.2))}
|
||||
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">−</button>
|
||||
<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>
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
|
||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||
MAP VIEWER (SLAM)
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
Zoom: {zoom.toFixed(2)}× | Trail: {robotTrail.length} points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
|
||||
<div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={600}
|
||||
height={400}
|
||||
className="w-full cursor-grab active:cursor-grabbing block"
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={() => { touchRef.current = null; }}
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-full cursor-grab active:cursor-grabbing"
|
||||
onWheel={handleWheel}
|
||||
style={{ userSelect: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||
<div className="text-gray-600">MAP SIZE</div>
|
||||
<div className="text-cyan-400 font-bold">
|
||||
{mapInfo ? `${mapInfo.width}×${mapInfo.height}` : '—'}
|
||||
{/* Info panels */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||
{/* Map info */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1">
|
||||
<div className="text-cyan-700 font-bold">MAP</div>
|
||||
{mapInfo ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Size:</span>
|
||||
<span className="text-gray-400">{mapInfo.width} × {mapInfo.height} cells</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Resolution:</span>
|
||||
<span className="text-gray-400">{mapInfo.resolution} m/cell</span>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||
<div className="text-gray-600">RESOLUTION</div>
|
||||
<div className="text-cyan-400 font-bold">
|
||||
{mapInfo ? `${mapInfo.resolution.toFixed(2)}m/cell` : '—'}
|
||||
<div className="flex justify-between">
|
||||
<span>Area:</span>
|
||||
<span className="text-gray-400">{mapInfo.area} m²</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-700">Waiting for map...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||
<div className="text-gray-600">ROBOT POS</div>
|
||||
<div className="text-orange-400 font-bold">
|
||||
{odomPose ? `${odomPose.x.toFixed(2)}, ${odomPose.y.toFixed(2)}` : '—'}
|
||||
|
||||
{/* Robot info */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1">
|
||||
<div className="text-cyan-700 font-bold">ROBOT POSE</div>
|
||||
{robotInfo ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>X:</span>
|
||||
<span className="text-cyan-400">{robotInfo.x} m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Y:</span>
|
||||
<span className="text-cyan-400">{robotInfo.y} m</span>
|
||||
</div>
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||
<div className="text-gray-600">HEADING</div>
|
||||
<div className="text-orange-400 font-bold">
|
||||
{odomPose ? `${(odomPose.yaw * 180 / Math.PI).toFixed(1)}°` : '—'}
|
||||
<div className="flex justify-between">
|
||||
<span>Heading:</span>
|
||||
<span className="text-cyan-400">{robotInfo.theta}°</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-700">Waiting for odometry...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#1a1a2e' }} />Unknown
|
||||
{/* Topic info */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Map Topic:</span>
|
||||
<span className="text-gray-500">/map (nav_msgs/OccupancyGrid)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#0a1020' }} />Free
|
||||
<div className="flex justify-between">
|
||||
<span>Odometry Topic:</span>
|
||||
<span className="text-gray-500">/odom (nav_msgs/Odometry)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#00b8d9' }} />Occupied
|
||||
</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 className="flex justify-between">
|
||||
<span>Legend:</span>
|
||||
<span className="text-gray-500">⬜ Free | 🟦 Unknown | ⬛ Occupied | 🔵 Robot</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MapViewer };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user