diff --git a/README.md b/README.md index cb52e3f..00d267a 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,47 @@ # OZON Tech Sorter Simulation -Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров». +Интеллектуальная роботизированная система сортировки товаров (Frontend MVP). -Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики, event log и режим защиты для жюри. +Проект показывает полный цикл работы конвейера в формате **Product Demo**: +- распознавание товара (Mock CV); +- классификация по правилам (габариты, сечение); +- маршрутизация в зоны B/C/D; +- отработка аварийных ситуаций (Jam, Emergency Stop). + +UI переработан под **Product Demo Landing Page**, который: +- работает без горизонтального скролла на любых экранах; +- адаптирован для Mobile, Tablet и Desktop; +- включает Storyline (Detection → Classification → Command → Routing); +- предлагает список Jury Scenarios и OZON Criteria; +- прячет сложный Engineering Dashboard в сворачиваемый аккордеон. ## Стек -- Vite -- React -- TypeScript -- SVG/CSS для инженерной 2D-сцены +- Vite, React, TypeScript +- CSS/SVG (адаптивная сцена) - Vitest для доменных тестов -- Docker + nginx для production static hosting - -Нет Three.js, backend, реального ML и тяжелых UI-библиотек. +- Docker + nginx (production static hosting) ## Как открыть демо Публично: - ```text https://arhipovdan.ru/ https://www.arhipovdan.ru/ ``` Локально на сервере: - ```text http://127.0.0.1:3100/ ``` -### Guided Demo +## Структура Demo-страницы -Рекомендуемый путь для жюри — использовать **Guided Demo** view. -В Header переключите режим на `Guided Demo`. -Это чистое демо-представление для защиты (1 экран без скролла). - -Отличия Guided Demo от Engineering Dashboard: -- Guided Demo показывает только ключевую информацию для защиты (Proof Card, Narration, Snapshot), скрывая длинные логи и графики. -- Engineering Mode дает полный доступ ко всем метрикам, логам, PID-графику и дереву классификации. - -Рекомендуемый путь: -- Start Guided Demo -- Next -- Oversized -- Round -- Low confidence -- Jam -- Emergency stop - -В Guided Demo закрываются критерии: classification correctness, routing, timing, fault handling. - -## 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 открывать не требуется. +1. **Hero Section:** краткая суть проекта и цепочки действий. +2. **Product Demo Section:** живая сцена (`SorterScene`) и карточка текущего результата. +3. **Scenarios Cards:** карточки для проверки нестандартных товаров (негабарит, шар, затор). +4. **Criteria Cards:** чек-лист покрытия требований OZON. +5. **Engineering Details:** подробные отладочные панели (Event Log, PID, метрики). ## Запуск локально diff --git a/docs/JURY_QA.md b/docs/JURY_QA.md index 67cb368..32b51b3 100644 --- a/docs/JURY_QA.md +++ b/docs/JURY_QA.md @@ -60,18 +60,14 @@ The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pu The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required. -## 16. Why is the interface divided into Guided and Engineering? +## 16. Why is the interface a Product Demo Page now? -Guided Demo is focused solely on the defense presentation, showing only the Proof Card, Scene, and Narrative, ensuring clarity in a 3-minute pitch. Engineering Mode provides full access to logs, PID, and internal state. +To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw JSON logs or PID graphs, especially on laptop projectors or mobile devices. -## 17. How does Guided Demo prove engineering realism? +## 17. How does the Demo prove engineering realism? -Even in Guided Demo, the scene and proof card are driven by the real underlying state machine, classifier, and metric engine—not a pre-rendered video. +Even in the Product Demo Section, the scene and proof card are driven by the real underlying state machine, classifier, and metric engine—not a pre-rendered video. ## 18. Where to see the full event log and PID? -In the Engineering Dashboard. - -## 19. Why aren't all panels shown in Guided Demo? - -To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw JSON logs or PID graphs. +Scroll down and open the "Engineering Details (Engineering Mode)" accordion. diff --git a/docs/SUBMISSION_CHECKLIST.md b/docs/SUBMISSION_CHECKLIST.md index 251a535..e4e8581 100644 --- a/docs/SUBMISSION_CHECKLIST.md +++ b/docs/SUBMISSION_CHECKLIST.md @@ -11,14 +11,11 @@ Before final submission: - [ ] Presentation Mode opens. - [ ] Demo steps checked. - [ ] Safe Demo confirmation checked for jam/emergency. -- [ ] Guided Demo verified on 1920x1080 (no scroll). -- [ ] Guided Demo verified on 1440x900. -- [ ] Start Guided Demo button works. -- [ ] Next/Previous buttons work. -- [ ] Reset Demo button works. -- [ ] Fault confirmation works in Guided Demo. -- [ ] Engineering Mode is not broken. -- [ ] OZON Criteria Coverage Panel checked. +- [ ] App is responsive on Desktop (1920x1080), Laptop (1440x900) and Mobile (390x844). +- [ ] NO horizontal scroll on body/app container. +- [ ] Product Demo layout shows step, scene, and result. +- [ ] Scenarios are displayed as cards. +- [ ] Engineering Details accordion works and shows all technical panels. - [ ] 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. diff --git a/src/App.tsx b/src/App.tsx index 3fd87e7..3d16130 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,28 +1,24 @@ import { useEffect, useMemo, useState } from 'react'; -import Dashboard from './components/Dashboard'; +import Header from './components/Header'; +import HeroSection from './components/HeroSection'; +import ProductDemoSection from './components/ProductDemoSection'; +import ScenarioCards from './components/ScenarioCards'; +import CriteriaCards from './components/CriteriaCards'; +import EngineeringDetails from './components/EngineeringDetails'; import { DEMO_STEPS } from './data/demoSteps'; import { SCENARIOS } from './data/scenarios'; -import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation'; -import type { DemoStep, PresentationMode, ScenarioId, SimulationState } from './domain/types'; - -import GuidedDemoView from './components/GuidedDemoView'; - -const TICK_MS = 250; - -function isFaultDemoStep(step: DemoStep): boolean { - return step.scenarioId === 'jam' || step.scenarioId === 'emergency_stop'; -} +import { createSimulation, setRunning, stepSimulationToNextState } from './domain/simulation'; +import type { ScenarioId, SimulationState } from './domain/types'; export default function App() { const [activeScenarioId, setActiveScenarioId] = useState('normal_flow'); - const [presentationMode, setPresentationMode] = useState('guided'); - const [safeDemoEnabled, setSafeDemoEnabled] = useState(true); const [demoStepIndex, setDemoStepIndex] = useState(0); - const [pendingFaultStepId, setPendingFaultStepId] = useState(); + const activeScenario = useMemo( - () => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0], + () => SCENARIOS.find((s) => s.id === activeScenarioId) ?? SCENARIOS[0], [activeScenarioId], ); + const currentDemoStep = DEMO_STEPS[demoStepIndex]; const [simulation, setSimulation] = useState(() => createSimulation(activeScenario)); @@ -30,110 +26,70 @@ export default function App() { setSimulation(createSimulation(activeScenario)); }, [activeScenario]); - useEffect(() => { - if (!simulation.running) { - return undefined; + // We are not using automatic interval in the new product demo by default to keep it step-by-step + // but if the user wants it to run automatically, we can preserve it if needed. + // Let's use handleNext to just step through. + + const handleStartDemo = () => { + document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' }); + if (!simulation.currentItem && demoStepIndex === 0) { + setSimulation(stepSimulationToNextState(simulation)); } - - const timer = window.setInterval(() => { - setSimulation((current) => stepSimulation(current, TICK_MS)); - }, TICK_MS); - - return () => window.clearInterval(timer); - }, [simulation.running]); - - const handleStart = () => setSimulation((current) => setRunning(current, true)); - const handlePause = () => setSimulation((current) => setRunning(current, false)); - const handleReset = () => setSimulation(createSimulation(activeScenario)); - const handleStep = () => setSimulation((current) => stepSimulationToNextState(current)); - 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(); + const handleNext = () => { + // If simulation is finished or errored, we might need to reset or move to next scenario + const isFinished = simulation.machineState === 'RETURN_HOME' || simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; + + if (isFinished) { + const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length; + setDemoStepIndex(nextIndex); + setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId); } else { - handleStep(); + setSimulation((current) => stepSimulationToNextState(current)); } }; - const handleRestartDemo = () => { - setPendingFaultStepId(undefined); - setDemoStepIndex(0); - setActiveScenarioId(DEMO_STEPS[0].scenarioId); + const handleReset = () => { + setSimulation(createSimulation(activeScenario)); }; - return presentationMode === 'guided' ? ( - - ) : ( - + const handleScenarioChange = (scenarioId: ScenarioId) => { + setActiveScenarioId(scenarioId); + // Find first step that matches this scenario + const stepIdx = DEMO_STEPS.findIndex(s => s.scenarioId === scenarioId); + if (stepIdx >= 0) setDemoStepIndex(stepIdx); + document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' }); + }; + + return ( +
+
+ +
+ + + + + + + + + +
+ +
+

