/**
* 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 */}
setZoom(z=>Math.min(z*1.5,200))}
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+
{zoom.toFixed(0)}px/m
setZoom(z=>Math.max(z/1.5,2))}
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">−
{ setZoom(30); setPan({x:0,y:0}); }}
className="px-2 py-1 rounded border border-gray-700 text-gray-400 text-xs ml-1">Reset
{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})
setMapMode('waypoint')}
className="ml-auto px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs"
>+ Place on Map
{waypoints.length === 0 ? (
No waypoints. Click "Place on Map" then click the canvas.
) : (
)}
);
}
// ── 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(''); }}}
/>
{ addRoute(newName.trim()); setNewName(''); }}
className="px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs disabled:opacity-40"
>Add Route
{routes.length === 0 ? (
No routes defined. Create a route and add waypoints to it.
) : (
{routes.map(rt => (
{/* Route header */}
{rt.name}
updateRoute(rt.id, {type: e.target.value})}
>
{Object.entries(ROUTE_TYPE_LABELS).map(([v,l]) => (
{l}
))}
updateRoute(rt.id, {robotId: e.target.value || null})}
>
— No robot —
{robots.map(r => {r.name||r.id} )}
setActiveRouteId(rt.id)}
className="ml-auto text-xs text-cyan-600 hover:text-cyan-300 border border-gray-700 px-2 py-0.5 rounded">
Show
removeRoute(rt.id)}
className="text-gray-700 hover:text-red-500 text-xs px-1">✕
{/* Waypoint sequence */}
{rt.waypointIds.map((wid, idx) => {
const wp = wpMap[wid];
return (
{idx > 0 && → }
{wp?.label ?? '?'}
removeWaypointFromRoute(rt.id, idx)}
className="text-gray-700 hover:text-red-500">×
);
})}
{rt.type === 'loop' && rt.waypointIds.length >= 2 && (
↩
)}
{/* Add waypoint to route */}
{waypoints.length > 0 && (
{ if (e.target.value) addWaypointToRoute(rt.id, e.target.value); }}
>
+ Add waypoint…
{waypoints.map(w => {w.label} )}
)}
))}
)}
);
}
// ── Geofences sub-view ────────────────────────────────────────────────────────
function GeofencesView({ geofences, updateGeofence, removeGeofence, setMapMode, setActiveGeofenceId }) {
const [pendingType, setPendingType] = useState('nogo');
const [pendingName, setPendingName] = useState('');
return (
{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
setActiveGeofenceId(gf.id)} className="text-gray-600 hover:text-cyan-400 px-1">🗺
removeGeofence(gf.id)} className="text-gray-700 hover:text-red-500 px-1">✕
))}
)}
);
}
// ── 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 */}
{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
{ loadTemplate(t.id); showMsg(`Loaded: ${t.name}`); }}
className="text-cyan-600 hover:text-cyan-300 border border-gray-700 px-2 py-0.5 rounded text-xs">Load
removeTemplate(t.id)}
className="text-gray-700 hover:text-red-500 px-1">✕
))}
)}
{/* Built-in starters */}
Mission Starters
{BUILTIN_TEMPLATES.map(t => (
{t.name}
{t.description}
starter
))}
{/* Import / Export */}
Export JSON
setShowImport(s=>!s)}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs">
Import JSON
{showImport && (
)}
);
}
// ── Schedule sub-view ─────────────────────────────────────────────────────────
function ScheduleView({ schedules, routes, robots, addSchedule, updateSchedule, removeSchedule }) {
const [form, setForm] = useState({ routeId: '', robotId: '', time: '09:00', days: [1,2,3,4,5] });
const set = (k,v) => setForm(f=>({...f,[k]:v}));
const toggleDay = (d) => set('days', form.days.includes(d) ? form.days.filter(x=>x!==d) : [...form.days, d]);
const handleAdd = () => {
if (!form.routeId) return;
addSchedule(form.routeId, form.robotId||null, form.time, form.days);
};
const routeMap = Object.fromEntries(routes.map(r=>[r.id,r]));
const robotMap = Object.fromEntries(robots.map(r=>[r.id,r]));
return (
MISSION SCHEDULE
{/* Add schedule */}
Add Time Trigger
Add Schedule
{/* Schedule list */}
{schedules.length === 0 ? (
No schedules configured.
) : (
{schedules.map(sc => {
const rt = routeMap[sc.routeId];
const rb = sc.robotId ? robotMap[sc.robotId] : null;
return (
updateSchedule(sc.id, { enabled: !sc.enabled })}
className={`w-8 h-4 rounded-full relative transition-colors ${sc.enabled?'bg-cyan-700':'bg-gray-700'}`}
>
{sc.time}
{sc.days.map(d=>DAY_LABELS[d]).join(' ')}
{rt?.name ?? '?'}
{rb && {rb.name||rb.id} }
removeSchedule(sc.id)}
className="text-gray-700 hover:text-red-500 px-1">✕
);
})}
)}
Note: Schedules are evaluated client-side. Keep this dashboard open for scheduled missions to trigger.
For persistent scheduling, push routes to the robot via ROS2 nav stack.
);
}
// ── Execute sub-view ──────────────────────────────────────────────────────────
function ExecuteView({ routes, waypoints, robots, connections, executions, publishRobot,
startExecution, pauseExecution, resumeExecution, abortExecution, advanceExecution }) {
const wpMap = Object.fromEntries(waypoints.map(w=>[w.id,w]));
const sendWaypoint = useCallback((robotId, wp) => {
if (!robotId || !publishRobot) return;
const cy2 = Math.cos(0), sy2 = Math.sin(0);
publishRobot(robotId, '/goal_pose', 'geometry_msgs/PoseStamped', {
header: { frame_id: 'map', stamp: { sec: 0, nanosec: 0 } },
pose: {
position: { x: wp.x, y: wp.y, z: 0 },
orientation: { x: 0, y: 0, z: sy2, w: cy2 },
},
});
}, [publishRobot]);
const handleStart = (rt) => {
const robotId = rt.robotId;
startExecution(rt.id, robotId);
// Send first waypoint
const first = wpMap[rt.waypointIds[0]];
if (first && robotId) sendWaypoint(robotId, first);
};
const handleAdvance = (rt) => {
const exec = executions[rt.id];
if (!exec) return;
const nextIdx = exec.currentIdx + 1;
const len = rt.waypointIds.length;
if (nextIdx >= len) {
if (rt.type === 'loop') {
advanceExecution(rt.id, 0);
const wp = wpMap[rt.waypointIds[0]];
if (wp && exec.robotId) sendWaypoint(exec.robotId, wp);
} else {
abortExecution(rt.id); // mark done
}
} else {
advanceExecution(rt.id, nextIdx);
const wp = wpMap[rt.waypointIds[nextIdx]];
if (wp && exec.robotId) sendWaypoint(exec.robotId, wp);
}
};
return (
LIVE EXECUTION
{routes.length === 0 ? (
No routes defined. Create routes in the Routes tab first.
) : (
{routes.map(rt => {
const exec = executions[rt.id];
const robotId = exec?.robotId ?? rt.robotId;
const robot = robots.find(r=>r.id===robotId);
const connected = robotId && connections[robotId]?.connected;
const pts = rt.waypointIds.map(id=>wpMap[id]).filter(Boolean);
const state = exec?.state ?? 'idle';
const progress = exec ? `${exec.currentIdx+1}/${pts.length}` : `0/${pts.length}`;
return (
{rt.name}
{state.toUpperCase()}
{ROUTE_TYPE_LABELS[rt.type]}
{progress}
{/* Robot assignment */}
Robot:
{robot ? (
{connected ? '● ' : '○ '}{robot.name||robot.id}
) : (
— unassigned (go to Routes tab)
)}
{/* Progress bar */}
{pts.length > 0 && (
{exec && state==='running' && (
→ {wpMap[rt.waypointIds[exec.currentIdx]]?.label ?? '?'}
)}
)}
{/* Controls */}
{state === 'idle' || state === 'done' || state === 'aborted' ? (
handleStart(rt)}
className="px-3 py-1 rounded bg-green-950 border border-green-700 text-green-300 hover:bg-green-900 text-xs disabled:opacity-40"
>▶ Start
) : state === 'running' ? (
<>
pauseExecution(rt.id)}
className="px-3 py-1 rounded bg-amber-950 border border-amber-700 text-amber-300 hover:bg-amber-900 text-xs">
⏸ Pause
handleAdvance(rt)}
className="px-3 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs">
⏭ Next WP
abortExecution(rt.id)}
className="px-3 py-1 rounded bg-red-950 border border-red-800 text-red-400 hover:bg-red-900 text-xs">
⏹ Abort
>
) : state === 'paused' ? (
<>
resumeExecution(rt.id)}
className="px-3 py-1 rounded bg-green-950 border border-green-700 text-green-300 hover:bg-green-900 text-xs">
▶ Resume
abortExecution(rt.id)}
className="px-3 py-1 rounded bg-red-950 border border-red-800 text-red-400 hover:bg-red-900 text-xs">
⏹ Abort
>
) : null}
{/* Send full patrol route */}
{connected && pts.length >= 2 && (state==='idle'||state==='done'||state==='aborted') && (
{
const cy2 = Math.cos(0);
publishRobot(robotId, '/outdoor/waypoints', 'geometry_msgs/PoseArray', {
header: { frame_id:'map', stamp:{sec:0,nanosec:0} },
poses: pts.map(wp => ({
position:{x:wp.x,y:wp.y,z:0},
orientation:{x:0,y:0,z:0,w:1},
})),
});
}}
className="px-3 py-1 rounded border border-cyan-800 text-cyan-500 hover:text-cyan-300 text-xs"
>Send Patrol Route
)}
);
})}
)}
);
}
// ── Root MissionPlanner ───────────────────────────────────────────────────────
export function MissionPlanner() {
const missions = useMissions();
const { robots, connections, robotData, publishRobot } = useFleet();
const [view, setView] = useState('Map');
const [mapMode, setMapMode] = useState('view'); // 'view'|'waypoint'|{mode:'geofence',...}
const [activeRouteId, setActiveRouteId] = useState(null);
const [activeGeofenceId,setActiveGeofenceId]= useState(null);
const isGeofenceMode = typeof mapMode === 'object' && mapMode.mode === 'geofence';
const canvasMode = mapMode === 'waypoint' ? 'waypoint' : isGeofenceMode ? 'geofence' : 'view';
const handleClickWorld = useCallback((xOrPts, y) => {
if (mapMode === 'waypoint') {
missions.addWaypoint(xOrPts, y);
} else if (isGeofenceMode && Array.isArray(xOrPts)) {
missions.addGeofence(mapMode.name, mapMode.type, xOrPts);
setMapMode('view');
}
}, [mapMode, isGeofenceMode, missions]);
const setMapModeWrapped = useCallback((m) => {
setMapMode(m);
if (m !== 'waypoint') setView('Map');
}, []);
// Schedule runner — check every minute
useEffect(() => {
const check = () => {
const now = new Date();
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
missions.schedules.forEach(sc => {
if (!sc.enabled) return;
if (!sc.days.includes(now.getDay())) return;
if (sc.time !== hhmm) return;
const rt = missions.routes.find(r=>r.id===sc.routeId);
if (!rt) return;
const robotId = sc.robotId ?? rt.robotId;
if (!robotId || !connections[robotId]?.connected) return;
const exec = missions.executions[rt.id];
if (exec?.state === 'running') return; // already running
missions.startExecution(rt.id, robotId);
});
};
const timer = setInterval(check, 60000);
return () => clearInterval(timer);
}, [missions, connections]);
return (
{/* Sub-nav */}
{VIEWS.map(v => (
setView(v)}
className={`px-3 py-2 text-xs font-bold tracking-wider whitespace-nowrap border-b-2 transition-colors ${
view === v
? 'border-cyan-500 text-cyan-300'
: 'border-transparent text-gray-600 hover:text-gray-300'
}`}
>{v.toUpperCase()}
))}
{mapMode !== 'view' && (
setMapMode('view')}
className="ml-auto px-3 py-1 my-0.5 rounded border border-amber-800 text-amber-400 text-xs">
✕ Exit {canvasMode} mode
)}
{/* Map always visible */}
{/* Sub-view panel */}
{view === 'Waypoints' && (
setMapMode('waypoint')}
/>
)}
{view === 'Routes' && (
)}
{view === 'Geofences' && (
)}
{view === 'Templates' && (
)}
{view === 'Schedule' && (
)}
{view === 'Execute' && (
)}
{view === 'Map' && (
setMapMode('waypoint')}
className={`px-3 py-1.5 rounded border text-xs ${mapMode==='waypoint'?'bg-cyan-950 border-cyan-700 text-cyan-300':'border-gray-700 text-gray-500 hover:text-gray-300'}`}>
+ Place Waypoint
{ setView('Waypoints'); setMapMode('waypoint'); }}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs">
Manage Waypoints →
setView('Routes')}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs">
Manage Routes →
setView('Execute')}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs">
Execute →
{missions.waypoints.length} wpts · {missions.routes.length} routes · {missions.geofences.length} fences
)}
);
}