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):
|
* Social tabs (issue #107):
|
||||||
* Status | Faces | Conversation | Personality | Navigation
|
* Status | Faces | Conversation | Personality | Navigation
|
||||||
@ -9,6 +9,9 @@
|
|||||||
*
|
*
|
||||||
* Fleet tabs (issue #139):
|
* Fleet tabs (issue #139):
|
||||||
* Fleet (self-contained via useFleet)
|
* Fleet (self-contained via useFleet)
|
||||||
|
*
|
||||||
|
* Mission tabs (issue #145):
|
||||||
|
* Missions (waypoint editor, route builder, geofence, schedule, execute)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
@ -32,6 +35,9 @@ import { SystemHealth } from './components/SystemHealth.jsx';
|
|||||||
// Fleet panel (issue #139)
|
// Fleet panel (issue #139)
|
||||||
import { FleetPanel } from './components/FleetPanel.jsx';
|
import { FleetPanel } from './components/FleetPanel.jsx';
|
||||||
|
|
||||||
|
// Mission planner (issue #145)
|
||||||
|
import { MissionPlanner } from './components/MissionPlanner.jsx';
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
@ -60,7 +66,8 @@ const TAB_GROUPS = [
|
|||||||
label: 'FLEET',
|
label: 'FLEET',
|
||||||
color: 'text-green-600',
|
color: 'text-green-600',
|
||||||
tabs: [
|
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 === 'control' && <ControlMode subscribe={subscribe} />}
|
||||||
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
|
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
|
||||||
|
|
||||||
{activeTab === 'fleet' && <FleetPanel />}
|
{activeTab === 'fleet' && <FleetPanel />}
|
||||||
|
{activeTab === 'missions' && <MissionPlanner />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── 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