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

@@ -2,7 +2,7 @@
Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров».
Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики и event log.
Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики, event log и режим защиты для жюри.
## Стек
@@ -15,6 +15,81 @@
Нет Three.js, backend, реального ML и тяжелых UI-библиотек.
## Как открыть демо
Публично:
```text
https://arhipovdan.ru/
https://www.arhipovdan.ru/
```
Локально на сервере:
```text
http://127.0.0.1:3100/
```
## Presentation Mode
В Header есть переключатель:
- `Engineering Mode` — полный инженерный dashboard.
- `Presentation Mode` — режим защиты с demo narrative, подсказками докладчика, фокусом на текущую область и criteria checklist.
В Presentation Mode панель demo steps показывает:
- номер текущего шага;
- что сейчас происходит;
- что смотреть на экране;
- что это доказывает для жюри;
- связанные OZON criteria;
- готовую presenter phrase.
Кнопки:
- `Previous step` / `Next step`;
- `Restart demo`;
- `Apply scenario`;
- `Run suggested action`.
Для `jam` и `emergency_stop` при `Safe Demo: ON` требуется явное повторное подтверждение fault-сценария.
## Demo Steps
1. System overview.
2. Normal item to B.
3. Oversized item to C.
4. Round object to D.
5. Boundary dimensions.
6. Low confidence fallback.
7. Close items queue.
8. Jam / fault handling.
9. Emergency stop.
10. Performance and synchronization.
## OZON Criteria Coverage Panel
Панель `OZON criteria coverage` показывает, какие критерии закрыты и где это доказано:
- category correctness;
- classification rules;
- boundary cases;
- physical routing;
- manipulation logic;
- geometry variety;
- safety;
- timing and throughput;
- CV-to-actuator integration;
- engineering realism;
- reproducibility/docs.
Фильтры: `all`, `covered`, `partially covered`, `demo step available`.
## Event Log JSON
`Copy JSON` в Event Log использует browser clipboard API. Если clipboard недоступен в браузере, экспорт не критичен для защиты: используйте визуальный Event Log с фильтрами, timestamp, state, command/category и severity. DevTools открывать не требуется.
## Запуск локально
```bash
@@ -22,8 +97,6 @@ npm install
npm run dev
```
Открыть: http://127.0.0.1:3100/
## Запуск через Docker
```bash
@@ -90,13 +163,7 @@ State machine управляет циклом:
## Simplified PID
PID-панель показывает упрощенную имитацию control loop:
- target speed;
- actual speed;
- error;
- correction;
- mini graph последних тиков скорости.
PID-панель показывает упрощенную имитацию control loop: target speed, actual speed, error, correction и mini graph последних тиков скорости.
В normal flow actual speed приближается к target. В `jam` и `emergency_stop` target становится 0, actual speed визуально падает к 0.
@@ -106,7 +173,7 @@ PID-панель показывает упрощенную имитацию cont
npm run test
```
Покрыты classifier, PID и целостность сценариев.
Покрыты classifier, PID, сценарии, demo steps и OZON criteria.
## Документация
@@ -114,6 +181,7 @@ npm run test
- `docs/DEMO_SCRIPT.md` — 3-5 минутный сценарий защиты.
- `docs/SCENARIOS.md` — ожидаемые результаты сценариев.
- `docs/JURY_QA.md` — короткие ответы на вопросы жюри.
- `docs/SUBMISSION_CHECKLIST.md` — checklist перед сдачей.
## Ограничения MVP

View File

