design: redesign product demo UI

This commit is contained in:
root
2026-07-04 12:14:08 +02:00
parent cb38cd0002
commit a7025ca50c
20 changed files with 2546 additions and 2207 deletions

View File

@@ -1,24 +1,30 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Header from './components/Header';
import HeroSection from './components/HeroSection';
import ProductDemoSection from './components/ProductDemoSection';
import StorylineStepper from './components/StorylineStepper';
import ScenarioCards from './components/ScenarioCards';
import CriteriaCards from './components/CriteriaCards';
import EngineeringDetails from './components/EngineeringDetails';
import EngineeringDetails, { type EngineeringDetailsHandle } from './components/EngineeringDetails';
import { DEMO_STEPS } from './data/demoSteps';
import { SCENARIOS } from './data/scenarios';
import { createSimulation, setRunning, stepSimulationToNextState } from './domain/simulation';
import { createSimulation, stepSimulationToNextState } from './domain/simulation';
import type { ScenarioId, SimulationState } from './domain/types';
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
export default function App() {
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [demoStepIndex, setDemoStepIndex] = useState(0);
const engineeringRef = useRef<EngineeringDetailsHandle>(null);
const activeScenario = useMemo(
() => SCENARIOS.find((s) => s.id === activeScenarioId) ?? SCENARIOS[0],
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
[activeScenarioId],
);
const currentDemoStep = DEMO_STEPS[demoStepIndex];
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
@@ -26,28 +32,30 @@ export default function App() {
setSimulation(createSimulation(activeScenario));
}, [activeScenario]);
// 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));
}
scrollToId('demo');
setSimulation((current) => {
if (current.machineState === 'IDLE' && !current.currentItem) {
return stepSimulationToNextState(current);
}
return current;
});
};
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 finished =
simulation.machineState === 'RETURN_HOME' ||
simulation.machineState === 'FAULT' ||
simulation.machineState === 'EMERGENCY_STOP';
if (finished) {
const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length;
setDemoStepIndex(nextIndex);
setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId);
} else {
setSimulation((current) => stepSimulationToNextState(current));
return;
}
setSimulation((current) => stepSimulationToNextState(current));
};
const handleReset = () => {
@@ -56,39 +64,66 @@ export default function App() {
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' });
const stepIdx = DEMO_STEPS.findIndex((step) => step.scenarioId === scenarioId);
if (stepIdx >= 0) {
setDemoStepIndex(stepIdx);
}
scrollToId('demo');
};
const handleOpenEngineering = () => {
engineeringRef.current?.open();
};
const handleShowScenarios = () => {
scrollToId('scenarios');
};
return (
<div className="product-page">
<Header simulation={simulation} />
<Header
simulation={simulation}
onReset={handleReset}
onOpenDemo={() => scrollToId('demo')}
onOpenScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<main className="product-main">
<HeroSection onStartDemo={handleStartDemo} />
<ProductDemoSection
simulation={simulation}
onNext={handleNext}
onReset={handleReset}
demoStepTitle={currentDemoStep.title}
<HeroSection
onStartDemo={handleStartDemo}
onShowScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<ScenarioCards
scenarios={SCENARIOS}
<ProductDemoSection
simulation={simulation}
demoStepTitle={currentDemoStep.title}
onStartDemo={handleStartDemo}
onNext={handleNext}
onReset={handleReset}
onOpenEngineering={handleOpenEngineering}
/>
<StorylineStepper simulation={simulation} />
<ScenarioCards
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
onScenarioChange={handleScenarioChange}
/>
<CriteriaCards />
<EngineeringDetails simulation={simulation} />
<CriteriaCards onLinkedScenario={handleScenarioChange} />
<EngineeringDetails
ref={engineeringRef}
simulation={simulation}
onScenarioChange={handleScenarioChange}
/>
</main>
<footer className="site-footer">
<p>OZON Tech Hackathon 2026. Sorter Simulation.</p>
<p>OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo</p>
</footer>
</div>
);

View File

@@ -1,27 +1,126 @@
import { OZON_CRITERIA } from '../data/criteria';
import type { ScenarioId } from '../domain/types';
export default function CriteriaCards() {
interface CriteriaCard {
id: string;
title: string;
evidence: string;
status: 'covered' | 'demo';
linkedScenario: ScenarioId;
linkedLabel: string;
}
const PRODUCT_CRITERIA: CriteriaCard[] = [
{
id: 'classification',
title: 'Классификация',
evidence: 'Rule-based дерево решений выдаёт категорию B/C/D для каждого SKU.',
status: 'covered',
linkedScenario: 'normal_flow',
linkedLabel: 'Обычный товар',
},
{
id: 'dimensions',
title: 'Габариты',
evidence: 'Проверка min/max размеров до анализа формы.',
status: 'covered',
linkedScenario: 'oversized_item',
linkedLabel: 'Негабарит',
},
{
id: 'round-section',
title: 'Круг в сечении',
evidence: 'Roundness ≥ 0.8 направляет товар в зону D.',
status: 'covered',
linkedScenario: 'round_object',
linkedLabel: 'Круглый объект',
},
{
id: 'rule-priority',
title: 'Приоритет правил',
evidence: 'Сначала габариты (C), затем форма (D), иначе основной поток B.',
status: 'covered',
linkedScenario: 'boundary_dimensions',
linkedLabel: 'Пограничные размеры',
},
{
id: 'routing',
title: 'Маршрутизация B/C/D',
evidence: 'Категория превращается в команду ROUTE_TO_* и физический маршрут.',
status: 'covered',
linkedScenario: 'normal_flow',
linkedLabel: 'Обычный товар',
},
{
id: 'cycle-time',
title: 'Cycle time',
evidence: 'Метрики показывают длительность цикла и throughput.',
status: 'covered',
linkedScenario: 'close_items',
linkedLabel: 'Очередь товаров',
},
{
id: 'faults',
title: 'Нештатные ситуации',
evidence: 'Jam переводит систему в FAULT и останавливает конвейер.',
status: 'demo',
linkedScenario: 'jam',
linkedLabel: 'Застревание',
},
{
id: 'safety',
title: 'Безопасность',
evidence: 'Emergency stop замораживает линию до Reset.',
status: 'demo',
linkedScenario: 'emergency_stop',
linkedLabel: 'Аварийная остановка',
},
{
id: 'reproducibility',
title: 'Воспроизводимость',
evidence: 'Сценарии, тесты и docs позволяют повторить демо жюри.',
status: 'covered',
linkedScenario: 'low_confidence',
linkedLabel: 'Низкая уверенность CV',
},
];
interface Props {
onLinkedScenario?: (id: ScenarioId) => void;
}
export default function CriteriaCards({ onLinkedScenario }: Props) {
return (
<section className="criteria-cards-section" id="criteria">
<section className="criteria-cards-section" id="criteria" aria-labelledby="criteria-title">
<div className="section-header">
<h2>Покрытие критериев OZON</h2>
<p>Каждый инженерный критерий хакатона покрывается симуляцией и тестами.</p>
<h2 id="criteria-title">Критерии OZON</h2>
<p>Каждый критерий хакатона закрыт сценарием, симуляцией и тестами.</p>
</div>
<div className="criteria-grid">
{OZON_CRITERIA.map((criterion) => (
<div key={criterion.id} className="criteria-card">
{PRODUCT_CRITERIA.map((criterion) => (
<article key={criterion.id} className="criteria-card">
<div className="criteria-card-header">
<h3>{criterion.title}</h3>
<span className={`status-badge ${criterion.status.replace(/ /g, '-')}`}>
{criterion.status}
<span className={`status-badge status-${criterion.status}`}>
{criterion.status === 'covered' ? 'Covered' : 'Demo'}
</span>
</div>
<p className="criteria-evidence">{criterion.evidence}</p>
<div className="criteria-footer">
<span className="related-docs">Docs: {criterion.relatedDoc}</span>
<span>Сценарий:</span>
{onLinkedScenario ? (
<button
type="button"
className="criteria-link"
onClick={() => onLinkedScenario(criterion.linkedScenario)}
>
{criterion.linkedLabel}
</button>
) : (
<strong>{criterion.linkedLabel}</strong>
)}
</div>
</div>
</article>
))}
</div>
</section>

View File

@@ -1,4 +1,3 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
import type { SimulationState } from '../domain/types';
export default function CurrentProofCard({ simulation }: { simulation: SimulationState }) {
@@ -8,14 +7,19 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
const dimensions = item?.dimensionsMm;
const lowConfidence = Boolean(item && item.confidence < 0.65);
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: result
? `ROUTE_TO_${result.category}`
: 'WAITING';
if (!current) {
return (
<div className="proof-card proof-card-empty">
<div className="proof-card-inner">
<p className="eyebrow">Awaiting cycle</p>
<h3>Ready to demonstrate sorting cycle</h3>
<p>Нажмите «Запустить демо» для начала или переключите сценарий ниже.</p>
<p className="eyebrow">Ожидание цикла</p>
<h3>Готово к демонстрации</h3>
<p>Нажмите Start demo — товар пройдёт Detection → Classification → Command → Routing.</p>
</div>
</div>
);
@@ -25,33 +29,33 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
<div className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
<div className="proof-card-header">
<p className="eyebrow">Результат классификации</p>
<h3>{item?.name} (ID: {item?.id})</h3>
<h3>{item?.name}</h3>
</div>
<div className="proof-grid">
<div className="proof-item">
<span className="proof-label">Категория</span>
<strong className={`category-${result?.category || 'default'}`}>{result?.category || '-'}</strong>
<strong className={`category-${result?.category ?? 'default'}`}>{result?.category ?? '—'}</strong>
</div>
<div className="proof-item">
<span className="proof-label">Маршрут</span>
<strong>{result?.category ? `Зона ${result.category}` : '-'}</strong>
<span className="proof-label">Зона</span>
<strong>{result?.category ? `Зона ${result.category}` : '—'}</strong>
</div>
<div className="proof-item">
<div className="proof-item proof-item-wide">
<span className="proof-label">Команда</span>
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
<strong>{command}</strong>
</div>
</div>
<div className="proof-details">
<div className="detail-row">
<span>Габариты:</span>
<span>Габариты</span>
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} мм
{dimensions?.width} × {dimensions?.depth} × {dimensions?.height} мм
</strong>
</div>
<div className="detail-row">
<span>Roundness:</span>
<span>Roundness</span>
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : ''}>
{item?.roundness.toFixed(2)}
</strong>
@@ -59,12 +63,15 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
</div>
<div className="proof-reason">
<strong>Почему такое решение:</strong> {result?.reason}
{lowConfidence && <p className="warning-note">Низкая уверенность CV — использован rule-based fallback</p>}
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Конвейер остановлен</p>}
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: Аварийная остановка</p>}
{simulation.metrics.queueLength > 1 && <p className="warning-note">Внимание: очередь товаров</p>}
<strong>Причина решения</strong>
<p>{result?.reason}</p>
{lowConfidence ? <p className="warning-note">Низкая уверенность CV — rule-based fallback</p> : null}
{simulation.machineState === 'FAULT' ? <p className="error-note">FAULT: конвейер остановлен</p> : null}
{simulation.machineState === 'EMERGENCY_STOP' ? (
<p className="error-note">EMERGENCY_STOP: аварийная остановка</p>
) : null}
{simulation.metrics.queueLength > 1 ? <p className="warning-note">Очередь товаров</p> : null}
</div>
</div>
);
}
}

View File

@@ -1,36 +1,107 @@
import { useState } from 'react';
import { forwardRef, useImperativeHandle, 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';
import ScenarioPanel from './ScenarioPanel';
import OzonCriteriaPanel from './OzonCriteriaPanel';
import SorterScene from './SorterScene';
import { DEMO_STEPS } from '../data/demoSteps';
import { SCENARIOS } from '../data/scenarios';
import type { ScenarioId, SimulationState } from '../domain/types';
export default function EngineeringDetails({ simulation }: { simulation: SimulationState }) {
const [isOpen, setIsOpen] = useState(false);
return (
<section className="engineering-details-section" id="engineering">
<div className="engineering-toggle" onClick={() => setIsOpen(!isOpen)}>
<h2>Инженерные детали (Engineering Mode)</h2>
<button className="btn-secondary">
{isOpen ? 'Свернуть' : 'Развернуть'}
</button>
</div>
{isOpen && (
<div className="engineering-content">
<div className="engineering-grid">
<div className="eng-panel"><StateMachinePanel currentState={simulation.machineState} /></div>
<div className="eng-panel"><MetricsPanel metrics={simulation.metrics} /></div>
<div className="eng-panel"><SensorPanel simulation={simulation} /></div>
<div className="eng-panel"><TimelinePanel simulation={simulation} /></div>
<div className="eng-panel"><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
<div className="eng-panel"><EventLog events={simulation.events} /></div>
</div>
</div>
)}
</section>
);
export interface EngineeringDetailsHandle {
open: () => void;
}
interface Props {
simulation: SimulationState;
onScenarioChange: (id: ScenarioId) => void;
}
const EngineeringDetails = forwardRef<EngineeringDetailsHandle, Props>(
function EngineeringDetails({ simulation, onScenarioChange }, ref) {
const [isOpen, setIsOpen] = useState(false);
const currentStep =
DEMO_STEPS.find((step) => step.scenarioId === simulation.scenario.id) ?? DEMO_STEPS[0];
useImperativeHandle(ref, () => ({
open: () => {
setIsOpen(true);
requestAnimationFrame(() => {
document.getElementById('engineering')?.scrollIntoView({ behavior: 'smooth' });
});
},
}));
return (
<section className="engineering-details-section" id="engineering" aria-labelledby="engineering-title">
<div className="engineering-toggle">
<div>
<p className="section-eyebrow">For experts</p>
<h2 id="engineering-title">Engineering Details</h2>
<p className="engineering-lead">
Полные технические панели: state machine, sensors, PID, timeline, event log и критерии.
</p>
</div>
<button
type="button"
className="btn-secondary"
onClick={() => setIsOpen((value) => !value)}
aria-expanded={isOpen}
aria-controls="engineering-content"
>
{isOpen ? 'Свернуть' : 'Открыть инженерный режим'}
</button>
</div>
{isOpen ? (
<div className="engineering-content" id="engineering-content">
<div className="eng-panel eng-panel-wide">
<SorterScene simulation={simulation} variant="full" />
</div>
<div className="engineering-grid">
<div className="eng-panel">
<StateMachinePanel currentState={simulation.machineState} />
</div>
<div className="eng-panel">
<MetricsPanel metrics={simulation.metrics} />
</div>
<div className="eng-panel">
<SensorPanel simulation={simulation} />
</div>
<div className="eng-panel">
<TimelinePanel simulation={simulation} />
</div>
<div className="eng-panel">
<PidPanel
pid={simulation.pid}
metrics={simulation.metrics}
status={simulation.systemStatus}
/>
</div>
<div className="eng-panel">
<ScenarioPanel
scenarios={SCENARIOS}
activeScenarioId={simulation.scenario.id}
onScenarioChange={onScenarioChange}
/>
</div>
<div className="eng-panel eng-panel-wide">
<EventLog events={simulation.events} />
</div>
<div className="eng-panel eng-panel-wide">
<OzonCriteriaPanel currentStep={currentStep} />
</div>
</div>
</div>
) : null}
</section>
);
},
);
export default EngineeringDetails;

View File

@@ -2,30 +2,49 @@ import type { SimulationState } from '../domain/types';
interface HeaderProps {
simulation: SimulationState;
onReset: () => void;
onOpenDemo: () => void;
onOpenScenarios: () => void;
onOpenEngineering: () => void;
}
export default function Header({ simulation }: HeaderProps) {
export default function Header({
simulation,
onReset,
onOpenDemo,
onOpenScenarios,
onOpenEngineering,
}: HeaderProps) {
return (
<header className="site-header">
<div className="header-logo">
<span className="logo-icon">O</span>
<span className="logo-icon" aria-hidden="true">
O
</span>
<div className="logo-text">
<span className="logo-title">OZON Tech</span>
<span className="logo-subtitle">Sorter Simulation</span>
<span className="logo-title">OZON Tech Sorter</span>
<span className="logo-subtitle">Product Demo</span>
</div>
</div>
<div className="header-nav">
<a href="#demo" className="nav-link">Демо</a>
<a href="#scenarios" className="nav-link">Сценарии</a>
<a href="#criteria" className="nav-link">Критерии</a>
<a href="#engineering" className="nav-link">Инженерный режим</a>
</div>
<div className="header-status-compact">
<div className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></div>
<nav className="header-nav" aria-label="Основная навигация">
<button type="button" className="nav-link" onClick={onOpenDemo}>
Демо
</button>
<button type="button" className="nav-link" onClick={onOpenScenarios}>
Сценарии
</button>
<button type="button" className="nav-link" onClick={onOpenEngineering}>
Инженерный режим
</button>
<button type="button" className="nav-link nav-reset" onClick={onReset}>
Reset
</button>
</nav>
<div className="header-status-compact" aria-live="polite">
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`} aria-hidden="true" />
<span>{simulation.systemStatus}</span>
<span className="sim-time">t+{(simulation.simTimeMs / 1000).toFixed(1)}s</span>
</div>
</header>
);

View File

@@ -1,14 +1,27 @@
export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }) {
interface HeroSectionProps {
onStartDemo: () => void;
onShowScenarios: () => void;
onOpenEngineering: () => void;
}
export default function HeroSection({
onStartDemo,
onShowScenarios,
onOpenEngineering,
}: HeroSectionProps) {
return (
<section className="hero-section">
<section className="hero-section" aria-labelledby="hero-title">
<div className="hero-content">
<h1 className="hero-title">Интеллектуальная система сортировки товаров</h1>
<p className="hero-eyebrow">OZON Tech · Product Demo</p>
<h1 id="hero-title" className="hero-title">
Интеллектуальная система сортировки товаров
</h1>
<p className="hero-subtitle">
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
управляющая команда и маршрутизация в зоны B, C, D.
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
управляющая команда и маршрутизация в зоны B/C/D.
</p>
<div className="hero-badges">
<div className="hero-badges" aria-label="Ключевые возможности">
<span className="hero-badge">Computer Vision</span>
<span className="hero-badge">Rule-based classification</span>
<span className="hero-badge">Actuator routing</span>
@@ -17,34 +30,43 @@ export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }
</div>
<div className="hero-actions">
<button className="btn-primary" onClick={onStartDemo}>Запустить демо</button>
<a href="#scenarios" className="btn-secondary">Показать сценарии</a>
<a href="#engineering" className="btn-secondary">Инженерный режим</a>
<button type="button" className="btn-primary" onClick={onStartDemo}>
Запустить демо
</button>
<button type="button" className="btn-secondary" onClick={onShowScenarios}>
Показать сценарии
</button>
<button type="button" className="btn-secondary" onClick={onOpenEngineering}>
Инженерный режим
</button>
</div>
</div>
<div className="hero-visual">
<div className="hero-visual" aria-hidden="true">
<div className="hero-chain">
<div className="chain-step">
<div className="chain-icon">📷</div>
<div className="chain-icon">1</div>
<span>Detection</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">🧠</div>
<div className="chain-icon">2</div>
<span>Classification</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">⚡</div>
<div className="chain-icon">3</div>
<span>Command</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">📦</div>
<div className="chain-icon">4</div>
<span>Routing</span>
</div>
</div>
<p className="hero-chain-caption">
Результат демо: категория B/C/D, команда ROUTE_TO_* и целевая зона
</p>
</div>
</section>
);

View File

@@ -1,40 +1,97 @@
import SorterScene from './SorterScene';
import CurrentProofCard from './CurrentProofCard';
import StorylineStepper from './StorylineStepper';
import type { SimulationState } from '../domain/types';
interface Props {
simulation: SimulationState;
demoStepTitle: string;
onStartDemo: () => void;
onNext: () => void;
onReset: () => void;
demoStepTitle: string;
onOpenEngineering: () => void;
}
export default function ProductDemoSection({ simulation, onNext, onReset, demoStepTitle }: Props) {
export default function ProductDemoSection({
simulation,
demoStepTitle,
onStartDemo,
onNext,
onReset,
onOpenEngineering,
}: Props) {
const item = simulation.currentItem;
const category = item?.classification.category;
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: item?.classification
? `ROUTE_TO_${item.classification.category}`
: '—';
return (
<section className="product-demo-section" id="demo">
<section className="product-demo-section" id="demo" aria-labelledby="demo-title">
<div className="demo-header">
<h2>Live Demo</h2>
<div>
<p className="section-eyebrow">Live product demo</p>
<h2 id="demo-title">Главное демо</h2>
<p className="demo-lead">
Слева — упрощённая сцена конвейера. Справа — товар, категория, причина решения и команда маршрутизации.
</p>
</div>
<div className="demo-status">
Текущий шаг: <strong>{demoStepTitle}</strong>
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></span>
<span>Шаг:</span>
<strong>{demoStepTitle}</strong>
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`} aria-hidden="true" />
<span className="demo-status-text">{simulation.systemStatus}</span>
</div>
</div>
<StorylineStepper simulation={simulation} />
<div className="demo-layout">
<div className="demo-scene-container">
<SorterScene simulation={simulation} />
</div>
<div className="demo-proof-container">
<CurrentProofCard simulation={simulation} />
<div className="demo-controls">
<button className="btn-primary" onClick={onNext}>Следующий шаг (Next)</button>
<button className="btn-secondary" onClick={onReset}>Сброс (Reset)</button>
<div className="demo-scene-column">
<p className="demo-mobile-hint">
Товар проходит камеру, классификатор и gate, затем уходит в зону B, C или D.
</p>
<div className="demo-scene-container">
<SorterScene simulation={simulation} variant="simple" />
</div>
</div>
<aside className="demo-result-column" aria-label="Результат классификации">
<CurrentProofCard simulation={simulation} />
<div className="demo-result-summary">
<div className="summary-item">
<span>Товар</span>
<strong>{item?.item.name ?? 'Ожидание'}</strong>
</div>
<div className="summary-item">
<span>Категория</span>
<strong className={category ? `category-${category}` : ''}>{category ?? '—'}</strong>
</div>
<div className="summary-item">
<span>Команда</span>
<strong>{command}</strong>
</div>
<div className="summary-item">
<span>Зона</span>
<strong>{category ? `Зона ${category}` : '—'}</strong>
</div>
</div>
<div className="demo-controls">
<button type="button" className="btn-primary" onClick={onStartDemo}>
Start demo
</button>
<button type="button" className="btn-primary" onClick={onNext}>
Next step
</button>
<button type="button" className="btn-secondary" onClick={onReset}>
Reset
</button>
<button type="button" className="btn-secondary" onClick={onOpenEngineering}>
Engineering details
</button>
</div>
</aside>
</div>
</section>
);

View File

@@ -6,39 +6,97 @@ interface Props {
onScenarioChange: (id: ScenarioId) => void;
}
const SCENARIO_UI: Record<
ScenarioId,
{ title: string; summary: string; badge: string; badgeTone: 'b' | 'c' | 'd' | 'mixed' | 'fault' }
> = {
normal_flow: {
title: 'Обычный товар',
summary: 'Стандартный поток с маршрутизацией в B, C и D.',
badge: 'B / C / D',
badgeTone: 'mixed',
},
oversized_item: {
title: 'Негабарит',
summary: 'Габариты превышают лимит — приоритет зоны C.',
badge: 'Зона C',
badgeTone: 'c',
},
round_object: {
title: 'Круглый объект',
summary: 'Габариты проходят, но круглое сечение ведёт в D.',
badge: 'Зона D',
badgeTone: 'd',
},
boundary_dimensions: {
title: 'Пограничные размеры',
summary: 'Проверка строгих min/max границ габаритов.',
badge: 'B / C',
badgeTone: 'mixed',
},
low_confidence: {
title: 'Низкая уверенность CV',
summary: 'Предупреждение CV и rule-based fallback.',
badge: 'Fallback',
badgeTone: 'b',
},
close_items: {
title: 'Очередь товаров',
summary: 'Близкие товары обрабатываются по одному циклу.',
badge: 'Queue',
badgeTone: 'mixed',
},
jam: {
title: 'Застревание',
summary: 'Jam у gate переводит линию в FAULT.',
badge: 'FAULT',
badgeTone: 'fault',
},
emergency_stop: {
title: 'Аварийная остановка',
summary: 'EMERGENCY_STOP останавливает движение до Reset.',
badge: 'E-STOP',
badgeTone: 'fault',
},
};
export default function ScenarioCards({ scenarios, activeScenarioId, onScenarioChange }: Props) {
return (
<section className="scenario-cards-section" id="scenarios">
<section className="scenario-cards-section" id="scenarios" aria-labelledby="scenarios-title">
<div className="section-header">
<h2>Сценарии тестирования (Jury Test Cases)</h2>
<p>Выберите сценарий для проверки поведения системы в разных условиях.</p>
<h2 id="scenarios-title">Сценарии</h2>
<p>Выберите кейс жюри: обычный поток, негабарит, круглое сечение, очередь или авария.</p>
</div>
<div className="scenario-grid">
{scenarios.map((scenario) => (
<div
key={scenario.id}
className={`scenario-card ${scenario.id === activeScenarioId ? 'active' : ''}`}
onClick={() => onScenarioChange(scenario.id)}
>
<div className="scenario-card-header">
<h3>{scenario.name}</h3>
{scenario.id === activeScenarioId && <span className="active-badge">Выбран</span>}
</div>
<p className="scenario-desc">{scenario.description}</p>
<div className="scenario-meta">
<div className="meta-item">
<span className="meta-label">Цель:</span>
<span className="meta-value">{scenario.goal}</span>
{scenarios.map((scenario) => {
const ui = SCENARIO_UI[scenario.id];
const active = scenario.id === activeScenarioId;
return (
<article
key={scenario.id}
className={`scenario-card ${active ? 'active' : ''}`}
>
<div className="scenario-card-header">
<h3>{ui.title}</h3>
<span className={`result-badge tone-${ui.badgeTone}`}>{ui.badge}</span>
</div>
<div className="meta-item">
<span className="meta-label">Результат:</span>
<span className="meta-value">{scenario.expectedCategorySummary}</span>
</div>
</div>
<button className="btn-secondary btn-small mt-auto">Применить</button>
</div>
))}
<p className="scenario-desc">{ui.summary}</p>
<p className="scenario-expected">
<span>Ожидаемый результат</span>
<strong>{scenario.expectedCategorySummary}</strong>
</p>
<button
type="button"
className={active ? 'btn-primary' : 'btn-secondary'}
onClick={() => onScenarioChange(scenario.id)}
>
{active ? 'Выбран' : 'Показать'}
</button>
</article>
);
})}
</div>
</section>
);

View File

@@ -64,106 +64,172 @@ function LegendItem({ color, label }: { color: string; label: string }) {
);
}
export default function SorterScene({ simulation }: { simulation: SimulationState }) {
interface SorterSceneProps {
simulation: SimulationState;
variant?: 'full' | 'simple';
}
export default function SorterScene({ simulation, variant = 'full' }: SorterSceneProps) {
const current = simulation.currentItem;
const category = current?.classification.category;
const color = category ? routeColors[category] : '#38bdf8';
const position = itemPosition(simulation);
const detecting = simulation.machineState === 'DETECTING';
const classifying = simulation.machineState === 'CLASSIFYING' || simulation.machineState === 'WAITING_AT_GATE';
const routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56;
const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48;
const isSimple = variant === 'simple';
return (
<div className="scene-wrap">
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
<h2>Engineering layout, sensors and routing commands</h2>
<div className={`scene-wrap scene-${variant}`}>
{!isSimple ? (
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
<h2>Engineering layout, sensors and routing commands</h2>
</div>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
) : (
<div className="scene-simple-header">
<p className="eyebrow">Конвейер · камера · classifier · gate · зоны B/C/D</p>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
)}
<svg viewBox="0 0 1120 720" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
<defs>
<pattern id="gridMinor" width="24" height="24" patternUnits="userSpaceOnUse">
<pattern id={`gridMinor-${variant}`} width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#12283b" strokeWidth="1" />
</pattern>
<pattern id="gridMajor" width="120" height="120" patternUnits="userSpaceOnUse">
<rect width="120" height="120" fill="url(#gridMinor)" />
<pattern id={`gridMajor-${variant}`} width="120" height="120" patternUnits="userSpaceOnUse">
<rect width="120" height="120" fill={`url(#gridMinor-${variant})`} />
<path d="M 120 0 L 0 0 0 120" fill="none" stroke="#244863" strokeWidth="1.4" />
</pattern>
{(['B', 'C', 'D'] as Category[]).map((route) => (
<marker key={route} id={`arrow${route}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<marker key={route} id={`arrow${route}-${variant}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill={routeColors[route]} />
</marker>
))}
</defs>
<rect x="24" y="26" width="1072" height="632" rx="12" fill="#07111d" stroke="#244863" />
<rect x="64" y="82" width="928" height="520" fill="url(#gridMajor)" opacity="0.9" />
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
{!isSimple ? (
<rect x="64" y="82" width="928" height="520" fill={`url(#gridMajor-${variant})`} opacity="0.9" />
) : null}
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
{!isSimple ? (
<>
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
</>
) : null}
<g className="zone zone-a">
<rect x="88" y="292" width="128" height="136" rx="8" />
<text x="152" y="282">A feed zone</text>
<text x="152" y={isSimple ? 370 : 282}>{isSimple ? 'A' : 'A feed zone'}</text>
</g>
<g className="zone zone-b">
<g className={`zone zone-b ${category === 'B' ? 'zone-active' : ''}`}>
<rect x="842" y="292" width="136" height="136" rx="8" />
<text x="910" y="282">B main sorter</text>
<text x="910" y={isSimple ? 370 : 282} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'B' : 'B main sorter'}
</text>
</g>
<g className="zone zone-d">
<g className={`zone zone-d ${category === 'D' ? 'zone-active' : ''}`}>
<rect x="642" y="112" width="210" height="124" rx="8" />
<text x="747" y="102">D roll-cage 1200 x 800 x 800 mm</text>
<text x="747" y={isSimple ? 185 : 102} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'D' : 'D roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<g className="zone zone-c">
<g className={`zone zone-c ${category === 'C' ? 'zone-active' : ''}`}>
<rect x="642" y="486" width="210" height="124" rx="8" />
<text x="747" y="632">C roll-cage 1200 x 800 x 800 mm</text>
<text x="747" y={isSimple ? 560 : 632} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'C' : 'C roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<rect className={stopped ? 'conveyor stopped' : 'conveyor'} x="106" y="314" width="850" height="92" rx="6" />
<line x1="126" y1="360" x2="936" y2="360" className="belt-center" />
{Array.from({ length: 18 }).map((_, index) => (
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
))}
{!isSimple
? Array.from({ length: 18 }).map((_, index) => (
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
))
: null}
<g className={simulation.sensors.camera.active ? 'device active' : 'device'}>
<rect x="350" y="218" width="76" height="50" rx="6" />
<line x1="388" y1="268" x2="388" y2="314" />
<text x="388" y="208">Camera / bbox</text>
</g>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="474" y="218" width="76" height="50" rx="6" />
<line x1="512" y1="268" x2="512" y2="314" />
<text x="512" y="208">Laser height</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="646" cy="243" r="28" />
<line x1="646" y1="271" x2="646" y2="314" />
<text x="646" y="208">Ultrasonic gate</text>
<text x="388" y="208">{isSimple ? 'Camera' : 'Camera / bbox'}</text>
</g>
{!isSimple ? (
<>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="474" y="218" width="76" height="50" rx="6" />
<line x1="512" y1="268" x2="512" y2="314" />
<text x="512" y="208">Laser height</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="646" cy="243" r="28" />
<line x1="646" y1="271" x2="646" y2="314" />
<text x="646" y="208">Ultrasonic gate</text>
</g>
</>
) : (
<g className={classifying || simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<rect x="520" y="218" width="110" height="50" rx="6" />
<line x1="575" y1="268" x2="575" y2="314" />
<text x="575" y="208">Classifier</text>
</g>
)}
<g className={simulation.gate.open ? 'gate open' : 'gate closed'}>
<line x1="708" y1="296" x2="708" y2="424" />
<text x="746" y="300">Stop-gate {simulation.gate.open ? 'open' : 'closed'}</text>
<text x="746" y="300">{isSimple ? 'Gate' : `Stop-gate ${simulation.gate.open ? 'open' : 'closed'}`}</text>
</g>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="625" y="424" width="174" height="34" rx="6" />
<text x="712" y="476">Pusher C command</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="625" y="262" width="174" height="34" rx="6" />
<text x="712" y="256">Pusher D command</text>
</g>
{!isSimple ? (
<>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="625" y="424" width="174" height="34" rx="6" />
<text x="712" y="476">Pusher C command</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="625" y="262" width="174" height="34" rx="6" />
<text x="712" y="256">Pusher D command</text>
</g>
</>
) : null}
<line
x1="706"
y1="360"
x2="928"
y2="360"
className={`route-guide route-b ${category === 'B' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowB-${variant})`}
/>
<line
x1="706"
y1="376"
x2="748"
y2="548"
className={`route-guide route-c ${category === 'C' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowC-${variant})`}
/>
<line
x1="706"
y1="344"
x2="748"
y2="174"
className={`route-guide route-d ${category === 'D' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowD-${variant})`}
/>
<line x1="706" y1="360" x2="928" y2="360" className="route-guide route-b" markerEnd="url(#arrowB)" />
<line x1="706" y1="376" x2="748" y2="548" className="route-guide route-c" markerEnd="url(#arrowC)" />
<line x1="706" y1="344" x2="748" y2="174" className="route-guide route-d" markerEnd="url(#arrowD)" />
{routeVisible && category ? (
<g className="route-command">
<rect x="788" y="326" width="156" height="34" rx="8" fill={routeColors[category]} />
@@ -184,11 +250,22 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
stroke="#ffffff"
strokeWidth="1.4"
/>
<text x={position.x} y={position.y + itemHeight / 2 + 18} className="item-label">{current.item.id}</text>
<text x={position.x} y={position.y + itemHeight / 2 + 18} className="item-label">
{isSimple ? current.item.name : current.item.id}
</text>
{detecting ? (
<g className="bbox">
<rect x={position.x - itemWidth / 2 - 10} y={position.y - itemHeight / 2 - 10} width={itemWidth + 20} height={itemHeight + 20} />
<text x={position.x} y={position.y - itemHeight / 2 - 18}>bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm</text>
<rect
x={position.x - itemWidth / 2 - 10}
y={position.y - itemHeight / 2 - 10}
width={itemWidth + 20}
height={itemHeight + 20}
/>
{!isSimple ? (
<text x={position.x} y={position.y - itemHeight / 2 - 18}>
bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm
</text>
) : null}
</g>
) : null}
</g>
@@ -202,13 +279,23 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
</g>
) : null}
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B main sorter" />
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
</g>
{!isSimple ? (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B main sorter" />
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
</g>
) : (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B" />
<g transform="translate(70 0)"><LegendItem color="#f59e0b" label="C" /></g>
<g transform="translate(140 0)"><LegendItem color="#c084fc" label="D" /></g>
<g transform="translate(210 0)"><LegendItem color="#38bdf8" label="active" /></g>
<g transform="translate(320 0)"><LegendItem color="#fb3d4e" label="fault" /></g>
</g>
)}
</svg>
</div>
);

View File

@@ -1,65 +1,76 @@
import type { SimulationState } from '../domain/types';
import type { MachineState, SimulationState } from '../domain/types';
type StepKey = 'detection' | 'classification' | 'decision' | 'command' | 'routing';
type StepStatus = 'active' | 'completed' | 'pending';
const STEPS: Array<{ key: StepKey; number: number; label: string; description: string }> = [
{ key: 'detection', number: 1, label: 'Detection', description: 'Камера и датчики снимают габариты' },
{ key: 'classification', number: 2, label: 'Classification', description: 'Правила проверяют размеры и форму' },
{ key: 'decision', number: 3, label: 'Decision', description: 'Система выбирает категорию B/C/D' },
{ key: 'command', number: 4, label: 'Command', description: 'Формируется команда ROUTE_TO_*' },
{ key: 'routing', number: 5, label: 'Routing', description: 'Товар уходит в целевую зону' },
];
function activeStep(state: MachineState): StepKey | null {
if (state === 'IDLE') return null;
if (state === 'MOVING_TO_CAMERA' || state === 'DETECTING') return 'detection';
if (state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE') return 'classification';
if (state === 'CLASSIFYING') return 'decision';
if (state.startsWith('ROUTE_TO_')) return 'command';
if (state === 'RETURN_HOME') return 'routing';
if (state === 'FAULT' || state === 'EMERGENCY_STOP') return 'routing';
return null;
}
function stepStatus(key: StepKey, current: StepKey | null, machineState: MachineState): StepStatus {
if (machineState === 'IDLE') return 'pending';
const order: StepKey[] = ['detection', 'classification', 'decision', 'command', 'routing'];
const currentIndex = current ? order.indexOf(current) : -1;
const keyIndex = order.indexOf(key);
if (machineState === 'RETURN_HOME') {
return keyIndex < order.length - 1 ? 'completed' : 'active';
}
if (machineState === 'FAULT' || machineState === 'EMERGENCY_STOP') {
return keyIndex <= currentIndex ? 'active' : 'pending';
}
if (keyIndex < currentIndex) return 'completed';
if (keyIndex === currentIndex) return 'active';
return 'pending';
}
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 (
<div className="storyline-stepper">
<Step
label="1. Detection"
desc="Распознавание габаритов"
active={isDetection}
done={Boolean(item) && !isDetection}
/>
<Step
label="2. Classification"
desc="Дерево решений"
active={isClassification}
done={Boolean(item?.classification) && !isClassification && !isDetection}
/>
<Step
label="3. Decision"
desc="Выбор категории"
active={isClassification && state === 'CLASSIFYING'}
done={Boolean(item?.classification)}
/>
<Step
label="4. Command"
desc="Сигнал механизмам"
active={isCommand}
done={isRouting}
/>
<Step
label="5. Routing"
desc="Движение в зону"
active={isCommand || isRouting}
done={isRouting}
/>
</div>
);
}
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';
const current = activeStep(simulation.machineState);
return (
<div className={`story-step ${statusClass}`}>
<div className="step-indicator">
<div className="step-dot"></div>
<section className="storyline-section" id="storyline" aria-labelledby="storyline-title">
<div className="section-header">
<h2 id="storyline-title">Этапы цикла</h2>
<p>Текущий этап системы в цепочке Detection → Classification → Decision → Command → Routing.</p>
</div>
<div className="step-content">
<strong>{label}</strong>
<span>{desc}</span>
</div>
</div>
<ol className="storyline-stepper">
{STEPS.map((step) => {
const status = stepStatus(step.key, current, simulation.machineState);
return (
<li key={step.key} className={`story-step step-${status}`}>
<div className="step-indicator" aria-hidden="true">
<span className="step-number">{step.number}</span>
</div>
<div className="step-content">
<strong>{step.label}</strong>
<span>{step.description}</span>
<span className="step-status-label">
{status === 'active' ? 'Сейчас' : status === 'completed' ? 'Готово' : 'Ожидание'}
</span>
</div>
</li>
);
})}
</ol>
</section>
);
}

File diff suppressed because it is too large Load Diff