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

@@ -1,17 +1,27 @@
import { useEffect, useMemo, useState } from 'react';
import Dashboard from './components/Dashboard';
import { DEMO_STEPS } from './data/demoSteps';
import { SCENARIOS } from './data/scenarios';
import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation';
import type { ScenarioId, SimulationState } from './domain/types';
import type { DemoStep, PresentationMode, ScenarioId, SimulationState } from './domain/types';
const TICK_MS = 250;
function isFaultDemoStep(step: DemoStep): boolean {
return step.scenarioId === 'jam' || step.scenarioId === 'emergency_stop';
}
export default function App() {
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [presentationMode, setPresentationMode] = useState<PresentationMode>('engineering');
const [safeDemoEnabled, setSafeDemoEnabled] = useState(true);
const [demoStepIndex, setDemoStepIndex] = useState(0);
const [pendingFaultStepId, setPendingFaultStepId] = useState<string | undefined>();
const activeScenario = useMemo(
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
[activeScenarioId],
);
const currentDemoStep = DEMO_STEPS[demoStepIndex];
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
useEffect(() => {
@@ -34,18 +44,71 @@ export default function App() {
const handlePause = () => setSimulation((current) => setRunning(current, false));
const handleReset = () => setSimulation(createSimulation(activeScenario));
const handleStep = () => setSimulation((current) => stepSimulationToNextState(current));
const handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId);
const handleScenarioChange = (scenarioId: ScenarioId) => {
setPendingFaultStepId(undefined);
setActiveScenarioId(scenarioId);
};
const handleDemoStepChange = (nextIndex: number) => {
setPendingFaultStepId(undefined);
setDemoStepIndex(Math.max(0, Math.min(nextIndex, DEMO_STEPS.length - 1)));
};
const handleApplyDemoStepScenario = (step: DemoStep) => {
if (presentationMode === 'presentation' && safeDemoEnabled && isFaultDemoStep(step) && pendingFaultStepId !== step.id) {
setPendingFaultStepId(step.id);
return;
}
setPendingFaultStepId(undefined);
setActiveScenarioId(step.scenarioId);
};
const handleRunPreferredAction = (step: DemoStep) => {
if (activeScenarioId !== step.scenarioId) {
handleApplyDemoStepScenario(step);
return;
}
if (step.preferredAction === 'start') {
handleStart();
} else if (step.preferredAction === 'pause') {
handlePause();
} else if (step.preferredAction === 'reset') {
handleReset();
} else {
handleStep();
}
};
const handleRestartDemo = () => {
setPendingFaultStepId(undefined);
setDemoStepIndex(0);
setActiveScenarioId(DEMO_STEPS[0].scenarioId);
};
return (
<Dashboard
simulation={simulation}
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
presentationMode={presentationMode}
safeDemoEnabled={safeDemoEnabled}
demoSteps={DEMO_STEPS}
demoStepIndex={demoStepIndex}
currentDemoStep={currentDemoStep}
pendingFaultStepId={pendingFaultStepId}
onStart={handleStart}
onPause={handlePause}
onReset={handleReset}
onStep={handleStep}
onScenarioChange={handleScenarioChange}
onPresentationModeChange={setPresentationMode}
onSafeDemoChange={setSafeDemoEnabled}
onDemoStepChange={handleDemoStepChange}
onApplyDemoStepScenario={handleApplyDemoStepScenario}
onRunPreferredAction={handleRunPreferredAction}
onRestartDemo={handleRestartDemo}
/>
);
}

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

22
src/data/criteria.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { OZON_CRITERIA } from './criteria';
import { SCENARIOS } from './scenarios';
const scenarioIds = new Set(SCENARIOS.map((scenario) => scenario.id));
describe('OZON criteria', () => {
it('contains evidence and valid scenario references', () => {
expect(OZON_CRITERIA.length).toBeGreaterThan(0);
for (const criterion of OZON_CRITERIA) {
expect(criterion.title).toBeTruthy();
expect(criterion.status).toMatch(/covered|partially covered|demo step available/);
expect(criterion.evidence).toBeTruthy();
expect(criterion.relatedDoc).toBeTruthy();
expect(criterion.relatedScenarioIds.length).toBeGreaterThan(0);
for (const scenarioId of criterion.relatedScenarioIds) {
expect(scenarioIds.has(scenarioId)).toBe(true);
}
}
});
});

108
src/data/criteria.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { OzonCriterion } from '../domain/types';
export const OZON_CRITERIA: OzonCriterion[] = [
{
id: 'category-correctness',
title: 'Category correctness',
status: 'covered',
evidence: 'Classifier tests and decision tree prove B/C/D outcomes for mock SKUs.',
relatedScenarioIds: ['normal_flow', 'oversized_item', 'round_object'],
relatedDoc: 'docs/SCENARIOS.md',
},
{
id: 'classification-rules',
title: 'Classification rules: dimensions, roundness, priority',
status: 'covered',
evidence: 'ClassificationPanel shows thresholds, actual values, PASS/FAIL and C priority.',
relatedScenarioIds: ['oversized_item', 'round_object', 'boundary_dimensions'],
relatedDoc: 'docs/ARCHITECTURE.md',
},
{
id: 'boundary-cases',
title: 'Boundary cases',
status: 'covered',
evidence: 'Boundary dimensions scenario and tests cover max boundary and min-width violation.',
relatedScenarioIds: ['boundary_dimensions'],
relatedDoc: 'docs/SCENARIOS.md',
},
{
id: 'physical-routing',
title: 'Physical routing B/C/D',
status: 'covered',
evidence: 'SorterScene displays B straight route, C lower route and D upper route with commands.',
relatedScenarioIds: ['normal_flow', 'oversized_item', 'round_object'],
relatedDoc: 'docs/DEMO_SCRIPT.md',
},
{
id: 'manipulation-logic',
title: 'Manipulation logic',
status: 'covered',
evidence: 'Stop-gate and pusher states are tied to state machine routes.',
relatedScenarioIds: ['oversized_item', 'round_object'],
relatedDoc: 'docs/ARCHITECTURE.md',
},
{
id: 'geometry-variety',
title: 'Geometry variety',
status: 'covered',
evidence: 'Mock items include boxes, pouf, plate, bottle, cylinder, pen and boundary box.',
relatedScenarioIds: ['normal_flow', 'round_object', 'boundary_dimensions'],
relatedDoc: 'docs/SCENARIOS.md',
},
{
id: 'safety-emergency',
title: 'Safety / emergency stop',
status: 'demo step available',
evidence: 'Emergency stop scenario enters EMERGENCY_STOP and requires Reset.',
relatedScenarioIds: ['emergency_stop'],
relatedDoc: 'docs/DEMO_SCRIPT.md',
},
{
id: 'cycle-time-throughput',
title: 'Cycle time and throughput',
status: 'covered',
evidence: 'Metrics cards show processed count, avg cycle time and throughput.',
relatedScenarioIds: ['normal_flow', 'close_items'],
relatedDoc: 'docs/ARCHITECTURE.md',
},
{
id: 'timing-synchronization',
title: 'Timing synchronization',
status: 'covered',
evidence: 'CycleTimeline shows state timestamps, durations and active/done status.',
relatedScenarioIds: ['normal_flow', 'close_items'],
relatedDoc: 'docs/ARCHITECTURE.md',
},
{
id: 'fault-handling',
title: 'Fault handling',
status: 'demo step available',
evidence: 'Jam scenario enters FAULT, logs fault and stops conveyor.',
relatedScenarioIds: ['jam', 'low_confidence'],
relatedDoc: 'docs/JURY_QA.md',
},
{
id: 'cv-actuator-integration',
title: 'CV-to-actuator integration',
status: 'covered',
evidence: 'Pseudo-CV packet feeds classification, which emits ROUTE_TO_* actuator commands.',
relatedScenarioIds: ['normal_flow', 'low_confidence'],
relatedDoc: 'docs/ARCHITECTURE.md',
},
{
id: 'engineering-realism',
title: 'Engineering realism',
status: 'partially covered',
evidence: 'Scaled 2D layout, sensor latency, PID imitation and event trace are present; real hardware is out of MVP scope.',
relatedScenarioIds: ['normal_flow', 'jam', 'emergency_stop'],
relatedDoc: 'docs/JURY_QA.md',
},
{
id: 'reproducibility-docs',
title: 'Reproducibility / docs',
status: 'covered',
evidence: 'README and docs describe architecture, demo script, scenarios and jury Q&A.',
relatedScenarioIds: ['normal_flow', 'boundary_dimensions'],
relatedDoc: 'docs/DEMO_SCRIPT.md',
},
];

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { DEMO_STEPS } from './demoSteps';
import { SCENARIOS } from './scenarios';
const scenarioIds = new Set(SCENARIOS.map((scenario) => scenario.id));
describe('demo steps', () => {
it('have required narrative fields and valid scenarios', () => {
for (const step of DEMO_STEPS) {
expect(step.id).toBeTruthy();
expect(step.title).toBeTruthy();
expect(scenarioIds.has(step.scenarioId)).toBe(true);
expect(step.relatedCriteria.length).toBeGreaterThan(0);
expect(step.presenterPhrase).toBeTruthy();
}
});
});

124
src/data/demoSteps.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { DemoStep } from '../domain/types';
export const DEMO_STEPS: DemoStep[] = [
{
id: 'system-overview',
title: 'System overview',
scenarioId: 'normal_flow',
preferredAction: 'reset',
focusArea: 'scene',
explanation: 'The line is presented as detection -> classification -> actuator command -> physical route.',
whatToWatch: 'Scaled SVG scene, sensor positions, stop-gate, pushers and B/C/D routing arrows.',
juryValue: 'Shows that the project is an engineering simulation of a sorting cell, not a decorative animation.',
relatedCriteria: ['engineering-realism', 'cv-actuator-integration', 'physical-routing'],
presenterPhrase: 'We model the whole loop: the item is detected, classified by rules, converted to an actuator command and routed physically.',
},
{
id: 'normal-item-to-b',
title: 'Normal item to B',
scenarioId: 'normal_flow',
preferredAction: 'step',
focusArea: 'classification',
explanation: 'A regular box passes dimensions and roundness checks, so it stays on the main sorter path.',
whatToWatch: 'Decision tree PASS/PASS and ROUTE_TO_B command in the scene and event log.',
juryValue: 'Proves the happy path and shows category correctness for standard goods.',
relatedCriteria: ['category-correctness', 'classification-rules', 'physical-routing'],
presenterPhrase: 'For a normal SKU, dimensions pass and circular section is not detected, so the gate opens to B.',
},
{
id: 'oversized-to-c',
title: 'Oversized item to C',
scenarioId: 'oversized_item',
preferredAction: 'step',
focusArea: 'classification',
explanation: 'Oversized goods fail the first rule and are routed to roll-cage C before shape is considered.',
whatToWatch: 'Dimensions FAIL, category C, pusher C and route arrow to the lower roll-cage.',
juryValue: 'Shows rule priority and safe diversion for non-compliant dimensions.',
relatedCriteria: ['classification-rules', 'boundary-cases', 'manipulation-logic'],
presenterPhrase: 'C has priority because geometry safety is checked before shape: this item cannot continue on the main sorter.',
},
{
id: 'round-object-to-d',
title: 'Round object to D',
scenarioId: 'round_object',
preferredAction: 'step',
focusArea: 'scene',
explanation: 'Roundness >= 0.8 after valid dimensions sends the item to D for repack/shape issue handling.',
whatToWatch: 'Roundness FAIL for B, category D and the upper route/pusher D command.',
juryValue: 'Shows how the circle-section rule becomes a physical route.',
relatedCriteria: ['classification-rules', 'geometry-variety', 'physical-routing'],
presenterPhrase: 'The dimensions fit, but roundness is high, so the system sends it to D for do-upakovka.',
},
{
id: 'boundary-dimensions',
title: 'Boundary dimensions',
scenarioId: 'boundary_dimensions',
preferredAction: 'step',
focusArea: 'classification',
explanation: 'Items at the edge of the allowed envelope prove the rule implementation is stable.',
whatToWatch: '450 x 320 x 320 passes, while width 9 mm fails min width and goes to C.',
juryValue: 'Shows deterministic boundary behavior rather than visual guessing.',
relatedCriteria: ['boundary-cases', 'category-correctness', 'reproducibility-docs'],
presenterPhrase: 'The exact boundary is accepted, but one millimeter below minimum width is rejected.',
},
{
id: 'low-confidence-fallback',
title: 'Low confidence fallback',
scenarioId: 'low_confidence',
preferredAction: 'step',
focusArea: 'eventLog',
explanation: 'Low pseudo-CV confidence raises a warning but does not block rule-based classification.',
whatToWatch: 'Warning message, fallback note and final category in classification/event log.',
juryValue: 'Shows graceful degradation when CV is uncertain.',
relatedCriteria: ['cv-actuator-integration', 'fault-handling', 'classification-rules'],
presenterPhrase: 'CV confidence is low, so we flag it, but dimensions and roundness still produce a deterministic decision.',
},
{
id: 'close-items-queue',
title: 'Close items queue',
scenarioId: 'close_items',
preferredAction: 'start',
focusArea: 'timeline',
explanation: 'Close spacing creates a queue warning and items are still processed one cycle at a time.',
whatToWatch: 'Queue length, queue delay, spacing warning and sequential timeline.',
juryValue: 'Shows robustness for a common operational issue without overcomplicated physics.',
relatedCriteria: ['timing-synchronization', 'cycle-time-throughput', 'fault-handling'],
presenterPhrase: 'The line detects close spacing, holds the queue and keeps the state machine deterministic.',
},
{
id: 'jam-fault-handling',
title: 'Jam / fault handling',
scenarioId: 'jam',
preferredAction: 'step',
focusArea: 'safety',
explanation: 'A jam at the stop-gate moves the system to FAULT and stops the conveyor.',
whatToWatch: 'FAULT state, red scene overlay, PID target 0 and Reset required.',
juryValue: 'Shows fail-safe behavior and operator recovery path.',
relatedCriteria: ['fault-handling', 'safety-emergency', 'pid-synchronization'],
presenterPhrase: 'When the gate jams, the safest command is to stop motion and require reset.',
},
{
id: 'emergency-stop',
title: 'Emergency stop',
scenarioId: 'emergency_stop',
preferredAction: 'step',
focusArea: 'safety',
explanation: 'Emergency stop interrupts the line and brings motion toward zero.',
whatToWatch: 'EMERGENCY_STOP state, stopped conveyor and PID speed falling to zero.',
juryValue: 'Shows explicit safety handling for abnormal operation.',
relatedCriteria: ['safety-emergency', 'fault-handling', 'engineering-realism'],
presenterPhrase: 'Emergency stop is not another route; it is a safety state that freezes the line until reset.',
},
{
id: 'performance-sync',
title: 'Performance and synchronization',
scenarioId: 'normal_flow',
preferredAction: 'start',
focusArea: 'pid',
explanation: 'Timeline, latency cards and PID graph show how the simulated control loop stays synchronized.',
whatToWatch: 'CycleTimeline, CV latency, actuator latency, throughput and PID speed curve.',
juryValue: 'Shows that the demo explains timing and throughput, not only classification.',
relatedCriteria: ['cycle-time-throughput', 'timing-synchronization', 'engineering-realism'],
presenterPhrase: 'Here we close the loop: decisions are timestamped, actuators have latency and conveyor control is visible.',
},
];

View File

@@ -186,3 +186,31 @@ export interface SimulationState {
events: EventLogEntry[];
activeRoute?: Category;
}
export type PresentationMode = 'engineering' | 'presentation';
export type DemoPreferredAction = 'start' | 'step' | 'reset' | 'pause';
export type DemoFocusArea = 'scene' | 'classification' | 'timeline' | 'pid' | 'eventLog' | 'criteria' | 'safety';
export type CriteriaStatus = 'covered' | 'partially covered' | 'demo step available';
export interface DemoStep {
id: string;
title: string;
scenarioId: ScenarioId;
preferredAction: DemoPreferredAction;
focusArea: DemoFocusArea;
explanation: string;
whatToWatch: string;
juryValue: string;
relatedCriteria: string[];
presenterPhrase: string;
}
export interface OzonCriterion {
id: string;
title: string;
status: CriteriaStatus;
evidence: string;
relatedScenarioIds: ScenarioId[];
relatedDoc: string;
}

View File

@@ -852,3 +852,274 @@ button:disabled {
grid-template-columns: 1fr 1fr;
}
}
.mode-toggle {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid #284762;
border-radius: 999px;
background: #071423;
}
.mode-toggle button,
.safe-demo-toggle {
min-height: 30px;
padding: 4px 10px;
font-size: 11px;
}
.mode-toggle button.active,
.safe-demo-toggle.active,
.presentation-actions button.active {
border-color: var(--cyan);
background: rgba(56, 189, 248, 0.14);
color: #e8f7ff;
}
.mode-presentation .dashboard-grid {
margin-top: 14px;
}
.presentation-panel {
display: grid;
grid-template-columns: 90px minmax(260px, 0.9fr) minmax(420px, 1.4fr);
gap: 14px;
margin-top: 16px;
padding: 16px;
border-color: rgba(56, 189, 248, 0.55);
background:
linear-gradient(135deg, rgba(56, 189, 248, 0.1), rgba(12, 23, 37, 0.92) 32%),
var(--panel);
}
.presentation-step-index {
display: grid;
place-items: center;
min-height: 92px;
border: 1px solid #2d5572;
border-radius: 16px;
color: var(--cyan);
font-size: 15px;
font-weight: 950;
text-align: center;
}
.presentation-copy h2 {
margin: 4px 0 8px;
font-size: 26px;
}
.presentation-copy p,
.presentation-proof span,
.presentation-meta span,
.presentation-panel blockquote {
color: #c6d7e8;
font-size: 13px;
line-height: 1.45;
}
.presentation-proof {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.presentation-proof div {
padding: 10px;
border: 1px solid #284762;
border-radius: 13px;
background: #081422;
}
.presentation-proof strong,
.presentation-proof span {
display: block;
}
.presentation-proof strong {
margin-bottom: 5px;
color: #f1f8ff;
font-size: 12px;
}
.presentation-panel blockquote {
grid-column: 2 / -1;
margin: 0;
padding: 10px 12px;
border-left: 3px solid var(--cyan);
border-radius: 10px;
background: rgba(56, 189, 248, 0.08);
font-weight: 750;
}
.presentation-meta,
.criteria-tags,
.presentation-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.presentation-meta span,
.criteria-tags span,
.criteria-count {
padding: 5px 9px;
border: 1px solid #284762;
border-radius: 999px;
background: #071423;
color: #b9cce0;
font-size: 11px;
font-weight: 750;
}
.criteria-tags span {
border-color: rgba(74, 222, 128, 0.38);
color: #bbf7d0;
}
.confirm-note {
grid-column: 1 / -1;
padding: 10px 12px;
border: 1px solid rgba(245, 158, 11, 0.65);
border-radius: 12px;
background: rgba(245, 158, 11, 0.1);
color: #fde68a;
font-size: 13px;
font-weight: 800;
}
.presentation-actions button {
min-height: 34px;
padding: 6px 12px;
font-size: 12px;
}
.focus-highlight > .panel,
.focus-highlight.panel {
border-color: var(--cyan);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.3), 0 0 30px rgba(56, 189, 248, 0.12);
}
.focus-highlight > .panel::before,
.focus-highlight.panel::before {
content: "Current demo focus";
display: inline-flex;
margin: -2px 0 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.16);
color: var(--cyan);
font-size: 10px;
font-weight: 950;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mode-presentation .presentation-muted > .panel,
.mode-presentation .presentation-muted.panel {
opacity: 0.74;
}
.criteria-panel {
padding: 14px;
}
.criteria-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.criteria-filters button {
min-height: 28px;
padding: 4px 8px;
font-size: 11px;
}
.criteria-filters button.active {
border-color: var(--cyan);
background: rgba(56, 189, 248, 0.12);
}
.criteria-list {
display: grid;
gap: 8px;
max-height: 290px;
overflow: auto;
}
.criterion-card {
padding: 10px;
border: 1px solid #243e57;
border-radius: 13px;
background: #081422;
}
.criterion-card.related {
border-color: var(--green);
background: rgba(74, 222, 128, 0.07);
}
.criterion-card div {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.criterion-card strong,
.criterion-card p,
.criterion-card em {
display: block;
font-size: 12px;
}
.criterion-card p {
color: #c1d1e3;
line-height: 1.35;
}
.criterion-card em {
margin-top: 6px;
color: var(--muted);
font-style: normal;
}
.criterion-status {
white-space: nowrap;
color: var(--cyan);
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
}
.status-covered { color: var(--green); }
.status-partially-covered { color: var(--orange); }
.status-demo-step-available { color: var(--purple); }
.scenario-panel-wrap {
grid-column: auto;
}
@media (max-width: 1500px) {
.presentation-panel {
grid-template-columns: 1fr;
}
.presentation-panel blockquote,
.presentation-meta,
.criteria-tags,
.presentation-actions,
.confirm-note {
grid-column: 1;
}
.presentation-proof {
grid-template-columns: 1fr;
}
}