@@ -1,69 +1,98 @@
# Demo Script
Target duration: 3-5 minutes.
Open https://arhipovdan.ru/ and switch Header to `Presentation Mode`.
## 1. Open The Dashboard
## 3-Minute Defense
Open https://arhipovdan.ru/.
### 0:00-0:25 — System Overview
Explain that this is a 2D engineering simulation, not a decorative animation: the SVG scene shows a scaled work zone, conveyor dimensions, sensors, stop-gate, pushers and roll-cages.
Demo step: `System overview`.
## 2. Normal Flow
Say: the system models the full loop: detection -> classification -> actuator command -> physical route.
Select `Normal flow`, press `Start`.
Watch: engineering SVG scene, sensors, stop-gate, pushers and B/C/D routes.
Explain:
### 0:25-0:55 — Normal Item To B
- item enters zone A;
- camera captures bbox;
- laser measures height;
- ultrasonic confirms gate position;
- gate holds item;
- classifier selects B/C/D;
- route command is shown on the scene;
- event log records the full cycle.
Demo step: `Normal item to B`.
Question closed: can the system show a full sorting cycle end-to-end?
Apply scenario, use `Run suggested action` or `Step state`.
## 3. Step-By-Step Decision
Say: dimensions pass, roundness is below threshold, category B opens the gate and sends the item straight.
Press `Reset`, then use `Step state`.
### 0:55-1:25 — Oversized To C
Explain each state in the timeline. Show that Step advances by logical state, not by arbitrary animation time.
Demo step: `Oversized item to C`.
Question closed: can the jury inspect synchronization and state transitions?
Say: dimensions fail first, so C has priority even before shape routing.
## 4. Oversized And Round Rules
Watch: decision tree FAIL, `ROUTE_TO_C`, lower roll-cage.
Select `Oversized item`, step to classification.
### 1:25-1:55 — Round Object To D
Show decision tree:
Demo step: `Round object to D`.
- dimensions check fails;
- category C selected;
- roundness is lower priority because dimensions are checked first.
Say: dimensions pass, but roundness >= 0.8, so the item goes to D for repack/shape issue.
Then select `Round object` and show D when dimensions pass and roundness >= 0.8.
### 1:55-2:25 — Fault And Safety
Question closed: how is classification proved?
Demo step: `Jam / fault handling` or `Emergency stop`.
## 5. Robustness Scenarios
With Safe Demo ON, confirm the fault scenario intentionally.
Show `close_items`, `low_confidence`, `jam`, `emergency_stop`.
Say: abnormal states stop conveyor motion and require Reset.
Explain:
### 2:25-3:00 — Criteria Coverage
- close items generate spacing warning and queue length;
- low confidence uses rule-based fallback;
- jam enters FAULT and stops conveyor;
- emergency stop enters EMERGENCY_STOP and requires Reset.
Demo step: `Performance and synchronization`, then focus criteria panel.
Question closed: what happens outside the happy path?
Say: this checklist maps every OZON criterion to scenario, component and doc evidence.
## 6. PID And Metrics
## 5-Minute Defense
Point to PID panel and metrics cards.
Use all 10 demo steps:
Explain that PID is simplified: actual speed approaches target in normal flow and decays toward zero in fault/emergency.
1. System overview.
2. Normal item to B.
3. Oversized item to C.
4. Round object to D.
5. Boundary dimensions.
6. Low confidence fallback.
7. Close items queue.
8. Jam / fault handling.
9. Emergency stop.
10. Performance and synchronization.
Question closed: how is conveyor control represented without overbuilding physics?
For each step:
1. Read `What to watch`.
2. Apply recommended scenario.
3. Use `Step state` or `Run suggested action`.
4. Point to the highlighted focus area.
5. Close with the presenter phrase.
## Fallback If Domain Does Not Open
1. SSH to server.
2. Check local frontend:
```bash
curl -I http://127.0.0.1:3100/
```
3. Check Docker project:
```bash
docker compose -p owl -f /opt/arhipovdan/app/docker-compose.server.yml ps
```
4. If screenshots/video are prepared later, show them while explaining the same demo steps.
## What Each Screen Proves
- SVG scene proves physical route and actuator command.
- Classification panel proves why category B/C/D was selected.
- Timeline proves synchronization and step-by-step behavior.
- PID panel proves conveyor control-loop behavior.
- Event Log proves traceability.
- Criteria panel proves coverage of OZON evaluation points.

View File

