feat: add presentation demo mode

This commit is contained in:
deploy
2026-07-04 01:09:10 +02:00
parent 77069748ee
commit f3e2d16059
15 changed files with 1064 additions and 87 deletions

View File

@@ -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>

View File

@@ -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}

View 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>
);
}

View 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>
);
}