sl-webui 51dcc01bfa feat(webui): mission planner — waypoint editor, routes, geofences, schedule (Issue #145)
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 <noreply@anthropic.com>
2026-03-02 10:04:38 -05:00

1187 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (
<div className="relative">
{/* Toolbar */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => 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">+</button>
<span className="text-gray-500 text-xs w-14 text-center">{zoom.toFixed(0)}px/m</span>
<button onClick={() => 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"></button>
<button onClick={() => { 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</button>
</div>
{mode === 'waypoint' && (
<span className="text-cyan-600 text-xs">Click map to place waypoint · Drag to move</span>
)}
{mode === 'geofence' && (
<span className="text-amber-600 text-xs">Click to add vertices · Double-click to close polygon</span>
)}
</div>
<div className="bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
<canvas
ref={canvasRef} width={640} height={420}
className={`w-full block ${mode==='waypoint'?'cursor-crosshair':mode==='geofence'?'cursor-crosshair':'cursor-grab active:cursor-grabbing'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => { dragging.current = null; }}
onClick={handleClick}
onDoubleClick={handleDblClick}
onWheel={handleWheel}
/>
</div>
{/* Legend */}
<div className="flex gap-4 text-xs text-gray-600 mt-1.5 flex-wrap">
<span className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-cyan-400"/>Waypoint</span>
<span className="flex items-center gap-1"><div className="w-3 h-0.5 bg-green-500"/>Route</span>
<span className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm" style={{background:'rgba(239,68,68,0.3)',border:'1px solid #ef4444'}}/>No-go</span>
<span className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm" style={{background:'rgba(34,197,94,0.3)',border:'1px solid #22c55e'}}/>Allowed</span>
<span className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-orange-400"/>Robot</span>
</div>
</div>
);
}
// ── 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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="text-cyan-700 text-xs font-bold tracking-widest">WAYPOINTS ({waypoints.length})</div>
<button
onClick={() => 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</button>
</div>
{waypoints.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
No waypoints. Click "Place on Map" then click the canvas.
</div>
) : (
<div className="space-y-1 max-h-72 overflow-y-auto">
{waypoints.map((wp, i) => (
<div key={wp.id} className="flex items-center gap-2 bg-gray-950 border border-gray-800 rounded px-3 py-2 text-xs">
<span className="text-gray-600 w-5">{i+1}</span>
{editing === wp.id ? (
<>
<input
className="bg-gray-900 border border-cyan-800 rounded px-1.5 py-0.5 text-cyan-200 w-20 focus:outline-none text-xs"
value={editForm.label}
onChange={e => setEditForm(f=>({...f,label:e.target.value}))}
/>
<input type="number" min="0" step="1"
className="bg-gray-900 border border-gray-700 rounded px-1.5 py-0.5 text-gray-200 w-16 focus:outline-none text-xs"
value={editForm.dwellTime}
onChange={e => setEditForm(f=>({...f,dwellTime:e.target.value}))}
placeholder="dwell s"
/>
<button onClick={() => applyEdit(wp.id)} className="text-cyan-400 hover:text-cyan-200 px-1"></button>
<button onClick={() => setEditing(null)} className="text-gray-600 hover:text-gray-400 px-1"></button>
</>
) : (
<>
<span className="font-bold text-cyan-300 w-20 truncate">{wp.label}</span>
<span className="text-gray-500 font-mono flex-1">
({wp.x.toFixed(1)}, {wp.y.toFixed(1)})
</span>
{wp.dwellTime > 0 && <span className="text-amber-500">{wp.dwellTime}s dwell</span>}
<button onClick={() => startEdit(wp)} className="text-gray-600 hover:text-cyan-400 px-1"></button>
<button onClick={() => removeWaypoint(wp.id)} className="text-gray-700 hover:text-red-500 px-1"></button>
</>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">PATROL ROUTES</div>
{/* Create route */}
<div className="flex gap-2">
<input
className="flex-1 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
placeholder="Route name…"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key==='Enter' && newName.trim()) { addRoute(newName.trim()); setNewName(''); }}}
/>
<button
disabled={!newName.trim()}
onClick={() => { 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</button>
</div>
{routes.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
No routes defined. Create a route and add waypoints to it.
</div>
) : (
<div className="space-y-3">
{routes.map(rt => (
<div key={rt.id}
className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-2"
style={{ borderLeftColor: rt.color, borderLeftWidth: 3 }}
>
{/* Route header */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-gray-200 text-sm">{rt.name}</span>
<select
className="bg-gray-900 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 focus:outline-none"
value={rt.type}
onChange={e => updateRoute(rt.id, {type: e.target.value})}
>
{Object.entries(ROUTE_TYPE_LABELS).map(([v,l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<select
className="bg-gray-900 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 focus:outline-none"
value={rt.robotId ?? ''}
onChange={e => updateRoute(rt.id, {robotId: e.target.value || null})}
>
<option value=""> No robot </option>
{robots.map(r => <option key={r.id} value={r.id}>{r.name||r.id}</option>)}
</select>
<button onClick={() => 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
</button>
<button onClick={() => removeRoute(rt.id)}
className="text-gray-700 hover:text-red-500 text-xs px-1"></button>
</div>
{/* Waypoint sequence */}
<div className="flex flex-wrap gap-1 items-center min-h-6">
{rt.waypointIds.map((wid, idx) => {
const wp = wpMap[wid];
return (
<div key={`${wid}-${idx}`} className="flex items-center gap-0.5">
{idx > 0 && <span className="text-gray-700"></span>}
<span className="bg-gray-900 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-cyan-300 flex items-center gap-1">
{wp?.label ?? '?'}
<button onClick={() => removeWaypointFromRoute(rt.id, idx)}
className="text-gray-700 hover:text-red-500">×</button>
</span>
</div>
);
})}
{rt.type === 'loop' && rt.waypointIds.length >= 2 && (
<span className="text-gray-600 text-xs"></span>
)}
</div>
{/* Add waypoint to route */}
{waypoints.length > 0 && (
<select
className="bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 focus:outline-none w-full"
value=""
onChange={e => { if (e.target.value) addWaypointToRoute(rt.id, e.target.value); }}
>
<option value="">+ Add waypoint</option>
{waypoints.map(w => <option key={w.id} value={w.id}>{w.label}</option>)}
</select>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── Geofences sub-view ────────────────────────────────────────────────────────
function GeofencesView({ geofences, updateGeofence, removeGeofence, setMapMode, setActiveGeofenceId }) {
const [pendingType, setPendingType] = useState('nogo');
const [pendingName, setPendingName] = useState('');
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-cyan-700 text-xs font-bold tracking-widest">GEOFENCES</div>
<div className="ml-auto flex gap-2 items-center">
<input
className="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none w-28"
placeholder="Name…"
value={pendingName}
onChange={e => setPendingName(e.target.value)}
/>
<select
className="bg-gray-900 border border-gray-700 rounded px-1.5 py-1 text-xs text-gray-300 focus:outline-none"
value={pendingType}
onChange={e => setPendingType(e.target.value)}
>
<option value="nogo">No-go zone</option>
<option value="allowed">Allowed area</option>
</select>
<button
onClick={() => {
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</button>
</div>
</div>
{geofences.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
No geofences. Enter a name, pick type, click Draw, then draw on the map.
</div>
) : (
<div className="space-y-1.5">
{geofences.map(gf => (
<div key={gf.id} className="flex items-center gap-2 bg-gray-950 border border-gray-800 rounded px-3 py-2 text-xs">
<div className="w-2 h-2 rounded-sm shrink-0"
style={{ background: gf.type==='nogo'?'#ef4444':'#22c55e' }} />
<span className="font-bold text-gray-200 flex-1">{gf.name}</span>
<span className={`text-xs px-1.5 rounded border ${gf.type==='nogo'?'bg-red-950 border-red-800 text-red-400':'bg-green-950 border-green-800 text-green-400'}`}>
{gf.type==='nogo'?'no-go':'allowed'}
</span>
<span className="text-gray-600">{gf.points.length} pts</span>
<button onClick={() => setActiveGeofenceId(gf.id)} className="text-gray-600 hover:text-cyan-400 px-1">🗺</button>
<button onClick={() => removeGeofence(gf.id)} className="text-gray-700 hover:text-red-500 px-1"></button>
</div>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">MISSION TEMPLATES</div>
{/* Save current state */}
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-2">
<div className="text-gray-500 text-xs font-bold">Save Current Mission</div>
<div className="flex gap-2">
<input
className="flex-1 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
placeholder="Template name…"
value={saveName} onChange={e=>setSaveName(e.target.value)}
/>
<input
className="flex-1 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
placeholder="Description (optional)"
value={saveDesc} onChange={e=>setSaveDesc(e.target.value)}
/>
<button
disabled={!saveName.trim()}
onClick={() => { saveTemplate(saveName.trim(), saveDesc); setSaveName(''); setSaveDesc(''); showMsg('Saved'); }}
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"
>Save</button>
</div>
</div>
{msg && <div className="text-green-400 text-xs">{msg}</div>}
{/* User templates */}
{templates.length > 0 && (
<div className="space-y-1.5">
<div className="text-gray-600 text-xs">Saved Templates</div>
{templates.map(t => (
<div key={t.id} className="flex items-center gap-2 bg-gray-950 border border-gray-800 rounded px-3 py-2 text-xs">
<div className="flex-1">
<span className="font-bold text-gray-200">{t.name}</span>
{t.description && <span className="text-gray-600 ml-2">{t.description}</span>}
</div>
<span className="text-gray-700">{t.waypoints?.length??0}wp {t.routes?.length??0}rt</span>
<button onClick={() => { 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</button>
<button onClick={() => removeTemplate(t.id)}
className="text-gray-700 hover:text-red-500 px-1"></button>
</div>
))}
</div>
)}
{/* Built-in starters */}
<div className="space-y-1.5">
<div className="text-gray-600 text-xs">Mission Starters</div>
{BUILTIN_TEMPLATES.map(t => (
<div key={t.id} className="flex items-center gap-2 bg-gray-950 border border-gray-800 rounded px-3 py-2 text-xs">
<div className="flex-1">
<span className="font-bold text-gray-300">{t.name}</span>
<span className="text-gray-600 ml-2">{t.description}</span>
</div>
<span className="text-gray-700 text-xs italic">starter</span>
</div>
))}
</div>
{/* Import / Export */}
<div className="flex gap-2 pt-1">
<button onClick={handleExport}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs">
Export JSON
</button>
<button onClick={() => 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
</button>
</div>
{showImport && (
<div className="space-y-2">
<textarea rows={5}
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-xs text-gray-200 focus:outline-none font-mono"
placeholder='Paste mission JSON here…'
value={importText} onChange={e=>setImportText(e.target.value)}
/>
<button onClick={handleImport}
className="px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs">
Import
</button>
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">MISSION SCHEDULE</div>
{/* Add schedule */}
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-3">
<div className="text-gray-500 text-xs font-bold">Add Time Trigger</div>
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1">
<span className="text-gray-600 text-xs">Route</span>
<select className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none"
value={form.routeId} onChange={e=>set('routeId',e.target.value)}>
<option value=""> Select route </option>
{routes.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-600 text-xs">Robot</span>
<select className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none"
value={form.robotId} onChange={e=>set('robotId',e.target.value)}>
<option value=""> Any robot </option>
{robots.map(r => <option key={r.id} value={r.id}>{r.name||r.id}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-600 text-xs">Time</span>
<input type="time" className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none"
value={form.time} onChange={e=>set('time',e.target.value)} />
</label>
<label className="space-y-1">
<span className="text-gray-600 text-xs">Days</span>
<div className="flex gap-1">
{DAY_LABELS.map((d,i) => (
<button key={i}
onClick={() => 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}</button>
))}
</div>
</label>
</div>
<button
disabled={!form.routeId}
onClick={handleAdd}
className="w-full py-1.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold disabled:opacity-40"
>Add Schedule</button>
</div>
{/* Schedule list */}
{schedules.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
No schedules configured.
</div>
) : (
<div className="space-y-1.5">
{schedules.map(sc => {
const rt = routeMap[sc.routeId];
const rb = sc.robotId ? robotMap[sc.robotId] : null;
return (
<div key={sc.id} className="flex items-center gap-2 bg-gray-950 border border-gray-800 rounded px-3 py-2 text-xs">
<button
onClick={() => updateSchedule(sc.id, { enabled: !sc.enabled })}
className={`w-8 h-4 rounded-full relative transition-colors ${sc.enabled?'bg-cyan-700':'bg-gray-700'}`}
>
<span className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-all ${sc.enabled?'left-4':'left-0.5'}`}/>
</button>
<span className="font-bold text-amber-300 w-12 shrink-0">{sc.time}</span>
<span className="text-gray-500 text-xs">
{sc.days.map(d=>DAY_LABELS[d]).join(' ')}
</span>
<span className="text-cyan-300 flex-1 truncate">{rt?.name ?? '?'}</span>
{rb && <span className="text-gray-500">{rb.name||rb.id}</span>}
<button onClick={() => removeSchedule(sc.id)}
className="text-gray-700 hover:text-red-500 px-1"></button>
</div>
);
})}
</div>
)}
<div className="text-gray-700 text-xs">
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.
</div>
</div>
);
}
// ── 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 (
<div className="space-y-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">LIVE EXECUTION</div>
{routes.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
No routes defined. Create routes in the Routes tab first.
</div>
) : (
<div className="space-y-2">
{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 (
<div key={rt.id} className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-2"
style={{ borderLeftColor: rt.color, borderLeftWidth: 3 }}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-gray-200 text-sm">{rt.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded border font-bold ${
state==='running' ? 'bg-green-950 border-green-700 text-green-400' :
state==='paused' ? 'bg-amber-950 border-amber-700 text-amber-400' :
state==='done' ? 'bg-cyan-950 border-cyan-700 text-cyan-400' :
state==='aborted' ? 'bg-red-950 border-red-700 text-red-400' :
'bg-gray-900 border-gray-700 text-gray-500'
}`}>{state.toUpperCase()}</span>
<span className="text-gray-500 text-xs">{ROUTE_TYPE_LABELS[rt.type]}</span>
<span className="text-gray-600 text-xs ml-auto">{progress}</span>
</div>
{/* Robot assignment */}
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-600">Robot:</span>
{robot ? (
<span className={connected ? 'text-green-400' : 'text-red-400'}>
{connected ? '● ' : '○ '}{robot.name||robot.id}
</span>
) : (
<span className="text-gray-600"> unassigned (go to Routes tab)</span>
)}
</div>
{/* Progress bar */}
{pts.length > 0 && (
<div>
<div className="w-full h-1.5 bg-gray-800 rounded overflow-hidden">
<div className="h-full rounded transition-all" style={{
width: exec ? `${((exec.currentIdx+1)/pts.length)*100}%` : '0%',
background: rt.color
}} />
</div>
{exec && state==='running' && (
<div className="text-xs text-gray-500 mt-1">
{wpMap[rt.waypointIds[exec.currentIdx]]?.label ?? '?'}
</div>
)}
</div>
)}
{/* Controls */}
<div className="flex gap-2">
{state === 'idle' || state === 'done' || state === 'aborted' ? (
<button
disabled={!connected || pts.length === 0}
onClick={() => 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</button>
) : state === 'running' ? (
<>
<button onClick={() => 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</button>
<button onClick={() => handleAdvance(rt)}
className="px-3 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs">
Next WP</button>
<button onClick={() => 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</button>
</>
) : state === 'paused' ? (
<>
<button onClick={() => 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</button>
<button onClick={() => 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</button>
</>
) : null}
{/* Send full patrol route */}
{connected && pts.length >= 2 && (state==='idle'||state==='done'||state==='aborted') && (
<button
onClick={() => {
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</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
{/* Sub-nav */}
<div className="flex gap-0.5 border-b border-gray-800 overflow-x-auto">
{VIEWS.map(v => (
<button key={v} onClick={() => 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()}</button>
))}
{mapMode !== 'view' && (
<button onClick={() => 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
</button>
)}
</div>
{/* Map always visible */}
<PlannerCanvas
waypoints={missions.waypoints}
routes={missions.routes}
geofences={missions.geofences}
executions={missions.executions}
robots={robots}
robotData={robotData}
mode={canvasMode}
activeRouteId={activeRouteId}
activeGeofenceId={activeGeofenceId}
onClickWorld={handleClickWorld}
onMoveWaypoint={missions.updateWaypoint}
/>
{/* Sub-view panel */}
<div>
{view === 'Waypoints' && (
<WaypointsView
waypoints={missions.waypoints}
updateWaypoint={missions.updateWaypoint}
removeWaypoint={missions.removeWaypoint}
reorderWaypoints={missions.reorderWaypoints}
setMapMode={() => setMapMode('waypoint')}
/>
)}
{view === 'Routes' && (
<RoutesView
waypoints={missions.waypoints}
routes={missions.routes}
addRoute={missions.addRoute}
updateRoute={missions.updateRoute}
removeRoute={missions.removeRoute}
addWaypointToRoute={missions.addWaypointToRoute}
removeWaypointFromRoute={missions.removeWaypointFromRoute}
robots={robots}
setActiveRouteId={setActiveRouteId}
/>
)}
{view === 'Geofences' && (
<GeofencesView
geofences={missions.geofences}
updateGeofence={missions.updateGeofence}
removeGeofence={missions.removeGeofence}
setMapMode={setMapModeWrapped}
setActiveGeofenceId={setActiveGeofenceId}
/>
)}
{view === 'Templates' && (
<TemplatesView
templates={missions.templates}
saveTemplate={missions.saveTemplate}
loadTemplate={missions.loadTemplate}
removeTemplate={missions.removeTemplate}
exportJSON={missions.exportJSON}
importJSON={missions.importJSON}
/>
)}
{view === 'Schedule' && (
<ScheduleView
schedules={missions.schedules}
routes={missions.routes}
robots={robots}
addSchedule={missions.addSchedule}
updateSchedule={missions.updateSchedule}
removeSchedule={missions.removeSchedule}
/>
)}
{view === 'Execute' && (
<ExecuteView
routes={missions.routes}
waypoints={missions.waypoints}
robots={robots}
connections={connections}
executions={missions.executions}
publishRobot={publishRobot}
startExecution={missions.startExecution}
pauseExecution={missions.pauseExecution}
resumeExecution={missions.resumeExecution}
abortExecution={missions.abortExecution}
advanceExecution={missions.advanceExecution}
/>
)}
{view === 'Map' && (
<div className="flex flex-wrap gap-2 text-xs">
<button onClick={() => 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
</button>
<button onClick={() => { 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
</button>
<button onClick={() => setView('Routes')}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs">
Manage Routes
</button>
<button onClick={() => setView('Execute')}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs">
Execute
</button>
<div className="text-gray-700 self-center ml-2">
{missions.waypoints.length} wpts · {missions.routes.length} routes · {missions.geofences.length} fences
</div>
</div>
)}
</div>
</div>
);
}