design: redesign product demo UI
This commit is contained in:
119
src/App.tsx
119
src/App.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
3277
src/styles.css
3277
src/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user