diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx
index 0852320..1bae6ee 100644
--- a/ui/social-bot/src/App.jsx
+++ b/ui/social-bot/src/App.jsx
@@ -1,5 +1,5 @@
/**
- * App.jsx — Saltybot Social + Telemetry + Fleet Dashboard root component.
+ * App.jsx — Saltybot Social + Telemetry + Fleet + Mission Dashboard root component.
*
* Social tabs (issue #107):
* Status | Faces | Conversation | Personality | Navigation
@@ -9,6 +9,9 @@
*
* Fleet tabs (issue #139):
* Fleet (self-contained via useFleet)
+ *
+ * Mission tabs (issue #145):
+ * Missions (waypoint editor, route builder, geofence, schedule, execute)
*/
import { useState, useCallback } from 'react';
@@ -32,6 +35,9 @@ import { SystemHealth } from './components/SystemHealth.jsx';
// Fleet panel (issue #139)
import { FleetPanel } from './components/FleetPanel.jsx';
+// Mission planner (issue #145)
+import { MissionPlanner } from './components/MissionPlanner.jsx';
+
const TAB_GROUPS = [
{
label: 'SOCIAL',
@@ -60,7 +66,8 @@ const TAB_GROUPS = [
label: 'FLEET',
color: 'text-green-600',
tabs: [
- { id: 'fleet', label: 'Fleet' },
+ { id: 'fleet', label: 'Fleet' },
+ { id: 'missions', label: 'Missions' },
],
},
];
@@ -188,7 +195,8 @@ export default function App() {
{activeTab === 'control' && }
{activeTab === 'health' && }
- {activeTab === 'fleet' && }
+ {activeTab === 'fleet' && }
+ {activeTab === 'missions' && }
{/* ── Footer ── */}
diff --git a/ui/social-bot/src/components/MissionPlanner.jsx b/ui/social-bot/src/components/MissionPlanner.jsx
new file mode 100644
index 0000000..d9d6bea
--- /dev/null
+++ b/ui/social-bot/src/components/MissionPlanner.jsx
@@ -0,0 +1,1186 @@
+/**
+ * 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
+
+ setPendingName(e.target.value)}
+ />
+ setPendingType(e.target.value)}
+ >
+ No-go zone
+ Allowed area
+
+ {
+ if (pendingName.trim()) setMapMode({ mode: 'geofence', type: pendingType, name: pendingName.trim() });
+ }}
+ disabled={!pendingName.trim()}
+ className="px-2 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs disabled:opacity-40"
+ >Draw
+
+
+
+ {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
+
+
+ Route
+ set('routeId',e.target.value)}>
+ — Select route —
+ {routes.map(r => {r.name} )}
+
+
+
+ Robot
+ set('robotId',e.target.value)}>
+ — Any robot —
+ {robots.map(r => {r.name||r.id} )}
+
+
+
+ Time
+ set('time',e.target.value)} />
+
+
+ Days
+
+ {DAY_LABELS.map((d,i) => (
+ toggleDay(i)}
+ className={`w-6 h-6 rounded text-xs font-bold ${form.days.includes(i)?'bg-cyan-950 border border-cyan-700 text-cyan-300':'bg-gray-900 border border-gray-700 text-gray-600'}`}
+ >{d}
+ ))}
+
+
+
+
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
+
+
+ )}
+
+
+ );
+}
diff --git a/ui/social-bot/src/hooks/useMissions.js b/ui/social-bot/src/hooks/useMissions.js
new file mode 100644
index 0000000..07d428a
--- /dev/null
+++ b/ui/social-bot/src/hooks/useMissions.js
@@ -0,0 +1,246 @@
+/**
+ * useMissions.js — Mission planner state management.
+ *
+ * Manages waypoints, patrol routes, geofences, templates, schedules.
+ * Persists to localStorage. Provides JSON export/import.
+ *
+ * Data shapes:
+ * Waypoint: { id, label, x, y, dwellTime } (x,y in metres, world frame)
+ * Route: { id, name, waypointIds[], type, robotId, color }
+ * type: 'loop' | 'oneshot' | 'pingpong'
+ * Geofence: { id, name, type, points[{x,y}], color }
+ * type: 'nogo' | 'allowed'
+ * Template: { id, name, description, waypoints[], routes[], geofences[] }
+ * Schedule: { id, routeId, robotId, time, days[], enabled }
+ * days: 0-6 (Sun-Sat), time: 'HH:MM'
+ * Execution: { routeId, robotId, currentIdx, state, startedAt, paused }
+ * state: 'idle'|'running'|'paused'|'done'|'aborted'
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+
+const STORAGE_KEY = 'saltybot_missions_v1';
+const EXEC_KEY = 'saltybot_executions_v1';
+
+const ROUTE_COLORS = ['#06b6d4','#f97316','#22c55e','#a855f7','#f59e0b','#ec4899'];
+
+function genId() {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;
+}
+
+function loadState() {
+ try {
+ const s = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ if (s && typeof s === 'object') return s;
+ } catch {}
+ return { waypoints: [], routes: [], geofences: [], templates: [], schedules: [] };
+}
+
+function saveState(state) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+}
+
+export function useMissions() {
+ const [waypoints, setWaypoints] = useState(() => loadState().waypoints);
+ const [routes, setRoutes] = useState(() => loadState().routes);
+ const [geofences, setGeofences] = useState(() => loadState().geofences);
+ const [templates, setTemplates] = useState(() => loadState().templates);
+ const [schedules, setSchedules] = useState(() => loadState().schedules);
+ const [executions, setExecutions] = useState({}); // { [routeId]: Execution }
+
+ // Persist on every change
+ useEffect(() => {
+ saveState({ waypoints, routes, geofences, templates, schedules });
+ }, [waypoints, routes, geofences, templates, schedules]);
+
+ // ── Waypoints ────────────────────────────────────────────────────────────
+
+ const addWaypoint = useCallback((x, y, label) => {
+ const wp = { id: genId(), label: label ?? `WP${Date.now().toString(36).slice(-4).toUpperCase()}`, x, y, dwellTime: 0 };
+ setWaypoints(w => [...w, wp]);
+ return wp.id;
+ }, []);
+
+ const updateWaypoint = useCallback((id, patch) => {
+ setWaypoints(w => w.map(wp => wp.id === id ? { ...wp, ...patch } : wp));
+ }, []);
+
+ const removeWaypoint = useCallback((id) => {
+ setWaypoints(w => w.filter(wp => wp.id !== id));
+ // Remove from any routes
+ setRoutes(r => r.map(rt => ({ ...rt, waypointIds: rt.waypointIds.filter(wid => wid !== id) })));
+ }, []);
+
+ const reorderWaypoints = useCallback((fromIdx, toIdx) => {
+ setWaypoints(w => {
+ const arr = [...w];
+ const [item] = arr.splice(fromIdx, 1);
+ arr.splice(toIdx, 0, item);
+ return arr;
+ });
+ }, []);
+
+ // ── Routes ───────────────────────────────────────────────────────────────
+
+ const addRoute = useCallback((name, waypointIds = [], type = 'loop') => {
+ const id = genId();
+ const color = ROUTE_COLORS[Math.floor(Math.random() * ROUTE_COLORS.length)];
+ setRoutes(r => [...r, { id, name, waypointIds, type, robotId: null, color }]);
+ return id;
+ }, []);
+
+ const updateRoute = useCallback((id, patch) => {
+ setRoutes(r => r.map(rt => rt.id === id ? { ...rt, ...patch } : rt));
+ }, []);
+
+ const removeRoute = useCallback((id) => {
+ setRoutes(r => r.filter(rt => rt.id !== id));
+ setSchedules(s => s.filter(sc => sc.routeId !== id));
+ }, []);
+
+ const addWaypointToRoute = useCallback((routeId, waypointId) => {
+ setRoutes(r => r.map(rt =>
+ rt.id === routeId
+ ? { ...rt, waypointIds: [...rt.waypointIds, waypointId] }
+ : rt
+ ));
+ }, []);
+
+ const removeWaypointFromRoute = useCallback((routeId, idx) => {
+ setRoutes(r => r.map(rt =>
+ rt.id === routeId
+ ? { ...rt, waypointIds: rt.waypointIds.filter((_, i) => i !== idx) }
+ : rt
+ ));
+ }, []);
+
+ // ── Geofences ────────────────────────────────────────────────────────────
+
+ const addGeofence = useCallback((name, type, points) => {
+ const id = genId();
+ const color = type === 'nogo' ? '#ef444488' : '#22c55e88';
+ setGeofences(g => [...g, { id, name, type, points, color }]);
+ return id;
+ }, []);
+
+ const updateGeofence = useCallback((id, patch) => {
+ setGeofences(g => g.map(gf => gf.id === id ? { ...gf, ...patch } : gf));
+ }, []);
+
+ const removeGeofence = useCallback((id) => {
+ setGeofences(g => g.filter(gf => gf.id !== id));
+ }, []);
+
+ // ── Templates ────────────────────────────────────────────────────────────
+
+ const saveTemplate = useCallback((name, description = '') => {
+ const id = genId();
+ setTemplates(t => [
+ ...t.filter(x => x.name !== name),
+ { id, name, description, waypoints, routes, geofences },
+ ]);
+ return id;
+ }, [waypoints, routes, geofences]);
+
+ const loadTemplate = useCallback((id) => {
+ setTemplates(t => {
+ const tmpl = t.find(x => x.id === id);
+ if (!tmpl) return t;
+ setWaypoints(tmpl.waypoints);
+ setRoutes(tmpl.routes);
+ setGeofences(tmpl.geofences);
+ return t;
+ });
+ }, []);
+
+ const removeTemplate = useCallback((id) => {
+ setTemplates(t => t.filter(x => x.id !== id));
+ }, []);
+
+ // ── Schedules ────────────────────────────────────────────────────────────
+
+ const addSchedule = useCallback((routeId, robotId, time, days = [1,2,3,4,5]) => {
+ const id = genId();
+ setSchedules(s => [...s, { id, routeId, robotId, time, days, enabled: true }]);
+ return id;
+ }, []);
+
+ const updateSchedule = useCallback((id, patch) => {
+ setSchedules(s => s.map(sc => sc.id === id ? { ...sc, ...patch } : sc));
+ }, []);
+
+ const removeSchedule = useCallback((id) => {
+ setSchedules(s => s.filter(sc => sc.id !== id));
+ }, []);
+
+ // ── Executions ───────────────────────────────────────────────────────────
+
+ const startExecution = useCallback((routeId, robotId) => {
+ setExecutions(e => ({
+ ...e,
+ [routeId]: { routeId, robotId, currentIdx: 0, state: 'running', startedAt: Date.now(), paused: false },
+ }));
+ }, []);
+
+ const pauseExecution = useCallback((routeId) => {
+ setExecutions(e => ({
+ ...e,
+ [routeId]: { ...e[routeId], state: 'paused', paused: true },
+ }));
+ }, []);
+
+ const resumeExecution = useCallback((routeId) => {
+ setExecutions(e => ({
+ ...e,
+ [routeId]: { ...e[routeId], state: 'running', paused: false },
+ }));
+ }, []);
+
+ const abortExecution = useCallback((routeId) => {
+ setExecutions(e => ({
+ ...e,
+ [routeId]: { ...e[routeId], state: 'aborted' },
+ }));
+ }, []);
+
+ const advanceExecution = useCallback((routeId, idx) => {
+ setExecutions(e => ({
+ ...e,
+ [routeId]: { ...e[routeId], currentIdx: idx },
+ }));
+ }, []);
+
+ // ── JSON export / import ─────────────────────────────────────────────────
+
+ const exportJSON = useCallback(() => {
+ return JSON.stringify({ waypoints, routes, geofences, templates, schedules }, null, 2);
+ }, [waypoints, routes, geofences, templates, schedules]);
+
+ const importJSON = useCallback((json) => {
+ const data = JSON.parse(json);
+ if (data.waypoints) setWaypoints(data.waypoints);
+ if (data.routes) setRoutes(data.routes);
+ if (data.geofences) setGeofences(data.geofences);
+ if (data.templates) setTemplates(data.templates);
+ if (data.schedules) setSchedules(data.schedules);
+ }, []);
+
+ return {
+ // State
+ waypoints, routes, geofences, templates, schedules, executions,
+ // Waypoints
+ addWaypoint, updateWaypoint, removeWaypoint, reorderWaypoints,
+ // Routes
+ addRoute, updateRoute, removeRoute, addWaypointToRoute, removeWaypointFromRoute,
+ // Geofences
+ addGeofence, updateGeofence, removeGeofence,
+ // Templates
+ saveTemplate, loadTemplate, removeTemplate,
+ // Schedules
+ addSchedule, updateSchedule, removeSchedule,
+ // Executions
+ startExecution, pauseExecution, resumeExecution, abortExecution, advanceExecution,
+ // I/O
+ exportJSON, importJSON,
+ };
+}