feat: add presentation demo mode
This commit is contained in:
@@ -9,58 +9,118 @@ import ScenarioPanel from './ScenarioPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import type { Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
import PresentationModePanel from './PresentationModePanel';
|
||||
import OzonCriteriaPanel from './OzonCriteriaPanel';
|
||||
import type { DemoFocusArea, DemoStep, PresentationMode, Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
|
||||
interface DashboardProps {
|
||||
simulation: SimulationState;
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: ScenarioId;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
demoSteps: DemoStep[];
|
||||
demoStepIndex: number;
|
||||
currentDemoStep: DemoStep;
|
||||
pendingFaultStepId?: string;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onScenarioChange: (scenarioId: ScenarioId) => void;
|
||||
onPresentationModeChange: (mode: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
onDemoStepChange: (index: number) => void;
|
||||
onApplyDemoStepScenario: (step: DemoStep) => void;
|
||||
onRunPreferredAction: (step: DemoStep) => void;
|
||||
onRestartDemo: () => void;
|
||||
}
|
||||
|
||||
function focusClass(mode: PresentationMode, activeFocus: DemoFocusArea, panelFocus: DemoFocusArea | DemoFocusArea[]): string {
|
||||
if (mode !== 'presentation') {
|
||||
return '';
|
||||
}
|
||||
const focusList = Array.isArray(panelFocus) ? panelFocus : [panelFocus];
|
||||
return focusList.includes(activeFocus) || (activeFocus === 'safety' && focusList.includes('scene')) ? 'focus-highlight' : 'presentation-muted';
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
simulation,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
demoSteps,
|
||||
demoStepIndex,
|
||||
currentDemoStep,
|
||||
pendingFaultStepId,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onScenarioChange,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
onDemoStepChange,
|
||||
onApplyDemoStepScenario,
|
||||
onRunPreferredAction,
|
||||
onRestartDemo,
|
||||
}: DashboardProps) {
|
||||
const focusArea = currentDemoStep.focusArea;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell mode-${presentationMode}`}>
|
||||
<Header
|
||||
simulation={simulation}
|
||||
presentationMode={presentationMode}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
onStart={onStart}
|
||||
onPause={onPause}
|
||||
onReset={onReset}
|
||||
onStep={onStep}
|
||||
onPresentationModeChange={onPresentationModeChange}
|
||||
onSafeDemoChange={onSafeDemoChange}
|
||||
/>
|
||||
{presentationMode === 'presentation' ? (
|
||||
<PresentationModePanel
|
||||
steps={demoSteps}
|
||||
currentStep={currentDemoStep}
|
||||
currentIndex={demoStepIndex}
|
||||
activeScenarioId={activeScenarioId}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
pendingFaultStepId={pendingFaultStepId}
|
||||
onPrevious={() => onDemoStepChange(demoStepIndex - 1)}
|
||||
onNext={() => onDemoStepChange(demoStepIndex + 1)}
|
||||
onRestart={onRestartDemo}
|
||||
onApplyScenario={() => onApplyDemoStepScenario(currentDemoStep)}
|
||||
onRunPreferredAction={() => onRunPreferredAction(currentDemoStep)}
|
||||
/>
|
||||
) : null}
|
||||
<main className="dashboard-grid">
|
||||
<section className="scene-column panel panel-scene">
|
||||
<section className={`scene-column panel panel-scene ${focusClass(presentationMode, focusArea, ['scene', 'safety'])}`}>
|
||||
<SorterScene simulation={simulation} />
|
||||
</section>
|
||||
<aside className="right-rail">
|
||||
<CurrentItemPanel simulation={simulation} />
|
||||
<ClassificationPanel simulation={simulation} />
|
||||
<StateMachinePanel currentState={simulation.machineState} />
|
||||
<SensorPanel simulation={simulation} />
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><CurrentItemPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><ClassificationPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><StateMachinePanel currentState={simulation.machineState} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'scene')}><SensorPanel simulation={simulation} /></div>
|
||||
</aside>
|
||||
<section className="bottom-row">
|
||||
<MetricsPanel metrics={simulation.metrics} />
|
||||
<TimelinePanel simulation={simulation} />
|
||||
<PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} />
|
||||
<EventLog events={simulation.events} />
|
||||
<ScenarioPanel
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={onScenarioChange}
|
||||
/>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><MetricsPanel metrics={simulation.metrics} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><TimelinePanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, ['pid', 'safety'])}><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'eventLog')}><EventLog events={simulation.events} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'criteria')}>
|
||||
<OzonCriteriaPanel currentStep={currentDemoStep} />
|
||||
</div>
|
||||
<div className="scenario-panel-wrap">
|
||||
<ScenarioPanel
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={onScenarioChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import type { SimulationState } from '../domain/types';
|
||||
import type { PresentationMode, SimulationState } from '../domain/types';
|
||||
|
||||
interface HeaderProps {
|
||||
simulation: SimulationState;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onPresentationModeChange: (mode: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) {
|
||||
export default function Header({
|
||||
simulation,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
}: HeaderProps) {
|
||||
const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
return (
|
||||
@@ -18,6 +32,15 @@ export default function Header({ simulation, onStart, onPause, onReset, onStep }
|
||||
<h1>OZON Tech Sorter Simulation</h1>
|
||||
</div>
|
||||
<div className="header-status">
|
||||
<div className="mode-toggle" role="group" aria-label="Dashboard mode">
|
||||
<button className={presentationMode === 'engineering' ? 'active' : ''} onClick={() => onPresentationModeChange('engineering')}>Engineering Mode</button>
|
||||
<button className={presentationMode === 'presentation' ? 'active' : ''} onClick={() => onPresentationModeChange('presentation')}>Presentation Mode</button>
|
||||
</div>
|
||||
{presentationMode === 'presentation' ? (
|
||||
<button className={safeDemoEnabled ? 'safe-demo-toggle active' : 'safe-demo-toggle'} onClick={() => onSafeDemoChange(!safeDemoEnabled)}>
|
||||
Safe Demo: {safeDemoEnabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
) : null}
|
||||
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
|
||||
<span />
|
||||
{simulation.systemStatus}
|
||||
|
||||
45
src/components/OzonCriteriaPanel.tsx
Normal file
45
src/components/OzonCriteriaPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { OZON_CRITERIA } from '../data/criteria';
|
||||
import type { CriteriaStatus, DemoStep } from '../domain/types';
|
||||
|
||||
type CriteriaFilter = 'all' | CriteriaStatus;
|
||||
|
||||
const filters: CriteriaFilter[] = ['all', 'covered', 'partially covered', 'demo step available'];
|
||||
|
||||
export default function OzonCriteriaPanel({ currentStep }: { currentStep: DemoStep }) {
|
||||
const [filter, setFilter] = useState<CriteriaFilter>('all');
|
||||
const visibleCriteria = useMemo(
|
||||
() => OZON_CRITERIA.filter((criterion) => filter === 'all' || criterion.status === filter),
|
||||
[filter],
|
||||
);
|
||||
const related = new Set(currentStep.relatedCriteria);
|
||||
|
||||
return (
|
||||
<section className="panel criteria-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">OZON criteria coverage</p>
|
||||
<h2>Evidence checklist</h2>
|
||||
</div>
|
||||
<span className="criteria-count">{visibleCriteria.length}/{OZON_CRITERIA.length}</span>
|
||||
</div>
|
||||
<div className="criteria-filters">
|
||||
{filters.map((item) => (
|
||||
<button key={item} className={filter === item ? 'active' : ''} onClick={() => setFilter(item)}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="criteria-list">
|
||||
{visibleCriteria.map((criterion) => (
|
||||
<article key={criterion.id} className={related.has(criterion.id) ? 'criterion-card related' : 'criterion-card'}>
|
||||
<div>
|
||||
<strong>{criterion.title}</strong>
|
||||
<span className={`criterion-status status-${criterion.status.replaceAll(' ', '-')}`}>{criterion.status}</span>
|
||||
</div>
|
||||
<p>{criterion.evidence}</p>
|
||||
<em>{criterion.relatedScenarioIds.join(', ')} / {criterion.relatedDoc}</em>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
src/components/PresentationModePanel.tsx
Normal file
80
src/components/PresentationModePanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { DemoStep, ScenarioId } from '../domain/types';
|
||||
|
||||
interface PresentationModePanelProps {
|
||||
steps: DemoStep[];
|
||||
currentStep: DemoStep;
|
||||
currentIndex: number;
|
||||
activeScenarioId: ScenarioId;
|
||||
safeDemoEnabled: boolean;
|
||||
pendingFaultStepId?: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onRestart: () => void;
|
||||
onApplyScenario: () => void;
|
||||
onRunPreferredAction: () => void;
|
||||
}
|
||||
|
||||
export default function PresentationModePanel({
|
||||
steps,
|
||||
currentStep,
|
||||
currentIndex,
|
||||
activeScenarioId,
|
||||
safeDemoEnabled,
|
||||
pendingFaultStepId,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onRestart,
|
||||
onApplyScenario,
|
||||
onRunPreferredAction,
|
||||
}: PresentationModePanelProps) {
|
||||
const scenarioApplied = activeScenarioId === currentStep.scenarioId;
|
||||
const pendingFaultConfirmation = pendingFaultStepId === currentStep.id;
|
||||
const isFaultStep = currentStep.scenarioId === 'jam' || currentStep.scenarioId === 'emergency_stop';
|
||||
|
||||
return (
|
||||
<section className="presentation-panel panel">
|
||||
<div className="presentation-step-index">Step {currentIndex + 1} / {steps.length}</div>
|
||||
<div className="presentation-copy">
|
||||
<p className="eyebrow">Presentation Mode</p>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p>{currentStep.explanation}</p>
|
||||
</div>
|
||||
<div className="presentation-proof">
|
||||
<div>
|
||||
<strong>What to watch</strong>
|
||||
<span>{currentStep.whatToWatch}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>What it proves for jury</strong>
|
||||
<span>{currentStep.juryValue}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Current proof</strong>
|
||||
<span>classification decision {'->'} control command {'->'} physical route {'->'} logged result</span>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{currentStep.presenterPhrase}</blockquote>
|
||||
<div className="presentation-meta">
|
||||
<span>Recommended scenario: <strong>{currentStep.scenarioId}</strong></span>
|
||||
<span>Action: <strong>{currentStep.preferredAction}</strong></span>
|
||||
<span>Focus: <strong>{currentStep.focusArea}</strong></span>
|
||||
<span>Safe Demo: <strong>{safeDemoEnabled ? 'ON' : 'OFF'}</strong></span>
|
||||
</div>
|
||||
<div className="criteria-tags">
|
||||
{currentStep.relatedCriteria.map((criterion) => <span key={criterion}>{criterion}</span>)}
|
||||
</div>
|
||||
{safeDemoEnabled && isFaultStep && pendingFaultConfirmation ? (
|
||||
<p className="confirm-note">Click “Confirm fault scenario” again to intentionally switch to {currentStep.scenarioId}.</p>
|
||||
) : null}
|
||||
<div className="presentation-actions">
|
||||
<button onClick={onPrevious} disabled={currentIndex === 0}>Previous step</button>
|
||||
<button onClick={onNext} disabled={currentIndex === steps.length - 1}>Next step</button>
|
||||
<button onClick={onRestart}>Restart demo</button>
|
||||
<button className={scenarioApplied ? 'active' : ''} onClick={onApplyScenario}>
|
||||
{pendingFaultConfirmation ? 'Confirm fault scenario' : scenarioApplied ? 'Scenario applied' : 'Apply scenario'}
|
||||
</button>
|
||||
<button onClick={onRunPreferredAction}>{scenarioApplied ? 'Run suggested action' : 'Apply then run'}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user