@@ -1,41 +1,61 @@
# Jury Q&A
## 1. Why 2D, Not 3D?
## 1. Why Is This Not Just An Animation?
The animation is driven by a deterministic state machine, classifier, sensor state, actuator state, metrics and event log. The scene visualizes domain state instead of playing a decorative timeline.
## 2. How Is Classification Connected To The Actuator?
`classifyItem` returns B/C/D. The simulation maps that category to `ROUTE_TO_B`, `ROUTE_TO_C` or `ROUTE_TO_D`, which changes gate and pusher states and draws the physical route.
## 3. How Do You Prove The Route Was Correct?
The decision tree shows thresholds and PASS/FAIL, the scene shows the selected command and route arrow, and Event Log records state, command/category and item id.
## 4. Where Are OZON Rules Reflected?
The rules are explicit in classification limits: min/max dimensions, roundness threshold and C priority. Criteria Coverage maps these rules to scenarios and docs.
## 5. How Does The Operator Safely Stop The System?
The `emergency_stop` scenario enters `EMERGENCY_STOP`, stops conveyor target speed and requires Reset. In Presentation Mode, Safe Demo requires confirmation before fault scenarios.
## 6. What Happens On Sensor Failure Or Low Confidence?
Low confidence is logged as a warning. The system still applies deterministic dimensions/roundness rules as a fallback and shows this in the panel.
## 7. Why 2D, Not 3D?
2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without spending effort on heavy rendering.
## 2. Where Is Computer Vision?
## 8. What Is Needed For A Physical Prototype?
Replace pseudo-CV with a real CV service, connect PLC/robot telemetry, calibrate sensor latencies, add hardware interlocks and persist event logs.
## 9. Where Is Computer Vision?
The MVP uses pseudo-CV: bbox, dimensions, confidence and latency are derived from mock items. The architecture keeps CV output separate from classification, so a real CV service can replace it later.
## 3. How Is Classification Correctness Proven?
## 10. How Is Classification Correctness Proven?
The decision tree shows PASS/FAIL for dimensions and roundness, actual values, thresholds and final category. Tests cover key boundary cases.
## 4. How Are Dimensions And Circular Section Handled?
## 11. How Are Dimensions And Circular Section Handled?
Dimensions are checked first against min/max width, depth and height. If they pass, roundness is checked against threshold 0.8.
## 5. Why Does C Have Priority Over D?
## 12. Why Does C Have Priority Over D?
Oversized or undersized items are operationally unsafe for the main line and must be diverted first. Therefore dimensions check precedes roundness.
## 6. How Is Synchronization Shown?
## 13. How Is Synchronization Shown?
Cycle timeline shows state order, simulated timestamps, durations and status: done, active, pending or skipped.
## 7. How Does The Actuator Part Work?
## 14. How Does The Actuator Part Work?
The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pusher D, then mechanisms return home.
## 8. What Happens On Jam?
## 15. What Happens On Jam?
The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
## 9. What Happens On Low CV Confidence?
A warning is logged, but the system still classifies by deterministic dimensions and roundness rules.
## 10. How To Scale This To A Real Hardware-Software System?
Replace pseudo-CV with a CV service, connect PLC/robot telemetry via backend/WebSocket, persist event logs, calibrate sensor latencies and add recovery policies.

View File

@@ -0,0 +1,19 @@
# Submission Checklist
Before final submission:
- [ ] `npm run build` passed.
- [ ] `npm run test` passed.
- [ ] Docker project `owl` is running.
- [ ] `https://arhipovdan.ru/` opens.
- [ ] `https://www.arhipovdan.ru/` opens.
- [ ] `https://ai-shorts.ru/` still works and was not affected.
- [ ] Presentation Mode opens.
- [ ] Demo steps checked.
- [ ] Safe Demo confirmation checked for jam/emergency.
- [ ] OZON Criteria Coverage Panel checked.
- [ ] Engineering Mode still works.
- [ ] Scenarios checked: normal_flow, oversized_item, round_object, boundary_dimensions, close_items, low_confidence, jam, emergency_stop.
- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, SUBMISSION_CHECKLIST.
- [ ] Branch pushed to `origin/dan_branch`.
- [ ] Nginx backup path from deploy is known if infrastructure rollback is needed.

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