feat: add sorter simulation dashboard MVP

This commit is contained in:
deploy
2026-07-04 00:26:08 +02:00
parent 482be98b49
commit 9395d25332
33 changed files with 3141 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
import type { SimulatedItem } from '../domain/types';
export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) {
const result = currentItem?.classification;
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Classification rules</p>
<h2>Decision tree</h2>
</div>
<ol className="decision-tree">
<li className={result ? (result.dimensionsPass ? 'pass' : 'fail') : ''}>
dimensions check: {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} W, {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} D, {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} H
</li>
<li className={result && result.dimensionsPass ? (result.roundnessPass ? 'pass' : 'fail') : ''}>
roundness check: threshold &lt; {DIMENSION_LIMITS.roundnessThreshold}
</li>
<li className={result ? `category-${result.category}` : ''}>
result: {result ? `${result.category} - ${result.label}` : 'waiting for item'}
</li>
</ol>
</section>
);
}

View File

@@ -0,0 +1,27 @@
import type { SimulationState } from '../domain/types';
export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) {
const current = simulation.currentItem;
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Current item</p>
<h2>{current?.item.id ?? 'No active item'}</h2>
</div>
{current ? (
<div className="kv-grid">
<span>Name</span><strong>{current.item.name}</strong>
<span>Dimensions</span><strong>{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm</strong>
<span>Height</span><strong>{current.item.dimensionsMm.height} mm</strong>
<span>Roundness</span><strong>{current.item.roundness.toFixed(2)}</strong>
<span>Confidence</span><strong>{Math.round(current.item.confidence * 100)}%</strong>
<span>Category</span><strong className={`category-${current.classification.category}`}>{current.classification.category} / {current.classification.label}</strong>
<span>Reason</span><strong>{current.classification.reason}</strong>
</div>
) : (
<p className="muted">Start or step the simulation to feed an item into zone A.</p>
)}
</section>
);
}

View File

