Merge pull request 'feat(webui): mission planner — waypoint editor, routes, geofences, schedule (Issue #145)' (#155) from sl-webui/issue-145-mission-planner into main
This commit is contained in:
commit
8facb80eab
@ -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 ── */}
|
||||
|
||||
1186
ui/social-bot/src/components/MissionPlanner.jsx
Normal file
1186
ui/social-bot/src/components/MissionPlanner.jsx
Normal file
File diff suppressed because it is too large
Load Diff
246
ui/social-bot/src/hooks/useMissions.js
Normal file
246
ui/social-bot/src/hooks/useMissions.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user