/** * 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) * - Imports map display from MapViewer for coordinate system */ import { useEffect, useRef, useState } from 'react'; function WaypointEditor({ subscribe, publish, callService }) { // Waypoint storage 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); // Map context const [mapData, setMapData] = useState(null); const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 }); // Canvas reference const canvasRef = useRef(null); const containerRef = useRef(null); // Refs for ROS integration const mapDataRef = useRef(null); const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 }); const waypointsRef = useRef([]); // Subscribe to map data (for coordinate reference) 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 (for current position reference) 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]); // Canvas event handlers const handleCanvasClick = (e) => { if (!mapDataRef.current || !canvasRef.current) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; // Convert canvas coordinates to world coordinates // This assumes the map is centered on the robot const map = mapDataRef.current; const robot = robotPoseRef.current; const zoom = 1; // Would need to track zoom if map has zoom controls // Inverse of map rendering calculation const centerX = canvas.width / 2; const centerY = canvas.height / 2; const worldX = robot.x + (clickX - centerX) / zoom; const worldY = robot.y - (clickY - centerY) / zoom; // Create new waypoint 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 handleCanvasContextMenu = (e) => { e.preventDefault(); // Right-click handled by waypoint list }; // Waypoint list handlers 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); }; // Execute waypoints const sendNavGoal = async (waypoint) => { if (!callService) return; try { // Create quaternion from heading (default to 0 if no heading) const heading = waypoint.theta || 0; const halfHeading = heading / 2; const qx = 0; const qy = 0; const qz = Math.sin(halfHeading); const qw = Math.cos(halfHeading); const goal = { pose: { position: { x: waypoint.x, y: waypoint.y, z: 0, }, orientation: { x: qx, y: qy, z: qz, w: qw, }, }, }; // Send to Nav2 navigate_to_pose action 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) { console.error('Failed to send goal for waypoint:', waypoint); break; } // Wait a bit before sending next goal 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 (