From 51dcc01bfa4e3cd74381c6863f1f46972948c18c Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 10:04:38 -0500 Subject: [PATCH] =?UTF-8?q?feat(webui):=20mission=20planner=20=E2=80=94=20?= =?UTF-8?q?waypoint=20editor,=20routes,=20geofences,=20schedule=20(Issue?= =?UTF-8?q?=20#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useMissions.js: - Waypoints: click-to-place, drag, dwell time, localStorage persistence - Routes: loop/oneshot/pingpong, per-robot assignment, waypoint sequence - Geofences: polygon draw (no-go / allowed zones) - Templates: save/load profiles + JSON export/import - Schedules: time+day triggers with client-side runner - Executions: running/paused/aborted state per route MissionPlanner.jsx: - Canvas: waypoints, route lines + direction arrows, geofence fills, robot position overlays, scale bar, zoom/pan, execution progress - 7 sub-views: Map | Waypoints | Routes | Geofences | Templates | Schedule | Execute - Execute: start/pause/resume/abort, waypoint-by-waypoint advance, sends /goal_pose (single) and /outdoor/waypoints (patrol PoseArray) - Integrates useFleet for robot selection and /goal_pose publishing App.jsx: adds Missions tab to FLEET group Co-Authored-By: Claude Sonnet 4.6 --- ui/social-bot/src/App.jsx | 14 +- .../src/components/MissionPlanner.jsx | 1186 +++++++++++++++++ ui/social-bot/src/hooks/useMissions.js | 246 ++++ 3 files changed, 1443 insertions(+), 3 deletions(-) create mode 100644 ui/social-bot/src/components/MissionPlanner.jsx create mode 100644 ui/social-bot/src/hooks/useMissions.js 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 */} +
+
+ + {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 && ( +
+