@@ -0,0 +1,66 @@
import Header from './Header';
import SorterScene from './SorterScene';
import CurrentItemPanel from './CurrentItemPanel';
import ClassificationPanel from './ClassificationPanel';
import StateMachinePanel from './StateMachinePanel';
import MetricsPanel from './MetricsPanel';
import EventLog from './EventLog';
import ScenarioPanel from './ScenarioPanel';
import SensorPanel from './SensorPanel';
import PidPanel from './PidPanel';
import type { Scenario, ScenarioId, SimulationState } from '../domain/types';
interface DashboardProps {
simulation: SimulationState;
scenarios: Scenario[];
activeScenarioId: ScenarioId;
onStart: () => void;
onPause: () => void;
onReset: () => void;
onStep: () => void;
onScenarioChange: (scenarioId: ScenarioId) => void;
}
export default function Dashboard({
simulation,
scenarios,
activeScenarioId,
onStart,
onPause,
onReset,
onStep,
onScenarioChange,
}: DashboardProps) {
return (
<div className="app-shell">
<Header
simulation={simulation}
onStart={onStart}
onPause={onPause}
onReset={onReset}
onStep={onStep}
/>
<main className="dashboard-grid">
<section className="scene-column panel panel-scene">
<SorterScene simulation={simulation} />
</section>
<aside className="right-rail">
<CurrentItemPanel simulation={simulation} />
<ClassificationPanel currentItem={simulation.currentItem} />
<StateMachinePanel currentState={simulation.machineState} />
<SensorPanel simulation={simulation} />
</aside>
<section className="bottom-row">
<MetricsPanel metrics={simulation.metrics} />
<PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} />
<EventLog events={simulation.events} />
<ScenarioPanel
scenarios={scenarios}
activeScenarioId={activeScenarioId}
onScenarioChange={onScenarioChange}
/>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { EventLogEntry } from '../domain/types';
function formatTime(ms: number): string {
return `t+${(ms / 1000).toFixed(1)}s`;
}
export default function EventLog({ events }: { events: EventLogEntry[] }) {
return (
<section className="panel event-panel">
<div className="panel-heading">
<p className="eyebrow">Event log</p>
<h2>Cycle trace</h2>
</div>
<div className="event-table" role="table">
<div className="event-row event-head" role="row">
<span>time</span><span>item</span><span>type</span><span>message</span><span>category</span><span>status</span>
</div>
{events.length === 0 ? (
<div className="empty-log">No events yet. Press Start or Step.</div>
) : events.map((entry) => (
<div className={`event-row ${entry.status}`} role="row" key={entry.id}>
<span>{formatTime(entry.timestampMs)}</span>
<span>{entry.itemId ?? '-'}</span>
<span>{entry.type}</span>
<span>{entry.message}</span>
<span>{entry.category ?? '-'}</span>
<span>{entry.status}</span>
</div>
))}
</div>
</section>
);
}

33
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { SimulationState } from '../domain/types';
interface HeaderProps {
simulation: SimulationState;
onStart: () => void;
onPause: () => void;
onReset: () => void;
onStep: () => void;
}
export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) {
return (
<header className="header">
<div>
<p className="eyebrow">Intelligent robotic sorting line</p>
<h1>OZON Tech Sorter Simulation</h1>
</div>
<div className="header-status">
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
<span />
{simulation.systemStatus}
</div>
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
<div className="header-actions">
<button onClick={onStart}>Start</button>
<button onClick={onPause}>Pause</button>
<button onClick={onReset}>Reset</button>
<button onClick={onStep}>Step</button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,35 @@
import type { Metrics } from '../domain/types';
const metricLabels: Array<[keyof Metrics, string, string]> = [
['processedCount', 'Processed', 'items'],
['successCount', 'Success', 'items'],
['errorCount', 'Errors', 'items'],
['avgCycleTimeMs', 'Avg cycle', 'ms'],
['throughputItemsPerMin', 'Throughput', 'items/min'],
['cvLatencyMs', 'CV latency', 'ms'],
['actuatorLatencyMs', 'Actuator latency', 'ms'],
['queueLength', 'Queue length', 'items'],
['conveyorSpeedMps', 'Conveyor speed', 'm/s'],
['pidTargetSpeedMps', 'PID target', 'm/s'],
['pidActualSpeedMps', 'PID actual', 'm/s'],
];
export default function MetricsPanel({ metrics }: { metrics: Metrics }) {
return (
<section className="panel metrics-panel">
<div className="panel-heading">
<p className="eyebrow">Metrics</p>
<h2>Line telemetry</h2>
</div>
<div className="metric-grid">
{metricLabels.map(([key, label, unit]) => (
<div className="metric-card" key={key}>
<span>{label}</span>
<strong>{metrics[key]}</strong>
<em>{unit}</em>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,42 @@
import type { Metrics, PidState, SystemStatus } from '../domain/types';
interface PidPanelProps {
pid: PidState;
metrics: Metrics;
status: SystemStatus;
}
export default function PidPanel({ pid, status }: PidPanelProps) {
const fill = Math.min((pid.actualSpeedMps / Math.max(pid.targetSpeedMps, 0.01)) * 100, 100);
return (
<section className="panel pid-panel">
<div className="panel-heading">
<p className="eyebrow">Conveyor control loop</p>
<h2>PID imitation</h2>
</div>
<div className="pid-bars">
<div>
<span>targetSpeedMps</span>
<strong>{pid.targetSpeedMps.toFixed(2)}</strong>
</div>
<div>
<span>actualSpeedMps</span>
<strong>{pid.actualSpeedMps.toFixed(2)}</strong>
</div>
<div>
<span>pidError</span>
<strong>{pid.pidError.toFixed(2)}</strong>
</div>
<div>
<span>correction</span>
<strong>{pid.correction.toFixed(2)}</strong>
</div>
</div>
<div className="speed-track">
<div style={{ width: `${fill}%` }} />
</div>
<p className="muted">Status {status}: actual speed follows target during flow and decays to zero on jam/emergency.</p>
</section>
);
}

View File

@@ -0,0 +1,30 @@
import type { Scenario, ScenarioId } from '../domain/types';
interface ScenarioPanelProps {
scenarios: Scenario[];
activeScenarioId: ScenarioId;
onScenarioChange: (scenarioId: ScenarioId) => void;
}
export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioChange }: ScenarioPanelProps) {
return (
<section className="panel scenario-panel">
<div className="panel-heading">
<p className="eyebrow">Scenario panel</p>
<h2>Test cases</h2>
</div>
<div className="scenario-buttons">
{scenarios.map((scenario) => (
<button
key={scenario.id}
className={scenario.id === activeScenarioId ? 'active' : ''}
onClick={() => onScenarioChange(scenario.id)}
>
<strong>{scenario.name}</strong>
<span>{scenario.description}</span>
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import type { SensorState, SimulationState } from '../domain/types';
function SensorRow({ sensor }: { sensor: SensorState }) {
return (
<div className={sensor.active ? 'sensor-row active' : 'sensor-row'}>
<div>
<strong>{sensor.label}</strong>
<span>{sensor.lastValue}</span>
</div>
<div className="sensor-meta">
<span>{sensor.latencyMs} ms</span>
<span>t+{(sensor.lastEventTimestampMs / 1000).toFixed(1)}s</span>
</div>
</div>
);
}
export default function SensorPanel({ simulation }: { simulation: SimulationState }) {
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Sensors and actuators</p>
<h2>Live I/O</h2>
</div>
<div className="sensor-stack">
<SensorRow sensor={simulation.sensors.camera} />
<SensorRow sensor={simulation.sensors.laser} />
<SensorRow sensor={simulation.sensors.ultrasound} />
</div>
<div className="actuator-grid">
<div><span>Stop-gate</span><strong>{simulation.gate.open ? 'open' : 'closed'}</strong></div>
<div><span>Pusher C</span><strong>{simulation.actuators.pusherC}</strong></div>
<div><span>Pusher D</span><strong>{simulation.actuators.pusherD}</strong></div>
</div>
</section>
);
}

View File

@@ -0,0 +1,163 @@
import type { Category, MachineState, SimulationState } from '../domain/types';
const routeColors: Record<Category, string> = {
B: '#4ade80',
C: '#f59e0b',
D: '#c084fc',
};
function progressForState(state: MachineState, elapsedMs: number): number {
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
MOVING_TO_CAMERA: [0.08, 0.34, 1200],
DETECTING: [0.34, 0.36, 900],
MOVING_TO_GATE: [0.36, 0.64, 1300],
WAITING_AT_GATE: [0.64, 0.65, 800],
CLASSIFYING: [0.65, 0.66, 700],
ROUTE_TO_B: [0.66, 0.92, 1200],
ROUTE_TO_C: [0.66, 0.82, 1200],
ROUTE_TO_D: [0.66, 0.82, 1200],
RETURN_HOME: [0.92, 0.94, 700],
};
const segment = ratios[state];
if (!segment) {
return state === 'IDLE' ? 0 : 0.65;
}
const [from, to, duration] = segment;
return from + (to - from) * Math.min(elapsedMs / duration, 1);
}
function itemPosition(simulation: SimulationState): { x: number; y: number } {
const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
const baseX = 120 + progress * 760;
const beltY = 315;
if (simulation.machineState === 'ROUTE_TO_C') {
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
}
if (simulation.machineState === 'ROUTE_TO_D') {
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
}
return { x: baseX, y: beltY };
}
export default function SorterScene({ simulation }: { simulation: SimulationState }) {
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 routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
return (
<div className="scene-wrap">
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm</p>
<h2>Sorting line digital twin</h2>
</div>
<div className="machine-state-chip">{simulation.machineState}</div>
</div>
<svg viewBox="0 0 1000 620" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
<defs>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#183044" strokeWidth="1" />
</pattern>
<marker id="arrowB" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4ade80" />
</marker>
<marker id="arrowC" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#f59e0b" />
</marker>
<marker id="arrowD" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#c084fc" />
</marker>
</defs>
<rect x="20" y="20" width="960" height="560" rx="18" fill="#08111d" stroke="#1f3b54" />
<rect x="40" y="40" width="920" height="520" fill="url(#grid)" opacity="0.75" />
<g className="zone zone-a">
<rect x="70" y="246" width="120" height="138" rx="14" />
<text x="130" y="238">A feed</text>
</g>
<g className="zone zone-b">
<rect x="805" y="246" width="125" height="138" rx="14" />
<text x="868" y="238">B main sorter</text>
</g>
<g className="zone zone-d">
<rect x="610" y="76" width="190" height="110" rx="14" />
<text x="705" y="68">D shape / repack</text>
</g>
<g className="zone zone-c">
<rect x="610" y="444" width="190" height="110" rx="14" />
<text x="705" y="574">C oversize cage</text>
</g>
<rect x="90" y="275" width="810" height="80" rx="12" fill="#111f2e" stroke="#38526d" strokeWidth="2" />
<line x1="110" y1="315" x2="880" y2="315" stroke="#5c7087" strokeWidth="2" strokeDasharray="18 12" />
{Array.from({ length: 16 }).map((_, index) => (
<line key={index} x1={125 + index * 48} y1="284" x2={148 + index * 48} y2="346" stroke="#21374c" strokeWidth="3" />
))}
<g className={simulation.sensors.camera.active ? 'device active' : 'device'}>
<rect x="345" y="188" width="70" height="48" rx="8" />
<line x1="380" y1="236" x2="380" y2="275" />
<text x="380" y="180">Camera</text>
</g>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="465" y="188" width="70" height="48" rx="8" />
<line x1="500" y1="236" x2="500" y2="275" />
<text x="500" y="180">Laser</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="620" cy="215" r="26" />
<line x1="620" y1="241" x2="620" y2="275" />
<text x="620" y="180">Ultrasound</text>
</g>
<g className={simulation.gate.open ? 'gate open' : 'gate closed'}>
<line x1="680" y1="258" x2="680" y2="372" />
<text x="706" y="263">Stop-gate {simulation.gate.open ? 'open' : 'closed'}</text>
</g>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="600" y="372" width="160" height="32" rx="8" />
<text x="680" y="426">Pusher C</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="600" y="226" width="160" height="32" rx="8" />
<text x="680" y="220">Pusher D</text>
</g>
{routeVisible && category === 'B' ? <line x1="690" y1="315" x2="875" y2="315" stroke="#4ade80" strokeWidth="5" markerEnd="url(#arrowB)" /> : null}
{routeVisible && category === 'C' ? <line x1="690" y1="330" x2="705" y2="485" stroke="#f59e0b" strokeWidth="5" markerEnd="url(#arrowC)" /> : null}
{routeVisible && category === 'D' ? <line x1="690" y1="300" x2="705" y2="145" stroke="#c084fc" strokeWidth="5" markerEnd="url(#arrowD)" /> : null}
{current ? (
<g>
<rect
x={position.x - 28}
y={position.y - 24}
width="56"
height="48"
rx={current.item.shape.includes('round') || current.item.shape.includes('cylinder') ? 24 : 8}
fill={color}
opacity="0.92"
stroke="#ffffff"
strokeWidth="1.5"
/>
<text x={position.x} y={position.y + 44} className="item-label">{current.item.id}</text>
{detecting ? (
<rect x={position.x - 39} y={position.y - 34} width="78" height="68" fill="none" stroke="#38bdf8" strokeWidth="2" strokeDasharray="6 5" />
) : null}
</g>
) : null}
<text x="80" y="535" className="scale-label">Conveyor width 500 mm / height 700 mm</text>
<text x="80" y="555" className="scale-label">SVG scene is scaled for operator overview</text>
</svg>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import type { MachineState } from '../domain/types';
const states: MachineState[] = [
'IDLE',
'MOVING_TO_CAMERA',
'DETECTING',
'MOVING_TO_GATE',
'WAITING_AT_GATE',
'CLASSIFYING',
'ROUTE_TO_B',
'ROUTE_TO_C',
'ROUTE_TO_D',
'RETURN_HOME',
'FAULT',
'EMERGENCY_STOP',
];
export default function StateMachinePanel({ currentState }: { currentState: MachineState }) {
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">State machine</p>
<h2>{currentState}</h2>
</div>
<div className="state-list">
{states.map((state) => (
<div key={state} className={state === currentState ? 'state active' : 'state'}>
<span />
{state}
</div>
))}
</div>
</section>
);
}