401 lines
11 KiB
TypeScript
401 lines
11 KiB
TypeScript
// This program has been developed by students from the bachelor Computer Science at Utrecht
|
|
// University within the Software Project course.
|
|
// © Copyright Utrecht University (Department of Information and Computing Sciences)
|
|
import React, { useCallback, useState } from 'react';
|
|
import styles from './MonitoringPage.module.css';
|
|
|
|
// Store & API
|
|
import useProgramStore from "../../utils/programStore";
|
|
import {
|
|
nextPhase,
|
|
useExperimentLogger,
|
|
useStatusLogger,
|
|
pauseExperiment,
|
|
playExperiment,
|
|
type ExperimentStreamData,
|
|
type GoalUpdate,
|
|
type TriggerUpdate,
|
|
type CondNormsStateUpdate,
|
|
type PhaseUpdate
|
|
} from "./MonitoringPageAPI";
|
|
import { graphReducer, runProgram } from '../VisProgPage/VisProgLogic.ts';
|
|
|
|
// Types
|
|
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
|
|
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
|
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
|
|
|
// Sub-components
|
|
import {
|
|
GestureControls,
|
|
SpeechPresets,
|
|
DirectSpeechInput,
|
|
StatusList,
|
|
RobotConnected
|
|
} from './MonitoringPageComponents';
|
|
import ExperimentLogs from "./components/ExperimentLogs.tsx";
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 1. State management
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Manages the state of the active experiment, including phase progression,
|
|
* goal tracking, and stream event listeners.
|
|
*/
|
|
function useExperimentLogic() {
|
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
|
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
|
|
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
|
const setProgramState = useProgramStore((state) => state.setProgramState);
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
|
|
const [goalIndex, setGoalIndex] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [phaseIndex, setPhaseIndex] = useState(0);
|
|
const [isFinished, setIsFinished] = useState(false);
|
|
|
|
const phaseIds = getPhaseIds();
|
|
const phaseNames = getPhaseNames();
|
|
|
|
// --- Stream Handlers ---
|
|
|
|
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
|
|
if (data.type === 'phase_update' && data.id) {
|
|
const payload = data as PhaseUpdate;
|
|
console.log(`${data.type} received, id : ${data.id}`);
|
|
|
|
if (payload.id === "end") {
|
|
setIsFinished(true);
|
|
} else {
|
|
setIsFinished(false);
|
|
const newIndex = getPhaseIds().indexOf(payload.id);
|
|
if (newIndex !== -1) {
|
|
setPhaseIndex(newIndex);
|
|
setGoalIndex(0);
|
|
}
|
|
}
|
|
}
|
|
else if (data.type === 'goal_update') {
|
|
const payload = data as GoalUpdate;
|
|
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
|
|
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
|
|
|
|
console.log(`${data.type} received, id : ${data.id}`);
|
|
|
|
if (gIndex === -1) {
|
|
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
|
|
} else {
|
|
setGoalIndex(gIndex);
|
|
// Mark all previous goals as achieved
|
|
setActiveIds((prev) => {
|
|
const nextState = { ...prev };
|
|
for (let i = 0; i < gIndex; i++) {
|
|
nextState[currentPhaseGoals[i].id] = true;
|
|
}
|
|
return nextState;
|
|
});
|
|
}
|
|
}
|
|
else if (data.type === 'trigger_update') {
|
|
const payload = data as TriggerUpdate;
|
|
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
|
|
}
|
|
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
|
|
|
|
const handleStatusUpdate = useCallback((data: unknown) => {
|
|
const payload = data as CondNormsStateUpdate;
|
|
if (payload.type !== 'cond_norms_state_update') return;
|
|
|
|
setActiveIds((prev) => {
|
|
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
|
|
if (!hasChanges) return prev;
|
|
|
|
const nextState = { ...prev };
|
|
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
|
|
return nextState;
|
|
});
|
|
}, []);
|
|
|
|
// Connect listeners
|
|
useExperimentLogger(handleStreamUpdate);
|
|
useStatusLogger(handleStatusUpdate);
|
|
|
|
// --- Actions ---
|
|
|
|
const resetExperiment = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const phases = graphReducer();
|
|
setProgramState({ phases });
|
|
|
|
setActiveIds({});
|
|
setPhaseIndex(0);
|
|
setGoalIndex(0);
|
|
setIsFinished(false);
|
|
|
|
await runProgram();
|
|
console.log("Experiment & UI successfully reset.");
|
|
} catch (err) {
|
|
console.error("Failed to reset program:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [setProgramState]);
|
|
|
|
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
|
|
try {
|
|
setLoading(true);
|
|
switch (action) {
|
|
case "pause":
|
|
setIsPlaying(false);
|
|
await pauseExperiment();
|
|
break;
|
|
case "play":
|
|
setIsPlaying(true);
|
|
await playExperiment();
|
|
break;
|
|
case "nextPhase":
|
|
await nextPhase();
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return {
|
|
loading,
|
|
isPlaying,
|
|
isFinished,
|
|
phaseIds,
|
|
phaseNames,
|
|
phaseIndex,
|
|
goalIndex,
|
|
activeIds,
|
|
setActiveIds,
|
|
resetExperiment,
|
|
handleControlAction,
|
|
};
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 2. Smaller Presentation Components
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Visual indicator of progress through experiment phases.
|
|
*/
|
|
function PhaseProgressBar({
|
|
phaseIds,
|
|
phaseIndex,
|
|
isFinished
|
|
}: {
|
|
phaseIds: string[],
|
|
phaseIndex: number,
|
|
isFinished: boolean
|
|
}) {
|
|
return (
|
|
<div className={styles.phaseProgress}>
|
|
{phaseIds.map((id, index) => {
|
|
let statusClass = "";
|
|
if (isFinished || index < phaseIndex) statusClass = styles.completed;
|
|
else if (index === phaseIndex) statusClass = styles.current;
|
|
|
|
return (
|
|
<span key={id} className={`${styles.phase} ${statusClass}`}>
|
|
{index + 1}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main control buttons (Play, Pause, Next, Reset).
|
|
*/
|
|
function ControlPanel({
|
|
loading,
|
|
isPlaying,
|
|
onAction,
|
|
onReset
|
|
}: {
|
|
loading: boolean,
|
|
isPlaying: boolean,
|
|
onAction: (a: "pause" | "play" | "nextPhase") => void,
|
|
onReset: () => void
|
|
}) {
|
|
return (
|
|
<div className={styles.experimentControls}>
|
|
<h3>Experiment Controls</h3>
|
|
<div className={styles.controlsButtons}>
|
|
<button
|
|
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
|
onClick={() => onAction("pause")}
|
|
disabled={loading}
|
|
>❚❚</button>
|
|
|
|
<button
|
|
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
|
onClick={() => onAction("play")}
|
|
disabled={loading}
|
|
>▶</button>
|
|
|
|
<button
|
|
className={styles.next}
|
|
onClick={() => onAction("nextPhase")}
|
|
disabled={loading}
|
|
>⏭</button>
|
|
|
|
<button
|
|
className={styles.restartExperiment}
|
|
onClick={onReset}
|
|
disabled={loading}
|
|
>⟲</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Displays lists of Goals, Triggers, and Norms for the current phase.
|
|
*/
|
|
function PhaseDashboard({
|
|
phaseId,
|
|
activeIds,
|
|
setActiveIds,
|
|
goalIndex
|
|
}: {
|
|
phaseId: string,
|
|
activeIds: Record<string, boolean>,
|
|
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
|
|
goalIndex: number
|
|
}) {
|
|
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth);
|
|
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
|
|
const getNorms = useProgramStore((s) => s.getNormsInPhase);
|
|
|
|
// Prepare data view models
|
|
const goals = getGoalsWithDepth(phaseId).map((g) => ({
|
|
...g,
|
|
id: g.id as string,
|
|
name: g.name as string,
|
|
achieved: activeIds[g.id as string] ?? false,
|
|
level: g.level, // Pass this new property to the UI
|
|
}));
|
|
|
|
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
|
|
...t,
|
|
achieved: activeIds[t.id] ?? false,
|
|
}));
|
|
|
|
const norms = (getNorms(phaseId) as NormNodeData[])
|
|
.filter(n => !n.condition)
|
|
.map(n => ({ ...n, label: n.norm }));
|
|
|
|
const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[])
|
|
.filter(n => !!n.condition)
|
|
.map(n => ({
|
|
...n,
|
|
achieved: activeIds[n.id] ?? false
|
|
}));
|
|
|
|
return (
|
|
<>
|
|
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds={setActiveIds} currentGoalIndex={goalIndex} />
|
|
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
|
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
|
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 3. Main Component
|
|
// ----------------------------------------------------------------------
|
|
|
|
const MonitoringPage: React.FC = () => {
|
|
const {
|
|
loading,
|
|
isPlaying,
|
|
isFinished,
|
|
phaseIds,
|
|
phaseNames,
|
|
phaseIndex,
|
|
goalIndex,
|
|
activeIds,
|
|
setActiveIds,
|
|
resetExperiment,
|
|
handleControlAction
|
|
} = useExperimentLogic();
|
|
|
|
if (phaseIds.length === 0) {
|
|
return <p className={styles.empty}>No program loaded.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.dashboardContainer}>
|
|
{/* HEADER */}
|
|
<header className={styles.experimentOverview}>
|
|
<div className={styles.phaseName}>
|
|
<h2>Experiment Overview</h2>
|
|
<p>
|
|
{isFinished ? (
|
|
<strong>Experiment finished</strong>
|
|
) : (
|
|
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
|
|
)}
|
|
</p>
|
|
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
|
|
</div>
|
|
|
|
<ControlPanel
|
|
loading={loading}
|
|
isPlaying={isPlaying}
|
|
onAction={handleControlAction}
|
|
onReset={resetExperiment}
|
|
/>
|
|
|
|
<div className={styles.connectionStatus}>
|
|
<RobotConnected />
|
|
</div>
|
|
</header>
|
|
|
|
{/* MAIN GRID */}
|
|
<main className={styles.phaseOverview}>
|
|
<section className={styles.phaseOverviewText}>
|
|
<h3>Phase Overview</h3>
|
|
</section>
|
|
|
|
{isFinished ? (
|
|
<div className={styles.finishedMessage}>
|
|
<p>All phases have been successfully completed.</p>
|
|
</div>
|
|
) : (
|
|
<PhaseDashboard
|
|
phaseId={phaseIds[phaseIndex]}
|
|
activeIds={activeIds}
|
|
setActiveIds={setActiveIds}
|
|
goalIndex={goalIndex}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* LOGS */}
|
|
<ExperimentLogs />
|
|
|
|
{/* FOOTER */}
|
|
<footer className={styles.controlsSection}>
|
|
<GestureControls />
|
|
<SpeechPresets />
|
|
<DirectSpeechInput />
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default MonitoringPage; |