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:
|
* Features:
|
||||||
* /map (nav_msgs/OccupancyGrid) — SLAM/static map
|
* - Subscribes to /map (nav_msgs/OccupancyGrid) for SLAM occupancy data
|
||||||
* /odom (nav_msgs/Odometry) — robot position & heading
|
* - Real-time robot position from /odom (nav_msgs/Odometry)
|
||||||
* /outdoor/route (nav_msgs/Path) — Nav2 / OSM route path
|
* - Canvas-based rendering with efficient grid visualization
|
||||||
*
|
* - Pan/zoom controls with mouse/touch support
|
||||||
* NOTE: OccupancyGrid data can be large (384×384 = 150K cells).
|
* - Costmap color coding: white=free, gray=unknown, black=occupied
|
||||||
* We decode on a worker-free canvas; map refreshes at topic rate
|
* - Robot position overlay with heading indicator
|
||||||
* (typically 0.1–1 Hz from SLAM), odom at ~10 Hz.
|
* - 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 = {
|
// Zoom and pan constants
|
||||||
unknown: '#1a1a2e',
|
const MIN_ZOOM = 0.5;
|
||||||
free: '#0a1020',
|
const MAX_ZOOM = 10;
|
||||||
occ: '#00ffff33',
|
const ZOOM_SPEED = 0.1;
|
||||||
occFull: '#00b8d9',
|
const MAX_TRAIL_LENGTH = 300; // ~30 seconds at 10Hz
|
||||||
};
|
|
||||||
|
|
||||||
function quatToYaw(o) {
|
function MapViewer({ subscribe }) {
|
||||||
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 }) {
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const mapRef = useRef(null); // last OccupancyGrid info
|
const containerRef = useRef(null);
|
||||||
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 ────────────────────────────────────────────────────────────────
|
// Map data
|
||||||
const render = useCallback(() => {
|
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 canvas = canvasRef.current;
|
||||||
const map = mapRef.current;
|
if (!canvas || !mapDataRef.current) return;
|
||||||
if (!canvas || !map) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
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);
|
// Clear canvas
|
||||||
ctx.fillStyle = CELL_COLORS.unknown;
|
ctx.fillStyle = '#1f2937';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
const { data, info } = map;
|
const map = mapDataRef.current;
|
||||||
const mW = info.width, mH = info.height;
|
const cellSize = Math.max(1, map.resolution * zoom);
|
||||||
const res = info.resolution; // m/cell
|
|
||||||
const cellPx = zoom; // 1 map cell = zoom pixels
|
|
||||||
|
|
||||||
// Canvas centre
|
// Calculate offset for centering and panning
|
||||||
const cx = W / 2 + pan.x;
|
const originX = map.origin.position.x;
|
||||||
const cy = H / 2 + pan.y;
|
const originY = map.origin.position.y;
|
||||||
|
|
||||||
// Map origin (bottom-left in world) → we flip y for canvas
|
const centerX = width / 2 + panX;
|
||||||
const ox = info.origin.position.x;
|
const centerY = height / 2 + panY;
|
||||||
const oy = info.origin.position.y;
|
|
||||||
|
|
||||||
// Draw map cells in chunks
|
// Draw occupancy grid
|
||||||
const img = ctx.createImageData(W, H);
|
for (let y = 0; y < map.height; y++) {
|
||||||
const imgData = img.data;
|
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++) {
|
// Convert grid coordinates to world coordinates
|
||||||
for (let c = 0; c < mW; c++) {
|
const worldX = originX + x * map.resolution;
|
||||||
const val = data[r * mW + c];
|
const worldY = originY + y * map.resolution;
|
||||||
// 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);
|
|
||||||
|
|
||||||
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;
|
// Only draw cells on screen
|
||||||
if (val < 0) { ro=26; go=26; bo=46; ao=255; } // unknown
|
if (canvasX >= -cellSize && canvasX <= width &&
|
||||||
else if (val === 0) { ro=10; go=16; bo=32; ao=255; } // free
|
canvasY >= -cellSize && canvasY <= height) {
|
||||||
else if (val < 60) { ro=0; go=100; bo=120; ao=120; } // low occ
|
|
||||||
else { ro=0; go=184; bo=217; ao=220; } // occupied
|
|
||||||
|
|
||||||
const i = (py * W + px) * 4;
|
// Color based on occupancy
|
||||||
imgData[i] = ro; imgData[i+1] = go;
|
if (cell >= 0 && cell < 25) {
|
||||||
imgData[i+2] = bo; imgData[i+3] = ao;
|
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 ─────────────────────────────────────────────────────────
|
// Draw robot trail
|
||||||
const path = pathRef.current;
|
if (robotTrailRef.current.length > 1) {
|
||||||
if (path.length >= 2) {
|
ctx.strokeStyle = '#10b981';
|
||||||
ctx.strokeStyle = '#f59e0b';
|
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.setLineDash([4, 4]);
|
ctx.globalAlpha = 0.6;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
path.forEach(({ x, y }, i) => {
|
|
||||||
const px = cx + x * cellPx / res;
|
const startPoint = robotTrailRef.current[0];
|
||||||
const py = cy - y * cellPx / res;
|
const sx = centerX + (startPoint.x - robotPoseRef.current.x) * zoom;
|
||||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
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.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Robot ────────────────────────────────────────────────────────────
|
// Draw robot position (at center, always)
|
||||||
const odom = odomRef.current;
|
const robotRadius = Math.max(8, 0.2 * zoom); // ~0.2m radius at zoom=1
|
||||||
if (odom) {
|
ctx.fillStyle = '#06b6d4';
|
||||||
const rx = cx + odom.x * cellPx / res;
|
ctx.beginPath();
|
||||||
const ry = cy - odom.y * cellPx / res;
|
ctx.arc(centerX, centerY, robotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
// Heading arrow
|
// Draw robot heading indicator
|
||||||
const arrowLen = Math.max(12, cellPx * 1.5);
|
const headingLen = robotRadius * 2.5;
|
||||||
ctx.strokeStyle = '#f97316';
|
const headX = centerX + Math.cos(robotPoseRef.current.theta) * headingLen;
|
||||||
ctx.lineWidth = 2;
|
const headY = centerY - Math.sin(robotPoseRef.current.theta) * headingLen;
|
||||||
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.strokeStyle = '#06b6d4';
|
||||||
ctx.fillStyle = '#f97316';
|
ctx.lineWidth = 2;
|
||||||
ctx.shadowBlur = 8;
|
ctx.beginPath();
|
||||||
ctx.shadowColor = '#f97316';
|
ctx.moveTo(centerX, centerY);
|
||||||
ctx.beginPath();
|
ctx.lineTo(headX, headY);
|
||||||
ctx.arc(rx, ry, 6, 0, Math.PI * 2);
|
ctx.stroke();
|
||||||
ctx.fill();
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
// Heading dot
|
// Draw grid overlay (every 1 meter)
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.strokeStyle = '#374151';
|
||||||
ctx.beginPath();
|
ctx.lineWidth = 0.5;
|
||||||
ctx.arc(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen, 3, 0, Math.PI * 2);
|
ctx.globalAlpha = 0.3;
|
||||||
ctx.fill();
|
|
||||||
|
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]);
|
||||||
|
|
||||||
// ── Scale bar ─────────────────────────────────────────────────────────
|
// Mouse wheel zoom
|
||||||
if (mapInfo) {
|
const handleWheel = (e) => {
|
||||||
const scaleM = 2; // 2-metre scale bar
|
e.preventDefault();
|
||||||
const scalePx = scaleM * cellPx / res;
|
const delta = e.deltaY > 0 ? -ZOOM_SPEED : ZOOM_SPEED;
|
||||||
const bx = 12, by = H - 12;
|
setZoom((prev) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, prev + delta)));
|
||||||
ctx.strokeStyle = '#06b6d4';
|
};
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
// Mouse dragging for pan
|
||||||
ctx.moveTo(bx, by); ctx.lineTo(bx + scalePx, by);
|
const handleMouseDown = (e) => {
|
||||||
ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
|
setIsDragging(true);
|
||||||
ctx.moveTo(bx + scalePx, by - 4); ctx.lineTo(bx + scalePx, by + 4);
|
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||||
ctx.stroke();
|
};
|
||||||
ctx.fillStyle = '#06b6d4';
|
|
||||||
ctx.font = '9px monospace';
|
const handleMouseMove = (e) => {
|
||||||
ctx.textAlign = 'left';
|
if (!isDragging) return;
|
||||||
ctx.fillText(`${scaleM}m`, bx, by - 6);
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
// ── 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 });
|
|
||||||
};
|
};
|
||||||
const onMouseUp = () => { dragging.current = null; };
|
|
||||||
|
|
||||||
// Touch pan
|
const handleTouchMove = (e) => {
|
||||||
const touchRef = useRef(null);
|
if (!isDragging || e.touches.length !== 1) return;
|
||||||
const onTouchStart = (e) => {
|
|
||||||
const t = e.touches[0];
|
const dx = e.touches[0].clientX - dragStartRef.current.x;
|
||||||
touchRef.current = { x: t.clientX - pan.x, y: t.clientY - pan.y };
|
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 onTouchMove = (e) => {
|
|
||||||
if (!touchRef.current) return;
|
const handleTouchEnd = () => {
|
||||||
const t = e.touches[0];
|
setIsDragging(false);
|
||||||
setPan({ x: t.clientX - touchRef.current.x, y: t.clientY - touchRef.current.y });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col h-full space-y-3">
|
||||||
{/* Toolbar */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
|
||||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">MAP VIEWER</div>
|
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||||
<button onClick={() => setZoom(z => Math.min(z * 1.5, 20))}
|
MAP VIEWER (SLAM)
|
||||||
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+</button>
|
</div>
|
||||||
<span className="text-gray-500 text-xs w-10 text-center">{zoom.toFixed(1)}x</span>
|
<div className="text-gray-600 text-xs">
|
||||||
<button onClick={() => setZoom(z => Math.max(z / 1.5, 0.2))}
|
Zoom: {zoom.toFixed(2)}× | Trail: {robotTrail.length} points
|
||||||
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 }); }}
|
</div>
|
||||||
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="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
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={600}
|
width={800}
|
||||||
height={400}
|
height={600}
|
||||||
className="w-full cursor-grab active:cursor-grabbing block"
|
className="w-full h-full cursor-grab active:cursor-grabbing"
|
||||||
onMouseDown={onMouseDown}
|
onWheel={handleWheel}
|
||||||
onMouseMove={onMouseMove}
|
style={{ userSelect: 'none' }}
|
||||||
onMouseUp={onMouseUp}
|
|
||||||
onMouseLeave={onMouseUp}
|
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchMove={onTouchMove}
|
|
||||||
onTouchEnd={() => { touchRef.current = null; }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info bar */}
|
{/* Info panels */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
{/* Map info */}
|
||||||
<div className="text-gray-600">MAP SIZE</div>
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1">
|
||||||
<div className="text-cyan-400 font-bold">
|
<div className="text-cyan-700 font-bold">MAP</div>
|
||||||
{mapInfo ? `${mapInfo.width}×${mapInfo.height}` : '—'}
|
{mapInfo ? (
|
||||||
</div>
|
<>
|
||||||
|
<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="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>
|
||||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
|
||||||
<div className="text-gray-600">RESOLUTION</div>
|
{/* Robot info */}
|
||||||
<div className="text-cyan-400 font-bold">
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 space-y-1">
|
||||||
{mapInfo ? `${mapInfo.resolution.toFixed(2)}m/cell` : '—'}
|
<div className="text-cyan-700 font-bold">ROBOT POSE</div>
|
||||||
</div>
|
{robotInfo ? (
|
||||||
</div>
|
<>
|
||||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
<div className="flex justify-between">
|
||||||
<div className="text-gray-600">ROBOT POS</div>
|
<span>X:</span>
|
||||||
<div className="text-orange-400 font-bold">
|
<span className="text-cyan-400">{robotInfo.x} m</span>
|
||||||
{odomPose ? `${odomPose.x.toFixed(2)}, ${odomPose.y.toFixed(2)}` : '—'}
|
</div>
|
||||||
</div>
|
<div className="flex justify-between">
|
||||||
</div>
|
<span>Y:</span>
|
||||||
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
<span className="text-cyan-400">{robotInfo.y} m</span>
|
||||||
<div className="text-gray-600">HEADING</div>
|
</div>
|
||||||
<div className="text-orange-400 font-bold">
|
<div className="flex justify-between">
|
||||||
{odomPose ? `${(odomPose.yaw * 180 / Math.PI).toFixed(1)}°` : '—'}
|
<span>Heading:</span>
|
||||||
</div>
|
<span className="text-cyan-400">{robotInfo.theta}°</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-700">Waiting for odometry...</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Topic info */}
|
||||||
<div className="flex gap-4 text-xs text-gray-600">
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex justify-between">
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#1a1a2e' }} />Unknown
|
<span>Map Topic:</span>
|
||||||
|
<span className="text-gray-500">/map (nav_msgs/OccupancyGrid)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex justify-between">
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#0a1020' }} />Free
|
<span>Odometry Topic:</span>
|
||||||
|
<span className="text-gray-500">/odom (nav_msgs/Odometry)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex justify-between">
|
||||||
<div className="w-3 h-3 rounded-sm" style={{ background: '#00b8d9' }} />Occupied
|
<span>Legend:</span>
|
||||||
</div>
|
<span className="text-gray-500">⬜ Free | 🟦 Unknown | ⬛ Occupied | 🔵 Robot</span>
|
||||||
<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 };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user