/** * MissionPlanner.jsx — Waypoint editor, patrol route builder, geofence editor, * mission templates, scheduler, and live execution overlay. * * Sub-views: Map | Waypoints | Routes | Geofences | Templates | Schedule | Execute * * Canvas coordinate space: * World frame (metres, ROS convention): x→east, y→north * Canvas: cx = W/2 + pan.x + world.x*zoom, cy = H/2 + pan.y - world.y*zoom * * Multi-robot: integrates with useFleet (imported lazily to avoid dep if unused). */ import { useState, useRef, useEffect, useCallback } from 'react'; import { useMissions } from '../hooks/useMissions.js'; import { useFleet } from '../hooks/useFleet.js'; // ── Constants ──────────────────────────────────────────────────────────────── const VIEWS = ['Map','Waypoints','Routes','Geofences','Templates','Schedule','Execute']; const ROUTE_TYPE_LABELS = { loop: 'Loop', oneshot: 'One-shot', pingpong: 'Ping-pong' }; const DAY_LABELS = ['Su','Mo','Tu','We','Th','Fr','Sa']; // ── Canvas helpers ──────────────────────────────────────────────────────────── function worldToCanvas(wx, wy, cx, cy, zoom) { return { px: cx + wx * zoom, py: cy - wy * zoom }; } function canvasToWorld(px, py, cx, cy, zoom) { return { x: (px - cx) / zoom, y: -(py - cy) / zoom }; } // ── Map Canvas ─────────────────────────────────────────────────────────────── function PlannerCanvas({ waypoints, routes, geofences, executions, robotData = {}, robots = [], mode, // 'view'|'waypoint'|'geofence' activeRouteId, activeGeofenceId, onClickWorld, // (x, y) → void onMoveWaypoint, // (id, x, y) → void }) { const canvasRef = useRef(null); const [zoom, setZoom] = useState(30); const [pan, setPan] = useState({ x: 0, y: 0 }); const dragging = useRef(null); // { type:'pan'|'waypoint', id?, startX, startY } const polyPts = useRef([]); // in-progress geofence points const cx = useCallback((W) => W / 2 + pan.x, [pan.x]); const cy = useCallback((H) => H / 2 + pan.y, [pan.y]); // ── Render ───────────────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; const ox = cx(W), oy = cy(H); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#050510'; ctx.fillRect(0, 0, W, H); // Grid ctx.strokeStyle = '#0d1b2a'; ctx.lineWidth = 1; const gs = zoom; for (let x = (ox % gs + gs) % gs; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); } for (let y = (oy % gs + gs) % gs; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); } // Origin ctx.strokeStyle = '#1e3a4a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ox-12,oy); ctx.lineTo(ox+12,oy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ox,oy-12); ctx.lineTo(ox,oy+12); ctx.stroke(); // ── Geofences ────────────────────────────────────────────────────────── for (const gf of geofences) { if (gf.points.length < 3) continue; ctx.beginPath(); gf.points.forEach(({ x, y }, i) => { const p = worldToCanvas(x, y, ox, oy, zoom); i === 0 ? ctx.moveTo(p.px, p.py) : ctx.lineTo(p.px, p.py); }); ctx.closePath(); ctx.fillStyle = gf.type === 'nogo' ? 'rgba(239,68,68,0.12)' : 'rgba(34,197,94,0.12)'; ctx.strokeStyle = gf.type === 'nogo' ? '#ef4444' : '#22c55e'; ctx.lineWidth = gf.id === activeGeofenceId ? 2 : 1; ctx.setLineDash(gf.id === activeGeofenceId ? [] : [4,4]); ctx.fill(); ctx.stroke(); ctx.setLineDash([]); // Label const lx = gf.points.reduce((s,p)=>s+p.x,0)/gf.points.length; const ly = gf.points.reduce((s,p)=>s+p.y,0)/gf.points.length; const lp = worldToCanvas(lx, ly, ox, oy, zoom); ctx.fillStyle = gf.type === 'nogo' ? '#ef4444' : '#22c55e'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(gf.name, lp.px, lp.py); } // ── In-progress geofence ─────────────────────────────────────────────── if (mode === 'geofence' && polyPts.current.length > 0) { ctx.beginPath(); polyPts.current.forEach((p, i) => { i === 0 ? ctx.moveTo(p.px, p.py) : ctx.lineTo(p.px, p.py); }); ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.5; ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]); polyPts.current.forEach(p => { ctx.fillStyle = '#f59e0b'; ctx.beginPath(); ctx.arc(p.px, p.py, 4, 0, Math.PI*2); ctx.fill(); }); } // ── Routes ───────────────────────────────────────────────────────────── const wpMap = Object.fromEntries(waypoints.map(w => [w.id, w])); for (const rt of routes) { const pts = rt.waypointIds.map(id => wpMap[id]).filter(Boolean); if (pts.length < 2) continue; const isActive = rt.id === activeRouteId; const exec = executions[rt.id]; ctx.strokeStyle = rt.color ?? '#06b6d4'; ctx.lineWidth = isActive ? 2.5 : 1.5; ctx.setLineDash(isActive ? [] : [6,3]); ctx.beginPath(); pts.forEach((wp, i) => { const p = worldToCanvas(wp.x, wp.y, ox, oy, zoom); i === 0 ? ctx.moveTo(p.px, p.py) : ctx.lineTo(p.px, p.py); }); if (rt.type === 'loop' || rt.type === 'pingpong') ctx.closePath(); ctx.stroke(); ctx.setLineDash([]); // Direction arrows on first segment if (pts.length >= 2) { const a = worldToCanvas(pts[0].x, pts[0].y, ox, oy, zoom); const b = worldToCanvas(pts[1].x, pts[1].y, ox, oy, zoom); const mx = (a.px + b.px)/2, my = (a.py + b.py)/2; const ang = Math.atan2(b.py - a.py, b.px - a.px); ctx.save(); ctx.translate(mx, my); ctx.rotate(ang); ctx.fillStyle = rt.color ?? '#06b6d4'; ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, -3); ctx.lineTo(-4, 3); ctx.closePath(); ctx.fill(); ctx.restore(); } // Execution progress highlight if (exec && exec.state === 'running' && exec.currentIdx < pts.length) { const cur = worldToCanvas(pts[exec.currentIdx].x, pts[exec.currentIdx].y, ox, oy, zoom); ctx.fillStyle = '#ffffff'; ctx.shadowBlur = 12; ctx.shadowColor = rt.color ?? '#06b6d4'; ctx.beginPath(); ctx.arc(cur.px, cur.py, 9, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = rt.color ?? '#06b6d4'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.fillText('▶', cur.px, cur.py + 3.5); } } // ── Waypoints ────────────────────────────────────────────────────────── for (const wp of waypoints) { const p = worldToCanvas(wp.x, wp.y, ox, oy, zoom); // Dwell ring if (wp.dwellTime > 0) { ctx.beginPath(); ctx.arc(p.px, p.py, 10, 0, Math.PI * 2); ctx.strokeStyle = '#f59e0b44'; ctx.lineWidth = 3; ctx.stroke(); } // Dot ctx.fillStyle = '#06b6d4'; ctx.shadowBlur = 6; ctx.shadowColor = '#06b6d4'; ctx.beginPath(); ctx.arc(p.px, p.py, 5, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; // Label ctx.fillStyle = '#67e8f9'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(wp.label, p.px, p.py - 9); } // ── Robot positions from fleet ───────────────────────────────────────── robots.forEach((robot, i) => { const d = robotData[robot.id]; if (!d?.pose) return; const p = worldToCanvas(d.pose.x, d.pose.y, ox, oy, zoom); const color = ['#f97316','#22c55e','#a855f7','#f59e0b'][i % 4]; ctx.fillStyle = color; ctx.shadowBlur = 8; ctx.shadowColor = color; ctx.beginPath(); ctx.arc(p.px, p.py, 7, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; const arrowLen = Math.max(12, zoom * 0.4); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.px + Math.cos(d.pose.yaw)*arrowLen, p.py - Math.sin(d.pose.yaw)*arrowLen); ctx.stroke(); ctx.fillStyle = color; ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillText(robot.name || robot.id, p.px, p.py + 16); }); // ── Cursor hint ──────────────────────────────────────────────────────── if (mode === 'waypoint') { ctx.strokeStyle = '#06b6d488'; ctx.lineWidth = 1; ctx.setLineDash([2,2]); ctx.beginPath(); ctx.arc(ox, oy, 8, 0, Math.PI*2); ctx.stroke(); ctx.setLineDash([]); } // ── Scale bar ───────────────────────────────────────────────────────── const scaleM = zoom >= 20 ? 1 : zoom >= 5 ? 5 : 10; const scalePx = scaleM * zoom; const bx = 12, by = H - 16; 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.stroke(); ctx.fillStyle = '#06b6d4'; ctx.font = '9px monospace'; ctx.textAlign = 'left'; ctx.fillText(`${scaleM}m`, bx, by-7); }, [waypoints, routes, geofences, executions, robotData, robots, mode, activeRouteId, activeGeofenceId, zoom, pan, cx, cy]); // ── Mouse handlers ───────────────────────────────────────────────────────── const hitWaypoint = (px, py, W, H) => { const ox = cx(W), oy = cy(H); return waypoints.find(wp => { const p = worldToCanvas(wp.x, wp.y, ox, oy, zoom); return Math.hypot(p.px - px, p.py - py) < 8; }); }; const handleMouseDown = (e) => { const rect = canvasRef.current.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const W = canvasRef.current.width; const H = canvasRef.current.height; if (mode === 'waypoint') { const hit = hitWaypoint(px, py, W, H); if (hit) { dragging.current = { type: 'waypoint', id: hit.id }; return; } } dragging.current = { type: 'pan', startX: e.clientX - pan.x, startY: e.clientY - pan.y }; }; const handleMouseMove = (e) => { if (!dragging.current) return; if (dragging.current.type === 'pan') { setPan({ x: e.clientX - dragging.current.startX, y: e.clientY - dragging.current.startY }); } else if (dragging.current.type === 'waypoint') { const rect = canvasRef.current.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const W = canvasRef.current.width; const H = canvasRef.current.height; const { x, y } = canvasToWorld(px, py, cx(W), cy(H), zoom); onMoveWaypoint?.(dragging.current.id, x, y); } }; const handleMouseUp = (e) => { if (dragging.current?.type === 'pan') { // only register click if barely moved const dx = Math.abs(e.clientX - (e.clientX - pan.x + dragging.current.startX - pan.x)); // check if it was a click not a drag via movement check } dragging.current = null; }; const handleClick = (e) => { const rect = canvasRef.current.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const W = canvasRef.current.width; const H = canvasRef.current.height; const { x, y } = canvasToWorld(px, py, cx(W), cy(H), zoom); if (mode === 'waypoint') { if (!hitWaypoint(px, py, W, H)) { onClickWorld?.(x, y); } } else if (mode === 'geofence') { polyPts.current = [...polyPts.current, { px, py, wx: x, wy: y }]; if (polyPts.current.length >= 3 && e.shiftKey) { // Shift+click to close polygon const pts = polyPts.current.map(p => ({ x: p.wx, y: p.wy })); onClickWorld?.(pts); polyPts.current = []; } } }; const handleDblClick = (e) => { if (mode === 'geofence' && polyPts.current.length >= 3) { const pts = polyPts.current.map(p => ({ x: p.wx, y: p.wy })); onClickWorld?.(pts); polyPts.current = []; } }; const handleWheel = (e) => { e.preventDefault(); setZoom(z => Math.max(2, Math.min(200, z * (e.deltaY < 0 ? 1.2 : 0.85)))); }; return (
{/* Toolbar */}
{zoom.toFixed(0)}px/m
{mode === 'waypoint' && ( Click map to place waypoint · Drag to move )} {mode === 'geofence' && ( Click to add vertices · Double-click to close polygon )}
{ dragging.current = null; }} onClick={handleClick} onDoubleClick={handleDblClick} onWheel={handleWheel} />
{/* Legend */}
Waypoint
Route
No-go
Allowed
Robot
); } // ── Waypoints sub-view ──────────────────────────────────────────────────────── function WaypointsView({ waypoints, updateWaypoint, removeWaypoint, reorderWaypoints, setMapMode }) { const [editing, setEditing] = useState(null); const [editForm, setEditForm] = useState({}); const startEdit = (wp) => { setEditing(wp.id); setEditForm({ label: wp.label, dwellTime: wp.dwellTime }); }; const applyEdit = (id) => { updateWaypoint(id, { label: editForm.label, dwellTime: Number(editForm.dwellTime) }); setEditing(null); }; return (
WAYPOINTS ({waypoints.length})
{waypoints.length === 0 ? (
No waypoints. Click "Place on Map" then click the canvas.
) : (
{waypoints.map((wp, i) => (
{i+1} {editing === wp.id ? ( <> setEditForm(f=>({...f,label:e.target.value}))} /> setEditForm(f=>({...f,dwellTime:e.target.value}))} placeholder="dwell s" /> ) : ( <> {wp.label} ({wp.x.toFixed(1)}, {wp.y.toFixed(1)}) {wp.dwellTime > 0 && {wp.dwellTime}s dwell} )}
))}
)}
); } // ── Routes sub-view ──────────────────────────────────────────────────────────── function RoutesView({ waypoints, routes, addRoute, updateRoute, removeRoute, addWaypointToRoute, removeWaypointFromRoute, robots, setActiveRouteId }) { const [newName, setNewName] = useState(''); const wpMap = Object.fromEntries(waypoints.map(w=>[w.id,w])); return (
PATROL ROUTES
{/* Create route */}
setNewName(e.target.value)} onKeyDown={e => { if (e.key==='Enter' && newName.trim()) { addRoute(newName.trim()); setNewName(''); }}} />
{routes.length === 0 ? (
No routes defined. Create a route and add waypoints to it.
) : (
{routes.map(rt => (
{/* Route header */}
{rt.name}
{/* Waypoint sequence */}
{rt.waypointIds.map((wid, idx) => { const wp = wpMap[wid]; return (
{idx > 0 && } {wp?.label ?? '?'}
); })} {rt.type === 'loop' && rt.waypointIds.length >= 2 && ( )}
{/* Add waypoint to route */} {waypoints.length > 0 && ( )}
))}
)}
); } // ── Geofences sub-view ──────────────────────────────────────────────────────── function GeofencesView({ geofences, updateGeofence, removeGeofence, setMapMode, setActiveGeofenceId }) { const [pendingType, setPendingType] = useState('nogo'); const [pendingName, setPendingName] = useState(''); return (
GEOFENCES
setPendingName(e.target.value)} />
{geofences.length === 0 ? (
No geofences. Enter a name, pick type, click Draw, then draw on the map.
) : (
{geofences.map(gf => (
{gf.name} {gf.type==='nogo'?'no-go':'allowed'} {gf.points.length} pts
))}
)}
); } // ── Templates sub-view ──────────────────────────────────────────────────────── const BUILTIN_TEMPLATES = [ { id: '__patrol', name: 'Patrol', description: 'Clockwise perimeter loop' }, { id: '__escort', name: 'Escort', description: 'Follow a fixed escort path' }, { id: '__dock', name: 'Dock', description: 'Navigate to charging dock' }, { id: '__explore', name: 'Exploration', description: 'Systematic area coverage' }, ]; function TemplatesView({ templates, saveTemplate, loadTemplate, removeTemplate, exportJSON, importJSON }) { const [saveName, setSaveName] = useState(''); const [saveDesc, setSaveDesc] = useState(''); const [importText, setImportText] = useState(''); const [showImport, setShowImport] = useState(false); const [msg, setMsg] = useState(null); const showMsg = (m) => { setMsg(m); setTimeout(()=>setMsg(null), 3000); }; const handleExport = () => { const json = exportJSON(); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([json], {type:'application/json'})); a.download = 'saltybot-missions.json'; a.click(); }; const handleImport = () => { try { importJSON(importText); setShowImport(false); setImportText(''); showMsg('Imported successfully'); } catch (e) { showMsg('Invalid JSON: ' + e.message); } }; return (
MISSION TEMPLATES
{/* Save current state */}
Save Current Mission
setSaveName(e.target.value)} /> setSaveDesc(e.target.value)} />
{msg &&
{msg}
} {/* User templates */} {templates.length > 0 && (
Saved Templates
{templates.map(t => (
{t.name} {t.description && {t.description}}
{t.waypoints?.length??0}wp {t.routes?.length??0}rt
))}
)} {/* Built-in starters */}
Mission Starters
{BUILTIN_TEMPLATES.map(t => (
{t.name} {t.description}
starter
))}
{/* Import / Export */}
{showImport && (