/** * WaypointEditor.jsx — Interactive waypoint navigation editor with click-to-place and drag-to-reorder * * Features: * - Click on map canvas to place waypoints * - Drag waypoints to reorder navigation sequence * - Right-click to delete waypoints * - Real-time waypoint list with labels and coordinates * - Send Nav2 goal to /navigate_to_pose action * - Execute waypoint sequence with automatic progression * - Clear all waypoints button * - Visual feedback for active waypoint (executing) */ import { useEffect, useRef, useState } from 'react'; function WaypointEditor({ subscribe, publish, callService }) { const [waypoints, setWaypoints] = useState([]); const [selectedWaypoint, setSelectedWaypoint] = useState(null); const [isDragging, setIsDragging] = useState(false); const [dragIndex, setDragIndex] = useState(null); const [activeWaypoint, setActiveWaypoint] = useState(null); const [executing, setExecuting] = useState(false); const [mapData, setMapData] = useState(null); const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 }); const canvasRef = useRef(null); const containerRef = useRef(null); const mapDataRef = useRef(null); const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 }); const waypointsRef = useRef([]); // Subscribe to map data 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, }; setMapData(mapInfo); mapDataRef.current = mapInfo; } catch (e) { console.error('Error parsing map data:', e); } } ); return unsubMap; }, [subscribe]); // Subscribe to robot odometry useEffect(() => { const unsubOdom = subscribe( '/odom', 'nav_msgs/Odometry', (msg) => { try { const pos = msg.pose.pose.position; const ori = msg.pose.pose.orientation; 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; } catch (e) { console.error('Error parsing odometry data:', e); } } ); return unsubOdom; }, [subscribe]); const handleCanvasClick = (e) => { if (!mapDataRef.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; const map = mapDataRef.current; const robot = robotPoseRef.current; const zoom = 1; const centerX = containerRef.current.clientWidth / 2; const centerY = containerRef.current.clientHeight / 2; const worldX = robot.x + (clickX - centerX) / zoom; const worldY = robot.y - (clickY - centerY) / zoom; const newWaypoint = { id: Date.now(), x: parseFloat(worldX.toFixed(2)), y: parseFloat(worldY.toFixed(2)), label: `WP-${waypoints.length + 1}`, }; setWaypoints((prev) => [...prev, newWaypoint]); waypointsRef.current = [...waypointsRef.current, newWaypoint]; }; const handleDeleteWaypoint = (id) => { setWaypoints((prev) => prev.filter((wp) => wp.id !== id)); waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id); if (selectedWaypoint === id) setSelectedWaypoint(null); }; const handleWaypointSelect = (id) => { setSelectedWaypoint(selectedWaypoint === id ? null : id); }; const handleWaypointDragStart = (e, index) => { setIsDragging(true); setDragIndex(index); }; const handleWaypointDragOver = (e, targetIndex) => { if (!isDragging || dragIndex === null || dragIndex === targetIndex) return; const newWaypoints = [...waypoints]; const draggedWaypoint = newWaypoints[dragIndex]; newWaypoints.splice(dragIndex, 1); newWaypoints.splice(targetIndex, 0, draggedWaypoint); setWaypoints(newWaypoints); waypointsRef.current = newWaypoints; setDragIndex(targetIndex); }; const handleWaypointDragEnd = () => { setIsDragging(false); setDragIndex(null); }; const sendNavGoal = async (waypoint) => { if (!callService) return; try { const heading = waypoint.theta || 0; const halfHeading = heading / 2; const goal = { pose: { position: { x: waypoint.x, y: waypoint.y, z: 0, }, orientation: { x: 0, y: 0, z: Math.sin(halfHeading), w: Math.cos(halfHeading), }, }, }; await callService( '/navigate_to_pose', 'nav2_msgs/NavigateToPose', { pose: goal.pose } ); setActiveWaypoint(waypoint.id); return true; } catch (e) { console.error('Error sending nav goal:', e); return false; } }; const executeWaypoints = async () => { if (waypoints.length === 0) return; setExecuting(true); for (const waypoint of waypoints) { const success = await sendNavGoal(waypoint); if (!success) break; await new Promise((resolve) => setTimeout(resolve, 500)); } setExecuting(false); setActiveWaypoint(null); }; const clearWaypoints = () => { setWaypoints([]); waypointsRef.current = []; setSelectedWaypoint(null); setActiveWaypoint(null); }; const sendSingleGoal = async () => { if (selectedWaypoint === null) return; const wp = waypoints.find((w) => w.id === selectedWaypoint); if (wp) { await sendNavGoal(wp); } }; return (
{/* Map area */}
e.preventDefault()} > {waypoints.map((wp, idx) => { if (!mapDataRef.current) return null; const robot = robotPoseRef.current; const zoom = 1; const centerX = containerRef.current?.clientWidth / 2 || 400; const centerY = containerRef.current?.clientHeight / 2 || 300; const canvasX = centerX + (wp.x - robot.x) * zoom; const canvasY = centerY - (wp.y - robot.y) * zoom; const isActive = wp.id === activeWaypoint; const isSelected = wp.id === selectedWaypoint; return ( {idx + 1} {idx < waypoints.length - 1 && ( )} ); })}
{waypoints.length === 0 && (
Click to place waypoints
Right-click to delete
)}
{/* Info panel */}
Waypoints: {waypoints.length}
Robot Position: ({robotPose.x.toFixed(2)}, {robotPose.y.toFixed(2)})
{/* Waypoint list sidebar */}
WAYPOINTS
{waypoints.length}
{/* Waypoint list */}
{waypoints.length === 0 ? (
Click map to add waypoints
) : ( waypoints.map((wp, idx) => (
handleWaypointDragStart(e, idx)} onDragOver={(e) => { e.preventDefault(); handleWaypointDragOver(e, idx); }} onDragEnd={handleWaypointDragEnd} onClick={() => handleWaypointSelect(wp.id)} onContextMenu={(e) => { e.preventDefault(); handleDeleteWaypoint(wp.id); }} className={`p-2 rounded border text-xs cursor-move transition-colors ${ wp.id === activeWaypoint ? 'bg-red-950 border-red-700 text-red-300' : wp.id === selectedWaypoint ? 'bg-amber-950 border-amber-700 text-amber-300' : 'bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600' }`} >
#{idx + 1}
{wp.label}
{wp.x.toFixed(2)}, {wp.y.toFixed(2)}
)) )}
{/* Control buttons */}
{/* Instructions */}
CONTROLS:
• Click: Place waypoint
• Right-click: Delete waypoint
• Drag: Reorder waypoints
• Click list: Select waypoint
{/* Topic info */}
Service: /navigate_to_pose
); } export { WaypointEditor };