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>
1187 lines
53 KiB
JavaScript
1187 lines
53 KiB
JavaScript
/**
|
||
* 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>
|
||
);
|
||
}
|