OZON Tech Hackathon 2026. Sorter Simulation.

+
+
); } diff --git a/src/components/CriteriaCards.tsx b/src/components/CriteriaCards.tsx new file mode 100644 index 0000000..3fb3f72 --- /dev/null +++ b/src/components/CriteriaCards.tsx @@ -0,0 +1,29 @@ +import { OZON_CRITERIA } from '../data/criteria'; + +export default function CriteriaCards() { + return ( +
+
+

Покрытие критериев OZON

+

Каждый инженерный критерий хакатона покрывается симуляцией и тестами.

+
+ +
+ {OZON_CRITERIA.map((criterion) => ( +
+
+

{criterion.title}

+ + {criterion.status} + +
+

{criterion.evidence}

+
+ Docs: {criterion.relatedDoc} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/CurrentProofCard.tsx b/src/components/CurrentProofCard.tsx index ba9f05d..e692131 100644 --- a/src/components/CurrentProofCard.tsx +++ b/src/components/CurrentProofCard.tsx @@ -11,88 +11,60 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio if (!current) { return ( -
+

Awaiting cycle

-

Ready to demonstrate sorting cycle

-

The demo will show detection, classification, control command and routing to B/C/D.

-
- Detection - → - Classification - → - Command - → - Actuator - → - Route - → - Log -
-

Click Start Guided Demo or Next to begin.

+

Ready to demonstrate sorting cycle

+

Нажмите «Запустить демо» для начала или переключите сценарий ниже.

-
+ ); } return ( -
-
-

Current Item Proof

-

{item?.name} (ID: {item?.id})

+
+
+

Результат классификации

+

{item?.name} (ID: {item?.id})

+
- Dimensions - - {dimensions?.width} x {dimensions?.depth} x {dimensions?.height} mm - + Категория + {result?.category || '-'}
- Roundness + Маршрут + {result?.category ? `Зона ${result.category}` : '-'} +
+
+ Команда + {simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'} +
+
+ +
+
+ Габариты: + + {dimensions?.width} x {dimensions?.depth} x {dimensions?.height} мм + +
+
+ Roundness: {item?.roundness.toFixed(2)}
-
- Confidence - - {(item?.confidence ?? 0 * 100).toFixed(0)}% - -
-
- Category - {result?.category} -
-
- Command - {simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'} -
-
- Target zone - {result?.category ? `Zone ${result.category}` : '-'} -
+
- Decision reason: {result?.reason} - {lowConfidence &&

Low CV confidence — rule-based fallback used

} - {simulation.machineState === 'FAULT' &&

FAULT: Conveyor stopped, reset required

} - {simulation.machineState === 'EMERGENCY_STOP' &&

EMERGENCY_STOP: All actuators stopped

} - {simulation.metrics.queueLength > 1 &&

Spacing warning: sequential processing enforced

} + Почему такое решение: {result?.reason} + {lowConfidence &&

Низкая уверенность CV — использован rule-based fallback

} + {simulation.machineState === 'FAULT' &&

FAULT: Конвейер остановлен

} + {simulation.machineState === 'EMERGENCY_STOP' &&

EMERGENCY_STOP: Аварийная остановка

} + {simulation.metrics.queueLength > 1 &&

Внимание: очередь товаров

}
-
- Detection - → - Classification - → - Command - → - Actuator - → - Route - → - Log -
-
+ ); } \ No newline at end of file diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx deleted file mode 100644 index 87f4219..0000000 --- a/src/components/Dashboard.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import Header from './Header'; -import SorterScene from './SorterScene'; -import CurrentItemPanel from './CurrentItemPanel'; -import ClassificationPanel from './ClassificationPanel'; -import StateMachinePanel from './StateMachinePanel'; -import MetricsPanel from './MetricsPanel'; -import EventLog from './EventLog'; -import ScenarioPanel from './ScenarioPanel'; -import SensorPanel from './SensorPanel'; -import PidPanel from './PidPanel'; -import TimelinePanel from './TimelinePanel'; -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 ( -
-
- {presentationMode === 'presentation' ? ( - onDemoStepChange(demoStepIndex - 1)} - onNext={() => onDemoStepChange(demoStepIndex + 1)} - onRestart={onRestartDemo} - onApplyScenario={() => onApplyDemoStepScenario(currentDemoStep)} - onRunPreferredAction={() => onRunPreferredAction(currentDemoStep)} - /> - ) : null} -
-
- -
- -
-
-
-
-
-
- -
-
- -
-
-
-
- ); -} diff --git a/src/components/EngineeringDetails.tsx b/src/components/EngineeringDetails.tsx new file mode 100644 index 0000000..d56a6bf --- /dev/null +++ b/src/components/EngineeringDetails.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import StateMachinePanel from './StateMachinePanel'; +import TimelinePanel from './TimelinePanel'; +import PidPanel from './PidPanel'; +import EventLog from './EventLog'; +import MetricsPanel from './MetricsPanel'; +import SensorPanel from './SensorPanel'; +import type { SimulationState } from '../domain/types'; + +export default function EngineeringDetails({ simulation }: { simulation: SimulationState }) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
setIsOpen(!isOpen)}> +

Инженерные детали (Engineering Mode)

+ +
+ + {isOpen && ( +
+
+
+
+
+
+
+
+
+
+ )} +
+ ); +} diff --git a/src/components/GuidedDemoView.tsx b/src/components/GuidedDemoView.tsx deleted file mode 100644 index 2f34f91..0000000 --- a/src/components/GuidedDemoView.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useEffect } from 'react'; -import SorterScene from './SorterScene'; -import CurrentProofCard from './CurrentProofCard'; -import { OZON_CRITERIA } from '../data/criteria'; -import type { DemoStep, Scenario, ScenarioId, SimulationState, PresentationMode } from '../domain/types'; - -interface GuidedDemoViewProps { - 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: any) => void; - onSafeDemoChange: (enabled: boolean) => void; - onDemoStepChange: (index: number) => void; - onApplyDemoStepScenario: (step: DemoStep) => void; - onRunPreferredAction: (step: DemoStep) => void; - onRestartDemo: () => void; -} - -export default function GuidedDemoView({ - simulation, - scenarios, - activeScenarioId, - demoSteps, - demoStepIndex, - currentDemoStep, - pendingFaultStepId, - safeDemoEnabled, - onStart, - onPause, - onReset, - onStep, - onPresentationModeChange, - onDemoStepChange, - onApplyDemoStepScenario, - onRunPreferredAction, - onRestartDemo, -}: GuidedDemoViewProps) { - const isFaultStep = currentDemoStep.scenarioId === 'jam' || currentDemoStep.scenarioId === 'emergency_stop'; - const pendingFaultConfirmation = pendingFaultStepId === currentDemoStep.id; - - const handleStartDemo = () => { - if (!simulation.currentItem && demoStepIndex === 0 && !simulation.running) { - onRunPreferredAction(currentDemoStep); - } else if (demoStepIndex === 0 && simulation.running) { - onRestartDemo(); - } else { - onRunPreferredAction(currentDemoStep); - } - }; - - const handleNext = () => { - onDemoStepChange(demoStepIndex + 1); - }; - - const handlePrevious = () => { - onDemoStepChange(demoStepIndex - 1); - }; - - const relatedCriteriaList = OZON_CRITERIA.filter(c => currentDemoStep.relatedCriteria.includes(c.id)); - - return ( -
- {/* 1. Top Demo Header */} -
-
-

Guided Sorting Demo

-

Step {demoStepIndex + 1} / {demoSteps.length}: {currentDemoStep.title}

-
-
-
- - {simulation.systemStatus} -
-
Scenario: {simulation.scenario.name}
-
-
- -
- - -
- - -
-
- - {/* Main Grid */} -
- {/* 2. Large Scene Area */} -
-
- -
-
- Cycle Time - {simulation.metrics.avgCycleTimeMs} ms -
-
- CV Latency - {simulation.metrics.cvLatencyMs} ms -
-
- Actuator Latency - {simulation.metrics.actuatorLatencyMs} ms -
-
- Throughput - {simulation.metrics.throughputItemsPerMin} items/min -
-
-
-
- - {/* Right Column */} - -
-
- ); -} \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 65a7828..77d9758 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,59 +1,31 @@ -import type { PresentationMode, SimulationState } from '../domain/types'; +import type { 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, - presentationMode, - safeDemoEnabled, - onStart, - onPause, - onReset, - onStep, - onPresentationModeChange, - onSafeDemoChange, -}: HeaderProps) { - const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP'; - +export default function Header({ simulation }: HeaderProps) { return ( -
-
-

Intelligent robotic sorting line

-

OZON Tech Sorter Simulation

+
+
+ O +
+ OZON Tech + Sorter Simulation +
-
-
- - - -
- {presentationMode === 'presentation' ? ( - - ) : null} -
- - {simulation.systemStatus} -
-
Scenario: {simulation.scenario.name}
-
Sim t+{(simulation.simTimeMs / 1000).toFixed(1)}s
-
- - - - -
+ + + +
+
+ {simulation.systemStatus} + t+{(simulation.simTimeMs / 1000).toFixed(1)}s
); diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx new file mode 100644 index 0000000..d26db98 --- /dev/null +++ b/src/components/HeroSection.tsx @@ -0,0 +1,51 @@ +export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }) { + return ( +
+
+

Интеллектуальная система сортировки товаров

+

+ Симуляция показывает полный цикл: обнаружение товара, классификация по правилам, + управляющая команда и маршрутизация в зоны B, C, D. +

+ +
+ Computer Vision + Rule-based classification + Actuator routing + Cycle time + Fault handling +
+ + +
+ +
+
+
+
📷
+ Detection +
+
→
+
+
🧠
+ Classification +
+
→
+
+
⚡
+ Command +
+
→
+
+
📦
+ Routing +
+
+
+
+ ); +} diff --git a/src/components/PresentationModePanel.tsx b/src/components/PresentationModePanel.tsx deleted file mode 100644 index 586e38d..0000000 --- a/src/components/PresentationModePanel.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 ( -
-
Step {currentIndex + 1} / {steps.length}
-
-

Presentation Mode

-

{currentStep.title}

-

{currentStep.explanation}

-
-
-
- What to watch - {currentStep.whatToWatch} -
-
- What it proves for jury - {currentStep.juryValue} -
-
- Current proof - classification decision {'->'} control command {'->'} physical route {'->'} logged result -
-
-
{currentStep.presenterPhrase}
-
- Recommended scenario: {currentStep.scenarioId} - Action: {currentStep.preferredAction} - Focus: {currentStep.focusArea} - Safe Demo: {safeDemoEnabled ? 'ON' : 'OFF'} -
-
- {currentStep.relatedCriteria.map((criterion) => {criterion})} -
- {safeDemoEnabled && isFaultStep && pendingFaultConfirmation ? ( -

Click “Confirm fault scenario” again to intentionally switch to {currentStep.scenarioId}.

- ) : null} -
- - - - - -
-
- ); -} diff --git a/src/components/ProductDemoSection.tsx b/src/components/ProductDemoSection.tsx new file mode 100644 index 0000000..ee3447f --- /dev/null +++ b/src/components/ProductDemoSection.tsx @@ -0,0 +1,41 @@ +import SorterScene from './SorterScene'; +import CurrentProofCard from './CurrentProofCard'; +import StorylineStepper from './StorylineStepper'; +import type { SimulationState } from '../domain/types'; + +interface Props { + simulation: SimulationState; + onNext: () => void; + onReset: () => void; + demoStepTitle: string; +} + +export default function ProductDemoSection({ simulation, onNext, onReset, demoStepTitle }: Props) { + return ( +
+
+

Live Demo

+
+ Текущий шаг: {demoStepTitle} + +
+
+ + + +
+
+ +
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/ScenarioCards.tsx b/src/components/ScenarioCards.tsx new file mode 100644 index 0000000..e4f765b --- /dev/null +++ b/src/components/ScenarioCards.tsx @@ -0,0 +1,45 @@ +import type { Scenario, ScenarioId } from '../domain/types'; + +interface Props { + scenarios: Scenario[]; + activeScenarioId: ScenarioId; + onScenarioChange: (id: ScenarioId) => void; +} + +export default function ScenarioCards({ scenarios, activeScenarioId, onScenarioChange }: Props) { + return ( +
+
+

Сценарии тестирования (Jury Test Cases)

+

Выберите сценарий для проверки поведения системы в разных условиях.

+
+ +
+ {scenarios.map((scenario) => ( +
onScenarioChange(scenario.id)} + > +
+

{scenario.name}

+ {scenario.id === activeScenarioId && Выбран} +
+

{scenario.description}

+
+
+ Цель: + {scenario.goal} +
+
+ Результат: + {scenario.expectedCategorySummary} +
+
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/StorylineStepper.tsx b/src/components/StorylineStepper.tsx new file mode 100644 index 0000000..649a7d4 --- /dev/null +++ b/src/components/StorylineStepper.tsx @@ -0,0 +1,65 @@ +import type { SimulationState } from '../domain/types'; + +export default function StorylineStepper({ simulation }: { simulation: SimulationState }) { + const state = simulation.machineState; + + const isDetection = state === 'MOVING_TO_CAMERA' || state === 'DETECTING'; + const isClassification = state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE' || state === 'CLASSIFYING'; + const isCommand = state.startsWith('ROUTE_TO_'); + const isRouting = state === 'RETURN_HOME'; + + const item = simulation.currentItem; + + return ( +
+ + + + + +
+ ); +} + +function Step({ label, desc, active, done }: { label: string, desc: string, active: boolean, done: boolean }) { + let statusClass = 'step-pending'; + if (active) statusClass = 'step-active'; + else if (done) statusClass = 'step-done'; + + return ( +
+
+
+
+
+ {label} + {desc} +
+
+ ); +} diff --git a/src/styles.css b/src/styles.css index 9241c7b..4912e0a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1423,3 +1423,742 @@ button:disabled { .criterion-mini-header strong { font-size: 13px; } .criterion-mini p { font-size: 12px; color: var(--muted); margin: 0; line-height: 1.4; } + +/* ====== NEW PRODUCT DEMO STYLES ====== */ + +.product-page { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; +} + +.product-main { + flex: 1; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 0 20px; +} + +/* Header */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: rgba(7, 14, 23, 0.9); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(12px); +} + +.header-logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--cyan); + color: #000; + font-weight: 900; + border-radius: 8px; +} + +.logo-text { + display: flex; + flex-direction: column; +} + +.logo-title { + font-weight: 800; + font-size: 16px; + line-height: 1; +} + +.logo-subtitle { + font-size: 11px; + color: var(--muted); +} + +.header-nav { + display: flex; + gap: 24px; +} + +.nav-link { + color: var(--text); + text-decoration: none; + font-size: 14px; + font-weight: 600; + transition: color 0.2s; +} + +.nav-link:hover { + color: var(--cyan); +} + +.header-status-compact { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 10px var(--green); +} +.status-dot.paused { background: var(--orange); box-shadow: 0 0 10px var(--orange); } +.status-dot.fault, .status-dot.emergency_stop { background: var(--red); box-shadow: 0 0 10px var(--red); } + +/* Hero */ +.hero-section { + padding: 60px 0 80px; + display: flex; + gap: 40px; + align-items: center; +} + +.hero-content { + flex: 1; +} + +.hero-title { + font-size: clamp(36px, 4vw, 56px); + line-height: 1.1; + letter-spacing: -0.02em; + margin: 0 0 20px; + font-weight: 900; +} + +.hero-subtitle { + font-size: 18px; + color: var(--muted); + margin: 0 0 32px; + max-width: 600px; +} + +.hero-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 40px; +} + +.hero-badge { + padding: 6px 12px; + background: rgba(56, 189, 248, 0.1); + color: var(--cyan); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +.hero-actions { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.btn-primary { + background: var(--cyan); + color: #000; + border: none; + padding: 14px 28px; + border-radius: 12px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-primary:hover { + background: var(--cyan-hover); + transform: translateY(-2px); +} + +.btn-secondary { + background: #0e1b2b; + color: var(--text); + border: 1px solid #2a4a66; + padding: 14px 28px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-secondary:hover { + border-color: var(--cyan); + background: #11273b; +} + +.hero-visual { + flex: 1; + display: flex; + justify-content: center; +} + +.hero-chain { + display: flex; + align-items: center; + gap: 16px; + background: var(--panel); + padding: 32px 40px; + border-radius: 24px; + border: 1px solid var(--line); + box-shadow: 0 20px 40px rgba(0,0,0,0.3); +} + +.chain-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.chain-icon { + width: 48px; + height: 48px; + background: #0d1b2a; + border: 1px solid #1a2f44; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; +} + +.chain-step span { + font-size: 12px; + font-weight: 600; + color: var(--muted); +} + +.chain-arrow { + color: #334155; + font-size: 20px; +} + +/* Demo Section */ +.product-demo-section { + padding: 40px 0 80px; + border-top: 1px solid var(--line); +} + +.demo-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 32px; +} + +.demo-header h2 { + font-size: 32px; + margin: 0; + font-weight: 800; +} + +.demo-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--muted); + background: var(--panel); + padding: 10px 16px; + border-radius: 999px; + border: 1px solid var(--line); +} + +.demo-status strong { + color: var(--cyan); +} + +.storyline-stepper { + display: flex; + justify-content: space-between; + margin-bottom: 40px; + background: var(--panel); + padding: 20px; + border-radius: 16px; + border: 1px solid var(--line); +} + +.story-step { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.step-indicator { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid #2a4a66; + display: flex; + align-items: center; + justify-content: center; + background: #050910; +} + +.step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: transparent; +} + +.step-active .step-indicator { border-color: var(--cyan); } +.step-active .step-dot { background: var(--cyan); box-shadow: 0 0 8px var(--cyan); } +.step-done .step-indicator { border-color: var(--green); background: var(--green); } +.step-done .step-dot { background: transparent; } +.step-pending { opacity: 0.5; } + +.step-content { + display: flex; + flex-direction: column; +} + +.step-content strong { font-size: 14px; margin-bottom: 4px; } +.step-content span { font-size: 11px; color: var(--muted); } + +.demo-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 24px; + align-items: start; +} + +.demo-scene-container { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 20px; + padding: 20px; +} + +/* Sorter scene sizing fix */ +.demo-scene-container .scene-wrap { + width: 100%; +} +.demo-scene-container .sorter-svg { + width: 100%; + height: auto; + max-height: 600px; +} +.demo-scene-container .scene-title-row { + display: none; +} + +.demo-proof-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Redesigned Proof Card */ +.proof-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 20px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.proof-card-stopped { + border-color: var(--red); + box-shadow: 0 0 20px rgba(251, 61, 78, 0.1); +} + +.proof-card-empty { + text-align: center; + align-items: center; + justify-content: center; + min-height: 300px; +} + +.proof-card-header { + border-bottom: 1px solid var(--line); + padding-bottom: 16px; +} + +.proof-card-header h3 { margin: 4px 0 0; font-size: 20px; } + +.proof-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.proof-item { + background: #0d1b2a; + border: 1px solid #1a2f44; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.proof-label { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; +} + +.proof-item strong { + font-size: 16px; +} + +.proof-details { + background: #0d1b2a; + border: 1px solid #1a2f44; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; +} + +.detail-row { + display: flex; + justify-content: space-between; +} +.detail-row span { color: var(--muted); } + +.proof-reason { + background: rgba(56, 189, 248, 0.05); + border-left: 4px solid var(--cyan); + padding: 12px 16px; + border-radius: 0 12px 12px 0; + font-size: 14px; + line-height: 1.5; +} + +.demo-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* Scenarios */ +.scenario-cards-section { + padding: 60px 0; + border-top: 1px solid var(--line); +} + +.section-header { + margin-bottom: 40px; +} + +.section-header h2 { + font-size: 32px; + margin: 0 0 12px; + font-weight: 800; +} + +.section-header p { + font-size: 16px; + color: var(--muted); + margin: 0; + max-width: 600px; +} + +.scenario-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.scenario-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + cursor: pointer; + transition: all 0.2s; +} + +.scenario-card:hover { + border-color: var(--cyan); + transform: translateY(-2px); +} + +.scenario-card.active { + border-color: var(--cyan); + background: rgba(56, 189, 248, 0.05); + box-shadow: 0 0 0 1px var(--cyan); +} + +.scenario-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.scenario-card-header h3 { + margin: 0; + font-size: 16px; +} + +.active-badge { + background: var(--cyan); + color: #000; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 800; +} + +.scenario-desc { + font-size: 13px; + color: var(--muted); + margin: 0 0 16px; + line-height: 1.4; + flex: 1; +} + +.scenario-meta { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + font-size: 12px; + background: #0d1b2a; + padding: 10px; + border-radius: 8px; +} + +.meta-item { + display: flex; + flex-direction: column; +} + +.meta-label { color: #64748b; } +.meta-value { font-weight: 600; color: #cbd5e1; } + +.btn-small { + padding: 8px 16px; + font-size: 13px; + width: 100%; +} +.mt-auto { margin-top: auto; } + +/* Criteria */ +.criteria-cards-section { + padding: 60px 0; + border-top: 1px solid var(--line); +} + +.criteria-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; +} + +.criteria-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; +} + +.criteria-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + gap: 12px; +} + +.criteria-card-header h3 { + margin: 0; + font-size: 15px; +} + +.criteria-evidence { + font-size: 13px; + color: var(--muted); + line-height: 1.5; + margin: 0 0 16px; + flex: 1; +} + +.criteria-footer { + font-size: 11px; + color: #64748b; + border-top: 1px solid var(--line); + padding-top: 12px; +} + +/* Engineering Details */ +.engineering-details-section { + padding: 40px 0 80px; + border-top: 1px solid var(--line); +} + +.engineering-toggle { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--panel); + padding: 24px; + border-radius: 16px; + border: 1px solid var(--line); + cursor: pointer; +} + +.engineering-toggle h2 { + margin: 0; + font-size: 24px; +} + +.engineering-content { + margin-top: 24px; +} + +.engineering-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 20px; +} + +.eng-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; +} + +.eng-panel .panel { + border: none; + background: transparent; + box-shadow: none; + padding: 0; +} + +.eng-panel .event-table { + max-width: 100%; +} + +.site-footer { + padding: 40px 20px; + text-align: center; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 14px; + margin-top: auto; +} + +/* Responsive Overrides */ +body { + min-width: 0 !important; /* Remove old 1180px limit */ +} + +@media (max-width: 1200px) { + .hero-section { + flex-direction: column; + text-align: center; + padding: 40px 0; + } + .hero-subtitle { + margin: 0 auto 32px; + } + .hero-badges { + justify-content: center; + } + .hero-actions { + justify-content: center; + } + .hero-chain { + width: 100%; + justify-content: center; + } + .demo-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .story-step { + flex-direction: column; + text-align: center; + } + .step-content { + align-items: center; + } + .engineering-grid { + grid-template-columns: 1fr; + } + .header-nav { + display: none; + } + .demo-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .storyline-stepper { + flex-wrap: wrap; + gap: 16px; + } +} + +@media (max-width: 640px) { + .hero-title { + font-size: 32px; + } + .hero-chain { + flex-wrap: wrap; + padding: 20px; + } + .chain-arrow { + display: none; + } + .proof-grid { + grid-template-columns: 1fr; + } + .demo-controls { + grid-template-columns: 1fr; + } + .engineering-toggle h2 { + font-size: 18px; + } + .site-header { + flex-direction: column; + gap: 12px; + } +} +