feat(webui): mission planner — waypoint editor, routes, geofences, schedule (Issue #145) #155

Merged
sl-jetson merged 1 commits from sl-webui/issue-145-mission-planner into main 2026-03-02 10:07:44 -05:00
3 changed files with 1443 additions and 3 deletions

View File

@ -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' && <ControlMode subscribe={subscribe} />}
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
{activeTab === 'fleet' && <FleetPanel />}
{activeTab === 'fleet' && <FleetPanel />}
{activeTab === 'missions' && <MissionPlanner />}
</main>
{/* ── Footer ── */}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,246 @@
/**
* useMissions.js Mission planner state management.
*
* Manages waypoints, patrol routes, geofences, templates, schedules.
* Persists to localStorage. Provides JSON export/import.
*
* Data shapes:
* Waypoint: { id, label, x, y, dwellTime } (x,y in metres, world frame)
* Route: { id, name, waypointIds[], type, robotId, color }
* type: 'loop' | 'oneshot' | 'pingpong'
* Geofence: { id, name, type, points[{x,y}], color }
* type: 'nogo' | 'allowed'
* Template: { id, name, description, waypoints[], routes[], geofences[] }
* Schedule: { id, routeId, robotId, time, days[], enabled }
* days: 0-6 (Sun-Sat), time: 'HH:MM'
* Execution: { routeId, robotId, currentIdx, state, startedAt, paused }
* state: 'idle'|'running'|'paused'|'done'|'aborted'
*/
import { useState, useCallback, useEffect, useRef } from 'react';
const STORAGE_KEY = 'saltybot_missions_v1';
const EXEC_KEY = 'saltybot_executions_v1';
const ROUTE_COLORS = ['#06b6d4','#f97316','#22c55e','#a855f7','#f59e0b','#ec4899'];
function genId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`;
}
function loadState() {
try {
const s = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (s && typeof s === 'object') return s;
} catch {}
return { waypoints: [], routes: [], geofences: [], templates: [], schedules: [] };
}
function saveState(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function useMissions() {
const [waypoints, setWaypoints] = useState(() => loadState().waypoints);
const [routes, setRoutes] = useState(() => loadState().routes);
const [geofences, setGeofences] = useState(() => loadState().geofences);
const [templates, setTemplates] = useState(() => loadState().templates);
const [schedules, setSchedules] = useState(() => loadState().schedules);
const [executions, setExecutions] = useState({}); // { [routeId]: Execution }
// Persist on every change
useEffect(() => {
saveState({ waypoints, routes, geofences, templates, schedules });
}, [waypoints, routes, geofences, templates, schedules]);
// ── Waypoints ────────────────────────────────────────────────────────────
const addWaypoint = useCallback((x, y, label) => {
const wp = { id: genId(), label: label ?? `WP${Date.now().toString(36).slice(-4).toUpperCase()}`, x, y, dwellTime: 0 };
setWaypoints(w => [...w, wp]);
return wp.id;
}, []);
const updateWaypoint = useCallback((id, patch) => {
setWaypoints(w => w.map(wp => wp.id === id ? { ...wp, ...patch } : wp));
}, []);
const removeWaypoint = useCallback((id) => {
setWaypoints(w => w.filter(wp => wp.id !== id));
// Remove from any routes
setRoutes(r => r.map(rt => ({ ...rt, waypointIds: rt.waypointIds.filter(wid => wid !== id) })));
}, []);
const reorderWaypoints = useCallback((fromIdx, toIdx) => {
setWaypoints(w => {
const arr = [...w];
const [item] = arr.splice(fromIdx, 1);
arr.splice(toIdx, 0, item);
return arr;
});
}, []);
// ── Routes ───────────────────────────────────────────────────────────────
const addRoute = useCallback((name, waypointIds = [], type = 'loop') => {
const id = genId();
const color = ROUTE_COLORS[Math.floor(Math.random() * ROUTE_COLORS.length)];
setRoutes(r => [...r, { id, name, waypointIds, type, robotId: null, color }]);
return id;
}, []);
const updateRoute = useCallback((id, patch) => {
setRoutes(r => r.map(rt => rt.id === id ? { ...rt, ...patch } : rt));
}, []);
const removeRoute = useCallback((id) => {
setRoutes(r => r.filter(rt => rt.id !== id));
setSchedules(s => s.filter(sc => sc.routeId !== id));
}, []);
const addWaypointToRoute = useCallback((routeId, waypointId) => {
setRoutes(r => r.map(rt =>
rt.id === routeId
? { ...rt, waypointIds: [...rt.waypointIds, waypointId] }
: rt
));
}, []);
const removeWaypointFromRoute = useCallback((routeId, idx) => {
setRoutes(r => r.map(rt =>
rt.id === routeId
? { ...rt, waypointIds: rt.waypointIds.filter((_, i) => i !== idx) }
: rt
));
}, []);
// ── Geofences ────────────────────────────────────────────────────────────
const addGeofence = useCallback((name, type, points) => {
const id = genId();
const color = type === 'nogo' ? '#ef444488' : '#22c55e88';
setGeofences(g => [...g, { id, name, type, points, color }]);
return id;
}, []);
const updateGeofence = useCallback((id, patch) => {
setGeofences(g => g.map(gf => gf.id === id ? { ...gf, ...patch } : gf));
}, []);
const removeGeofence = useCallback((id) => {
setGeofences(g => g.filter(gf => gf.id !== id));
}, []);
// ── Templates ────────────────────────────────────────────────────────────
const saveTemplate = useCallback((name, description = '') => {
const id = genId();
setTemplates(t => [
...t.filter(x => x.name !== name),
{ id, name, description, waypoints, routes, geofences },
]);
return id;
}, [waypoints, routes, geofences]);
const loadTemplate = useCallback((id) => {
setTemplates(t => {
const tmpl = t.find(x => x.id === id);
if (!tmpl) return t;
setWaypoints(tmpl.waypoints);
setRoutes(tmpl.routes);
setGeofences(tmpl.geofences);
return t;
});
}, []);
const removeTemplate = useCallback((id) => {
setTemplates(t => t.filter(x => x.id !== id));
}, []);
// ── Schedules ────────────────────────────────────────────────────────────
const addSchedule = useCallback((routeId, robotId, time, days = [1,2,3,4,5]) => {
const id = genId();
setSchedules(s => [...s, { id, routeId, robotId, time, days, enabled: true }]);
return id;
}, []);
const updateSchedule = useCallback((id, patch) => {
setSchedules(s => s.map(sc => sc.id === id ? { ...sc, ...patch } : sc));
}, []);
const removeSchedule = useCallback((id) => {
setSchedules(s => s.filter(sc => sc.id !== id));
}, []);
// ── Executions ───────────────────────────────────────────────────────────
const startExecution = useCallback((routeId, robotId) => {
setExecutions(e => ({
...e,
[routeId]: { routeId, robotId, currentIdx: 0, state: 'running', startedAt: Date.now(), paused: false },
}));
}, []);
const pauseExecution = useCallback((routeId) => {
setExecutions(e => ({
...e,
[routeId]: { ...e[routeId], state: 'paused', paused: true },
}));
}, []);
const resumeExecution = useCallback((routeId) => {
setExecutions(e => ({
...e,
[routeId]: { ...e[routeId], state: 'running', paused: false },
}));
}, []);
const abortExecution = useCallback((routeId) => {
setExecutions(e => ({
...e,
[routeId]: { ...e[routeId], state: 'aborted' },
}));
}, []);
const advanceExecution = useCallback((routeId, idx) => {
setExecutions(e => ({
...e,
[routeId]: { ...e[routeId], currentIdx: idx },
}));
}, []);
// ── JSON export / import ─────────────────────────────────────────────────
const exportJSON = useCallback(() => {
return JSON.stringify({ waypoints, routes, geofences, templates, schedules }, null, 2);
}, [waypoints, routes, geofences, templates, schedules]);
const importJSON = useCallback((json) => {
const data = JSON.parse(json);
if (data.waypoints) setWaypoints(data.waypoints);
if (data.routes) setRoutes(data.routes);
if (data.geofences) setGeofences(data.geofences);
if (data.templates) setTemplates(data.templates);
if (data.schedules) setSchedules(data.schedules);
}, []);
return {
// State
waypoints, routes, geofences, templates, schedules, executions,
// Waypoints
addWaypoint, updateWaypoint, removeWaypoint, reorderWaypoints,
// Routes
addRoute, updateRoute, removeRoute, addWaypointToRoute, removeWaypointFromRoute,
// Geofences
addGeofence, updateGeofence, removeGeofence,
// Templates
saveTemplate, loadTemplate, removeTemplate,
// Schedules
addSchedule, updateSchedule, removeSchedule,
// Executions
startExecution, pauseExecution, resumeExecution, abortExecution, advanceExecution,
// I/O
exportJSON, importJSON,
};
}