feat: add sorter simulation dashboard MVP
This commit is contained in:
26
src/components/ClassificationPanel.tsx
Normal file
26
src/components/ClassificationPanel.tsx
Normal 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 < {DIMENSION_LIMITS.roundnessThreshold}
|
||||
</li>
|
||||
<li className={result ? `category-${result.category}` : ''}>
|
||||
result: {result ? `${result.category} - ${result.label}` : 'waiting for item'}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
src/components/CurrentItemPanel.tsx
Normal file
27
src/components/CurrentItemPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/Dashboard.tsx
Normal file
66
src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/EventLog.tsx
Normal file
33
src/components/EventLog.tsx
Normal 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
33
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/MetricsPanel.tsx
Normal file
35
src/components/MetricsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/PidPanel.tsx
Normal file
42
src/components/PidPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/ScenarioPanel.tsx
Normal file
30
src/components/ScenarioPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/SensorPanel.tsx
Normal file
37
src/components/SensorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/components/SorterScene.tsx
Normal file
163
src/components/SorterScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/StateMachinePanel.tsx
Normal file
35
src/components/StateMachinePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user