feat: add sorter simulation dashboard MVP
This commit is contained in:
51
src/App.tsx
Normal file
51
src/App.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import { SCENARIOS } from './data/scenarios';
|
||||
import { createSimulation, setRunning, stepSimulation } from './domain/simulation';
|
||||
import type { ScenarioId, SimulationState } from './domain/types';
|
||||
|
||||
const TICK_MS = 250;
|
||||
|
||||
export default function App() {
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
|
||||
const activeScenario = useMemo(
|
||||
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
|
||||
[activeScenarioId],
|
||||
);
|
||||
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
|
||||
|
||||
useEffect(() => {
|
||||
setSimulation(createSimulation(activeScenario));
|
||||
}, [activeScenario]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!simulation.running) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setSimulation((current) => stepSimulation(current, TICK_MS));
|
||||
}, TICK_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [simulation.running]);
|
||||
|
||||
const handleStart = () => setSimulation((current) => setRunning(current, true));
|
||||
const handlePause = () => setSimulation((current) => setRunning(current, false));
|
||||
const handleReset = () => setSimulation(createSimulation(activeScenario));
|
||||
const handleStep = () => setSimulation((current) => stepSimulation(setRunning(current, false), 500, true));
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId);
|
||||
|
||||
return (
|
||||
<Dashboard
|
||||
simulation={simulation}
|
||||
scenarios={SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onStart={handleStart}
|
||||
onPause={handlePause}
|
||||
onReset={handleReset}
|
||||
onStep={handleStep}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
102
src/data/items.ts
Normal file
102
src/data/items.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Item } from '../domain/types';
|
||||
|
||||
export const ITEMS: Item[] = [
|
||||
{
|
||||
id: 'SKU-001',
|
||||
name: 'Box 300x200x200',
|
||||
dimensionsMm: { width: 300, depth: 200, height: 200 },
|
||||
roundness: 0.12,
|
||||
confidence: 0.94,
|
||||
shape: 'box',
|
||||
expectedCategory: 'B',
|
||||
},
|
||||
{
|
||||
id: 'SKU-002',
|
||||
name: 'Lunchbox 201x152x62',
|
||||
dimensionsMm: { width: 201, depth: 152, height: 62 },
|
||||
roundness: 0.22,
|
||||
confidence: 0.91,
|
||||
shape: 'rectangular prism',
|
||||
expectedCategory: 'B',
|
||||
},
|
||||
{
|
||||
id: 'SKU-003',
|
||||
name: 'Detergent 259x179x278',
|
||||
dimensionsMm: { width: 259, depth: 179, height: 278 },
|
||||
roundness: 0.38,
|
||||
confidence: 0.88,
|
||||
shape: 'bottle box',
|
||||
expectedCategory: 'B',
|
||||
},
|
||||
{
|
||||
id: 'SKU-004',
|
||||
name: 'Oversized box 401x300x400',
|
||||
dimensionsMm: { width: 401, depth: 300, height: 400 },
|
||||
roundness: 0.18,
|
||||
confidence: 0.9,
|
||||
shape: 'oversized box',
|
||||
expectedCategory: 'C',
|
||||
},
|
||||
{
|
||||
id: 'SKU-005',
|
||||
name: 'Pouf 489x264x489',
|
||||
dimensionsMm: { width: 489, depth: 264, height: 489 },
|
||||
roundness: 0.74,
|
||||
confidence: 0.86,
|
||||
shape: 'soft bulky item',
|
||||
expectedCategory: 'C',
|
||||
},
|
||||
{
|
||||
id: 'SKU-006',
|
||||
name: 'Plate 210x209x27',
|
||||
dimensionsMm: { width: 210, depth: 209, height: 27 },
|
||||
roundness: 0.95,
|
||||
confidence: 0.89,
|
||||
shape: 'round plate',
|
||||
expectedCategory: 'D',
|
||||
},
|
||||
{
|
||||
id: 'SKU-007',
|
||||
name: 'Bottle 91x91x305',
|
||||
dimensionsMm: { width: 91, depth: 91, height: 305 },
|
||||
roundness: 0.92,
|
||||
confidence: 0.93,
|
||||
shape: 'cylinder bottle',
|
||||
expectedCategory: 'D',
|
||||
},
|
||||
{
|
||||
id: 'SKU-008',
|
||||
name: 'Cylinder 435x50x43',
|
||||
dimensionsMm: { width: 435, depth: 50, height: 43 },
|
||||
roundness: 0.88,
|
||||
confidence: 0.87,
|
||||
shape: 'long cylinder',
|
||||
expectedCategory: 'D',
|
||||
},
|
||||
{
|
||||
id: 'SKU-009',
|
||||
name: 'Pen 9x13x148',
|
||||
dimensionsMm: { width: 9, depth: 13, height: 148 },
|
||||
roundness: 0.66,
|
||||
confidence: 0.84,
|
||||
shape: 'thin item',
|
||||
expectedCategory: 'C',
|
||||
},
|
||||
{
|
||||
id: 'SKU-010',
|
||||
name: 'Boundary box 450x320x320',
|
||||
dimensionsMm: { width: 450, depth: 320, height: 320 },
|
||||
roundness: 0.2,
|
||||
confidence: 0.9,
|
||||
shape: 'boundary box',
|
||||
expectedCategory: 'B',
|
||||
},
|
||||
];
|
||||
|
||||
export function getItem(id: string): Item {
|
||||
const item = ITEMS.find((candidate) => candidate.id === id);
|
||||
if (!item) {
|
||||
throw new Error(`Unknown item id: ${id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
60
src/data/scenarios.ts
Normal file
60
src/data/scenarios.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { getItem } from './items';
|
||||
import type { Scenario } from '../domain/types';
|
||||
|
||||
export const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
id: 'normal_flow',
|
||||
name: 'Normal flow',
|
||||
description: 'Обычный поток из нескольких товаров B/C/D с полным циклом сортировки.',
|
||||
items: ['SKU-001', 'SKU-006', 'SKU-004', 'SKU-003', 'SKU-007'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'oversized_item',
|
||||
name: 'Oversized item',
|
||||
description: 'Товар выходит за max dimensions и должен попасть в roll-cage C.',
|
||||
items: ['SKU-004', 'SKU-005'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'round_object',
|
||||
name: 'Round object',
|
||||
description: 'Габариты проходят, но roundness >= 0.8, маршрут в D.',
|
||||
items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'boundary_dimensions',
|
||||
name: 'Boundary dimensions',
|
||||
description: 'Товары около min/max границ показывают устойчивость правил.',
|
||||
items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'close_items',
|
||||
name: 'Close items',
|
||||
description: 'Два товара близко друг к другу: warning queue/spacing и последовательная обработка.',
|
||||
items: ['SKU-001', 'SKU-002', 'SKU-006'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'low_confidence',
|
||||
name: 'Low confidence',
|
||||
description: 'CV confidence ниже 0.65, система предупреждает и принимает rule-based решение.',
|
||||
items: [
|
||||
{ ...getItem('SKU-003'), id: 'SKU-003-LC', confidence: 0.58 },
|
||||
{ ...getItem('SKU-006'), id: 'SKU-006-LC', confidence: 0.61 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'jam',
|
||||
name: 'Jam at gate',
|
||||
description: 'Застревание у stop-gate переводит систему в FAULT и останавливает конвейер.',
|
||||
items: ['SKU-004'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'emergency_stop',
|
||||
name: 'Emergency stop',
|
||||
description: 'Аварийная остановка переводит систему в EMERGENCY_STOP, движение остановлено.',
|
||||
items: ['SKU-001', 'SKU-006'].map(getItem),
|
||||
},
|
||||
];
|
||||
|
||||
export function getScenario(id: string): Scenario {
|
||||
return SCENARIOS.find((scenario) => scenario.id === id) ?? SCENARIOS[0];
|
||||
}
|
||||
52
src/domain/classifier.ts
Normal file
52
src/domain/classifier.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Category, CategoryDefinition, ClassificationResult, Item } from './types';
|
||||
|
||||
export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
|
||||
B: {
|
||||
label: 'Основной сортировщик',
|
||||
reason: 'Габариты подходят, круговое сечение не обнаружено',
|
||||
},
|
||||
C: {
|
||||
label: 'Неправильные габариты',
|
||||
reason: 'Нарушены допустимые габариты',
|
||||
},
|
||||
D: {
|
||||
label: 'Неправильная форма / доупаковка',
|
||||
reason: 'Обнаружен признак круглого сечения',
|
||||
},
|
||||
};
|
||||
|
||||
export const DIMENSION_LIMITS = {
|
||||
min: { width: 10, depth: 10, height: 2 },
|
||||
max: { width: 450, depth: 320, height: 320 },
|
||||
roundnessThreshold: 0.8,
|
||||
};
|
||||
|
||||
export function classifyItem(item: Item): ClassificationResult {
|
||||
const { width, depth, height } = item.dimensionsMm;
|
||||
const dimensionsPass =
|
||||
width >= DIMENSION_LIMITS.min.width &&
|
||||
depth >= DIMENSION_LIMITS.min.depth &&
|
||||
height >= DIMENSION_LIMITS.min.height &&
|
||||
width <= DIMENSION_LIMITS.max.width &&
|
||||
depth <= DIMENSION_LIMITS.max.depth &&
|
||||
height <= DIMENSION_LIMITS.max.height;
|
||||
|
||||
const roundnessPass = item.roundness < DIMENSION_LIMITS.roundnessThreshold;
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (item.confidence < 0.65) {
|
||||
warnings.push('CV confidence ниже 0.65, решение принято rule-based способом');
|
||||
}
|
||||
|
||||
const category: Category = !dimensionsPass ? 'C' : roundnessPass ? 'B' : 'D';
|
||||
const definition = CATEGORY_DEFINITIONS[category];
|
||||
|
||||
return {
|
||||
category,
|
||||
label: definition.label,
|
||||
reason: definition.reason,
|
||||
dimensionsPass,
|
||||
roundnessPass,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
31
src/domain/metrics.ts
Normal file
31
src/domain/metrics.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metrics } from './types';
|
||||
|
||||
export function createMetrics(): Metrics {
|
||||
return {
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
avgCycleTimeMs: 0,
|
||||
throughputItemsPerMin: 0,
|
||||
cvLatencyMs: 0,
|
||||
actuatorLatencyMs: 0,
|
||||
queueLength: 0,
|
||||
conveyorSpeedMps: 0,
|
||||
pidTargetSpeedMps: 0.42,
|
||||
pidActualSpeedMps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordCycle(metrics: Metrics, cycleTimeMs: number, success: boolean): Metrics {
|
||||
const processedCount = metrics.processedCount + 1;
|
||||
const totalCycleTime = metrics.avgCycleTimeMs * metrics.processedCount + cycleTimeMs;
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
processedCount,
|
||||
successCount: metrics.successCount + (success ? 1 : 0),
|
||||
errorCount: metrics.errorCount + (success ? 0 : 1),
|
||||
avgCycleTimeMs: Math.round(totalCycleTime / processedCount),
|
||||
throughputItemsPerMin: Number((60000 / Math.max(cycleTimeMs, 1)).toFixed(1)),
|
||||
};
|
||||
}
|
||||
24
src/domain/pid.ts
Normal file
24
src/domain/pid.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PidState } from './types';
|
||||
|
||||
export function createPidState(targetSpeedMps = 0.42): PidState {
|
||||
return {
|
||||
targetSpeedMps,
|
||||
actualSpeedMps: 0,
|
||||
pidError: targetSpeedMps,
|
||||
correction: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function updatePid(state: PidState, targetSpeedMps: number, deltaMs: number): PidState {
|
||||
const dt = Math.max(deltaMs / 1000, 0.1);
|
||||
const pidError = targetSpeedMps - state.actualSpeedMps;
|
||||
const correction = pidError * 0.42;
|
||||
const nextActual = Math.max(0, state.actualSpeedMps + correction * dt);
|
||||
|
||||
return {
|
||||
targetSpeedMps,
|
||||
actualSpeedMps: Number(nextActual.toFixed(3)),
|
||||
pidError: Number(pidError.toFixed(3)),
|
||||
correction: Number(correction.toFixed(3)),
|
||||
};
|
||||
}
|
||||
408
src/domain/simulation.ts
Normal file
408
src/domain/simulation.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { classifyItem } from './classifier';
|
||||
import { createMetrics, recordCycle } from './metrics';
|
||||
import { createPidState, updatePid } from './pid';
|
||||
import type {
|
||||
Category,
|
||||
EventLogEntry,
|
||||
MachineState,
|
||||
Scenario,
|
||||
SimulationState,
|
||||
SystemStatus,
|
||||
} from './types';
|
||||
|
||||
const STATE_DURATIONS_MS: Partial<Record<MachineState, number>> = {
|
||||
MOVING_TO_CAMERA: 1200,
|
||||
DETECTING: 900,
|
||||
MOVING_TO_GATE: 1300,
|
||||
WAITING_AT_GATE: 800,
|
||||
CLASSIFYING: 700,
|
||||
ROUTE_TO_B: 1200,
|
||||
ROUTE_TO_C: 1200,
|
||||
ROUTE_TO_D: 1200,
|
||||
RETURN_HOME: 700,
|
||||
};
|
||||
|
||||
const ROUTE_STATE_BY_CATEGORY: Record<Category, MachineState> = {
|
||||
B: 'ROUTE_TO_B',
|
||||
C: 'ROUTE_TO_C',
|
||||
D: 'ROUTE_TO_D',
|
||||
};
|
||||
|
||||
let eventCounter = 0;
|
||||
|
||||
function event(timestampMs: number, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): EventLogEntry {
|
||||
eventCounter += 1;
|
||||
return {
|
||||
id: `evt-${eventCounter}`,
|
||||
timestampMs,
|
||||
...entry,
|
||||
};
|
||||
}
|
||||
|
||||
function appendEvent(state: SimulationState, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): SimulationState {
|
||||
return {
|
||||
...state,
|
||||
events: [event(state.simTimeMs, entry), ...state.events].slice(0, 80),
|
||||
};
|
||||
}
|
||||
|
||||
function transitionTo(state: SimulationState, nextState: MachineState): SimulationState {
|
||||
const item = state.currentItem?.item;
|
||||
let next: SimulationState = {
|
||||
...state,
|
||||
machineState: nextState,
|
||||
elapsedInStateMs: 0,
|
||||
activeRoute: nextState.startsWith('ROUTE_TO_') ? state.currentItem?.classification.category : undefined,
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (nextState === 'DETECTING') {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'sensor',
|
||||
message: `Camera captured bbox, confidence ${Math.round(item.confidence * 100)}%`,
|
||||
category: state.currentItem?.classification.category,
|
||||
status: item.confidence < 0.65 ? 'warning' : 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextState === 'WAITING_AT_GATE') {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'actuator',
|
||||
message: 'Stop-gate closed, item fixed before classification',
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextState === 'CLASSIFYING' && state.currentItem) {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'classification',
|
||||
message: `${state.currentItem.classification.label}: ${state.currentItem.classification.reason}`,
|
||||
category: state.currentItem.classification.category,
|
||||
status: state.currentItem.classification.warnings.length > 0 ? 'warning' : 'success',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextState.startsWith('ROUTE_TO_') && state.currentItem) {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'routing',
|
||||
message: `Route selected: ${state.currentItem.classification.category}`,
|
||||
category: state.currentItem.classification.category,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function startNextItem(state: SimulationState): SimulationState {
|
||||
const item = state.scenario.items[state.itemIndex];
|
||||
if (!item) {
|
||||
return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED' };
|
||||
}
|
||||
|
||||
const classification = classifyItem(item);
|
||||
let next: SimulationState = {
|
||||
...state,
|
||||
currentItem: {
|
||||
item,
|
||||
classification,
|
||||
startedAtMs: state.simTimeMs,
|
||||
cycleTimeMs: 0,
|
||||
},
|
||||
machineState: 'MOVING_TO_CAMERA',
|
||||
elapsedInStateMs: 0,
|
||||
itemIndex: state.itemIndex + 1,
|
||||
};
|
||||
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'system',
|
||||
message: `Item entered zone A: ${item.name}`,
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
if (state.scenario.id === 'close_items' && state.itemIndex === 0) {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'warning',
|
||||
message: 'Queue/spacing warning: next item detected too close, processing sequentially',
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
for (const warning of classification.warnings) {
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'warning',
|
||||
message: warning,
|
||||
category: classification.category,
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function completeCurrentItem(state: SimulationState): SimulationState {
|
||||
if (!state.currentItem) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const cycleTimeMs = state.simTimeMs - state.currentItem.startedAtMs;
|
||||
const success = state.currentItem.classification.category === state.currentItem.item.expectedCategory;
|
||||
const metrics = recordCycle(state.metrics, cycleTimeMs, success);
|
||||
|
||||
let next = appendEvent(
|
||||
{
|
||||
...state,
|
||||
metrics,
|
||||
},
|
||||
{
|
||||
itemId: state.currentItem.item.id,
|
||||
type: 'routing',
|
||||
message: `Cycle completed in ${(cycleTimeMs / 1000).toFixed(1)}s`,
|
||||
category: state.currentItem.classification.category,
|
||||
status: success ? 'success' : 'error',
|
||||
},
|
||||
);
|
||||
|
||||
next = {
|
||||
...next,
|
||||
currentItem: undefined,
|
||||
activeRoute: undefined,
|
||||
};
|
||||
|
||||
return transitionTo(next, 'RETURN_HOME');
|
||||
}
|
||||
|
||||
function targetSpeedFor(state: SimulationState): number {
|
||||
if (state.machineState === 'FAULT' || state.machineState === 'EMERGENCY_STOP') {
|
||||
return 0;
|
||||
}
|
||||
if (!state.running) {
|
||||
return 0;
|
||||
}
|
||||
return state.scenario.id === 'close_items' ? 0.32 : 0.42;
|
||||
}
|
||||
|
||||
function deriveSensorsAndActuators(state: SimulationState): SimulationState {
|
||||
const item = state.currentItem?.item;
|
||||
const classification = state.currentItem?.classification;
|
||||
const cameraActive = state.machineState === 'DETECTING';
|
||||
const laserActive = state.machineState === 'DETECTING' || state.machineState === 'MOVING_TO_GATE';
|
||||
const ultrasoundActive = state.machineState === 'WAITING_AT_GATE' || state.machineState === 'CLASSIFYING';
|
||||
const route = classification?.category;
|
||||
const routeToC = state.machineState === 'ROUTE_TO_C';
|
||||
const routeToD = state.machineState === 'ROUTE_TO_D';
|
||||
|
||||
return {
|
||||
...state,
|
||||
gate: {
|
||||
open: state.machineState === 'ROUTE_TO_B' || state.machineState === 'IDLE',
|
||||
},
|
||||
actuators: {
|
||||
pusherC: routeToC ? 'extended' : state.machineState === 'RETURN_HOME' && route === 'C' ? 'retracting' : 'idle',
|
||||
pusherD: routeToD ? 'extended' : state.machineState === 'RETURN_HOME' && route === 'D' ? 'retracting' : 'idle',
|
||||
},
|
||||
sensors: {
|
||||
camera: {
|
||||
kind: 'camera',
|
||||
label: 'Camera / pseudo-CV',
|
||||
active: cameraActive,
|
||||
lastValue: item ? `bbox ${item.dimensionsMm.width}x${item.dimensionsMm.depth} mm` : 'idle',
|
||||
latencyMs: item ? Math.round(38 + (1 - item.confidence) * 80) : 0,
|
||||
lastEventTimestampMs: cameraActive ? state.simTimeMs : state.sensors.camera.lastEventTimestampMs,
|
||||
bbox: item ? { x: 0, y: 0, width: item.dimensionsMm.width, height: item.dimensionsMm.depth } : undefined,
|
||||
confidence: item?.confidence,
|
||||
cvLatencyMs: item ? Math.round(38 + (1 - item.confidence) * 80) : 0,
|
||||
},
|
||||
laser: {
|
||||
kind: 'laser',
|
||||
label: 'Laser height sensor',
|
||||
active: laserActive,
|
||||
lastValue: item ? `${item.dimensionsMm.height} mm` : 'idle',
|
||||
latencyMs: item ? 14 : 0,
|
||||
lastEventTimestampMs: laserActive ? state.simTimeMs : state.sensors.laser.lastEventTimestampMs,
|
||||
measuredHeightMm: item?.dimensionsMm.height,
|
||||
},
|
||||
ultrasound: {
|
||||
kind: 'ultrasound',
|
||||
label: 'Ultrasound gate sensor',
|
||||
active: ultrasoundActive,
|
||||
lastValue: ultrasoundActive ? 'object at gate' : item ? 'tracking' : 'idle',
|
||||
latencyMs: item ? 22 : 0,
|
||||
lastEventTimestampMs: ultrasoundActive ? state.simTimeMs : state.sensors.ultrasound.lastEventTimestampMs,
|
||||
distanceToGateMm: ultrasoundActive ? 0 : item ? 900 : undefined,
|
||||
objectAtGate: ultrasoundActive,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimulation(scenario: Scenario): SimulationState {
|
||||
const pid = createPidState();
|
||||
return deriveSensorsAndActuators({
|
||||
scenario,
|
||||
systemStatus: 'PAUSED',
|
||||
running: false,
|
||||
machineState: 'IDLE',
|
||||
itemIndex: 0,
|
||||
elapsedInStateMs: 0,
|
||||
simTimeMs: 0,
|
||||
currentItem: undefined,
|
||||
metrics: createMetrics(),
|
||||
sensors: {
|
||||
camera: {
|
||||
kind: 'camera',
|
||||
label: 'Camera / pseudo-CV',
|
||||
active: false,
|
||||
lastValue: 'idle',
|
||||
latencyMs: 0,
|
||||
lastEventTimestampMs: 0,
|
||||
cvLatencyMs: 0,
|
||||
},
|
||||
laser: {
|
||||
kind: 'laser',
|
||||
label: 'Laser height sensor',
|
||||
active: false,
|
||||
lastValue: 'idle',
|
||||
latencyMs: 0,
|
||||
lastEventTimestampMs: 0,
|
||||
},
|
||||
ultrasound: {
|
||||
kind: 'ultrasound',
|
||||
label: 'Ultrasound gate sensor',
|
||||
active: false,
|
||||
lastValue: 'idle',
|
||||
latencyMs: 0,
|
||||
lastEventTimestampMs: 0,
|
||||
objectAtGate: false,
|
||||
},
|
||||
},
|
||||
gate: { open: true },
|
||||
actuators: { pusherC: 'idle', pusherD: 'idle' },
|
||||
pid,
|
||||
events: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function setRunning(state: SimulationState, running: boolean): SimulationState {
|
||||
const systemStatus: SystemStatus = state.machineState === 'FAULT' ? 'FAULT' : state.machineState === 'EMERGENCY_STOP' ? 'EMERGENCY_STOP' : running ? 'RUNNING' : 'PAUSED';
|
||||
return deriveSensorsAndActuators({ ...state, running, systemStatus });
|
||||
}
|
||||
|
||||
export function stepSimulation(state: SimulationState, deltaMs: number, forceStep = false): SimulationState {
|
||||
if (!state.running && !forceStep) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let next: SimulationState = {
|
||||
...state,
|
||||
simTimeMs: state.simTimeMs + deltaMs,
|
||||
elapsedInStateMs: state.elapsedInStateMs + deltaMs,
|
||||
};
|
||||
|
||||
const shouldEmergencyStop = next.scenario.id === 'emergency_stop' && next.simTimeMs >= 1800;
|
||||
if (shouldEmergencyStop && next.machineState !== 'EMERGENCY_STOP') {
|
||||
next = appendEvent(next, {
|
||||
itemId: next.currentItem?.item.id,
|
||||
type: 'fault',
|
||||
message: 'Emergency stop pressed, all movement stopped',
|
||||
status: 'error',
|
||||
});
|
||||
next = { ...next, machineState: 'EMERGENCY_STOP', running: false, systemStatus: 'EMERGENCY_STOP' };
|
||||
}
|
||||
|
||||
const shouldJam = next.scenario.id === 'jam' && next.machineState === 'WAITING_AT_GATE' && next.elapsedInStateMs >= 700;
|
||||
if (shouldJam) {
|
||||
next = appendEvent(next, {
|
||||
itemId: next.currentItem?.item.id,
|
||||
type: 'fault',
|
||||
message: 'Jam detected at stop-gate, conveyor stopped',
|
||||
category: next.currentItem?.classification.category,
|
||||
status: 'error',
|
||||
});
|
||||
next = { ...next, machineState: 'FAULT', running: false, systemStatus: 'FAULT' };
|
||||
}
|
||||
|
||||
if (next.machineState === 'FAULT' || next.machineState === 'EMERGENCY_STOP') {
|
||||
const pid = updatePid(next.pid, 0, deltaMs);
|
||||
return deriveSensorsAndActuators({
|
||||
...next,
|
||||
pid,
|
||||
metrics: {
|
||||
...next.metrics,
|
||||
conveyorSpeedMps: pid.actualSpeedMps,
|
||||
pidTargetSpeedMps: pid.targetSpeedMps,
|
||||
pidActualSpeedMps: pid.actualSpeedMps,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (next.machineState === 'IDLE') {
|
||||
next = startNextItem(next);
|
||||
}
|
||||
|
||||
const duration = STATE_DURATIONS_MS[next.machineState];
|
||||
if (duration && next.elapsedInStateMs >= duration) {
|
||||
switch (next.machineState) {
|
||||
case 'MOVING_TO_CAMERA':
|
||||
next = transitionTo(next, 'DETECTING');
|
||||
break;
|
||||
case 'DETECTING':
|
||||
next = transitionTo(next, 'MOVING_TO_GATE');
|
||||
break;
|
||||
case 'MOVING_TO_GATE':
|
||||
next = transitionTo(next, 'WAITING_AT_GATE');
|
||||
break;
|
||||
case 'WAITING_AT_GATE':
|
||||
next = transitionTo(next, 'CLASSIFYING');
|
||||
break;
|
||||
case 'CLASSIFYING':
|
||||
if (next.currentItem) {
|
||||
next = transitionTo(next, ROUTE_STATE_BY_CATEGORY[next.currentItem.classification.category]);
|
||||
}
|
||||
break;
|
||||
case 'ROUTE_TO_B':
|
||||
case 'ROUTE_TO_C':
|
||||
case 'ROUTE_TO_D':
|
||||
next = completeCurrentItem(next);
|
||||
break;
|
||||
case 'RETURN_HOME':
|
||||
next = next.itemIndex < next.scenario.items.length ? startNextItem(next) : { ...next, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', elapsedInStateMs: 0 };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const targetSpeed = targetSpeedFor(next);
|
||||
const pid = updatePid(next.pid, targetSpeed, deltaMs);
|
||||
const queueLength = Math.max(next.scenario.items.length - next.itemIndex, 0) + (next.currentItem ? 1 : 0);
|
||||
const cvLatencyMs = next.sensors.camera.cvLatencyMs || next.metrics.cvLatencyMs;
|
||||
const actuatorLatencyMs = next.actuators.pusherC !== 'idle' || next.actuators.pusherD !== 'idle' ? 115 : next.machineState === 'ROUTE_TO_B' ? 55 : 0;
|
||||
|
||||
next = {
|
||||
...next,
|
||||
pid,
|
||||
systemStatus: next.running ? 'RUNNING' : next.systemStatus,
|
||||
metrics: {
|
||||
...next.metrics,
|
||||
cvLatencyMs,
|
||||
actuatorLatencyMs,
|
||||
queueLength,
|
||||
conveyorSpeedMps: pid.actualSpeedMps,
|
||||
pidTargetSpeedMps: pid.targetSpeedMps,
|
||||
pidActualSpeedMps: pid.actualSpeedMps,
|
||||
},
|
||||
};
|
||||
|
||||
return deriveSensorsAndActuators(next);
|
||||
}
|
||||
173
src/domain/types.ts
Normal file
173
src/domain/types.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
export type Category = 'B' | 'C' | 'D';
|
||||
|
||||
export type 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 type SystemStatus = 'RUNNING' | 'PAUSED' | 'FAULT' | 'EMERGENCY_STOP';
|
||||
|
||||
export type ScenarioId =
|
||||
| 'normal_flow'
|
||||
| 'oversized_item'
|
||||
| 'round_object'
|
||||
| 'boundary_dimensions'
|
||||
| 'close_items'
|
||||
| 'low_confidence'
|
||||
| 'jam'
|
||||
| 'emergency_stop';
|
||||
|
||||
export type SensorKind = 'camera' | 'laser' | 'ultrasound';
|
||||
export type EventType = 'system' | 'sensor' | 'classification' | 'actuator' | 'routing' | 'warning' | 'fault';
|
||||
export type EventStatus = 'info' | 'success' | 'warning' | 'error';
|
||||
export type PusherState = 'idle' | 'extended' | 'retracting';
|
||||
|
||||
export interface DimensionsMm {
|
||||
width: number;
|
||||
depth: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
dimensionsMm: DimensionsMm;
|
||||
roundness: number;
|
||||
confidence: number;
|
||||
shape: string;
|
||||
expectedCategory: Category;
|
||||
}
|
||||
|
||||
export interface CategoryDefinition {
|
||||
label: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClassificationResult {
|
||||
category: Category;
|
||||
label: string;
|
||||
reason: string;
|
||||
dimensionsPass: boolean;
|
||||
roundnessPass: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: ScenarioId;
|
||||
name: string;
|
||||
description: string;
|
||||
items: Item[];
|
||||
initialStatus?: SystemStatus;
|
||||
}
|
||||
|
||||
export interface EventLogEntry {
|
||||
id: string;
|
||||
timestampMs: number;
|
||||
itemId?: string;
|
||||
type: EventType;
|
||||
message: string;
|
||||
category?: Category;
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
avgCycleTimeMs: number;
|
||||
throughputItemsPerMin: number;
|
||||
cvLatencyMs: number;
|
||||
actuatorLatencyMs: number;
|
||||
queueLength: number;
|
||||
conveyorSpeedMps: number;
|
||||
pidTargetSpeedMps: number;
|
||||
pidActualSpeedMps: number;
|
||||
}
|
||||
|
||||
export interface SensorState {
|
||||
kind: SensorKind;
|
||||
label: string;
|
||||
active: boolean;
|
||||
lastValue: string;
|
||||
latencyMs: number;
|
||||
lastEventTimestampMs: number;
|
||||
}
|
||||
|
||||
export interface CameraState extends SensorState {
|
||||
kind: 'camera';
|
||||
bbox?: BoundingBox;
|
||||
confidence?: number;
|
||||
cvLatencyMs: number;
|
||||
}
|
||||
|
||||
export interface LaserState extends SensorState {
|
||||
kind: 'laser';
|
||||
measuredHeightMm?: number;
|
||||
}
|
||||
|
||||
export interface UltrasoundState extends SensorState {
|
||||
kind: 'ultrasound';
|
||||
distanceToGateMm?: number;
|
||||
objectAtGate: boolean;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface StopGateState {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ActuatorState {
|
||||
pusherC: PusherState;
|
||||
pusherD: PusherState;
|
||||
}
|
||||
|
||||
export interface PidState {
|
||||
targetSpeedMps: number;
|
||||
actualSpeedMps: number;
|
||||
pidError: number;
|
||||
correction: number;
|
||||
}
|
||||
|
||||
export interface SimulatedItem {
|
||||
item: Item;
|
||||
startedAtMs: number;
|
||||
classification: ClassificationResult;
|
||||
cycleTimeMs: number;
|
||||
}
|
||||
|
||||
export interface SimulationState {
|
||||
scenario: Scenario;
|
||||
systemStatus: SystemStatus;
|
||||
running: boolean;
|
||||
machineState: MachineState;
|
||||
itemIndex: number;
|
||||
elapsedInStateMs: number;
|
||||
simTimeMs: number;
|
||||
currentItem?: SimulatedItem;
|
||||
metrics: Metrics;
|
||||
sensors: {
|
||||
camera: CameraState;
|
||||
laser: LaserState;
|
||||
ultrasound: UltrasoundState;
|
||||
};
|
||||
gate: StopGateState;
|
||||
actuators: ActuatorState;
|
||||
pid: PidState;
|
||||
events: EventLogEntry[];
|
||||
activeRoute?: Category;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
520
src/styles.css
Normal file
520
src/styles.css
Normal file
@@ -0,0 +1,520 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050910;
|
||||
--panel: rgba(10, 19, 31, 0.92);
|
||||
--panel-strong: #0c1725;
|
||||
--line: #1e3a54;
|
||||
--muted: #91a4b8;
|
||||
--text: #e5f2ff;
|
||||
--cyan: #38bdf8;
|
||||
--green: #4ade80;
|
||||
--orange: #f59e0b;
|
||||
--purple: #c084fc;
|
||||
--red: #fb3d4e;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1180px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.13), transparent 26%),
|
||||
radial-gradient(circle at 86% 30%, rgba(192, 132, 252, 0.12), transparent 26%),
|
||||
linear-gradient(135deg, #03060b 0%, #07111d 54%, #050910 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #2a4a66;
|
||||
border-radius: 10px;
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
button:focus-visible {
|
||||
border-color: var(--cyan);
|
||||
background: #11273b;
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: rgba(7, 14, 23, 0.9);
|
||||
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 3vw, 42px);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--cyan);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.machine-state-chip,
|
||||
.scenario-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #284762;
|
||||
border-radius: 999px;
|
||||
background: #091522;
|
||||
color: #c8d7e8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-pill span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 18px var(--green);
|
||||
}
|
||||
|
||||
.status-paused span { background: var(--orange); box-shadow: 0 0 18px var(--orange); }
|
||||
.status-fault span,
|
||||
.status-emergency_stop span { background: var(--red); box-shadow: 0 0 18px var(--red); }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(760px, 1fr) 390px;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--panel);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 18px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.panel-scene {
|
||||
min-height: 672px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.scene-title-row,
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sorter-svg {
|
||||
width: 100%;
|
||||
height: 588px;
|
||||
border-radius: 16px;
|
||||
background: #060d16;
|
||||
}
|
||||
|
||||
.zone rect {
|
||||
fill: rgba(14, 27, 43, 0.82);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.zone text,
|
||||
.device text,
|
||||
.gate text,
|
||||
.pusher text,
|
||||
.item-label,
|
||||
.scale-label {
|
||||
fill: #b8c8da;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.zone-a rect { stroke: #38bdf8; }
|
||||
.zone-b rect { stroke: #4ade80; }
|
||||
.zone-c rect { stroke: #f59e0b; }
|
||||
.zone-d rect { stroke: #c084fc; }
|
||||
|
||||
.device rect,
|
||||
.device circle {
|
||||
fill: #0f2031;
|
||||
stroke: #31526d;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.device line {
|
||||
stroke: #31526d;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 5;
|
||||
}
|
||||
|
||||
.device.active rect,
|
||||
.device.active circle {
|
||||
stroke: var(--cyan);
|
||||
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.7));
|
||||
}
|
||||
|
||||
.device.active line {
|
||||
stroke: var(--cyan);
|
||||
}
|
||||
|
||||
.gate line {
|
||||
stroke-width: 7;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.gate.open line { stroke: var(--green); transform: rotate(22deg); transform-origin: 680px 315px; }
|
||||
.gate.closed line { stroke: var(--red); }
|
||||
|
||||
.pusher rect {
|
||||
fill: #101d2d;
|
||||
stroke: #395873;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.pusher.extended rect {
|
||||
stroke: var(--orange);
|
||||
filter: drop-shadow(0 0 12px rgba(245, 158, 11, 0.55));
|
||||
}
|
||||
|
||||
.right-rail {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.compact-panel,
|
||||
.metrics-panel,
|
||||
.pid-panel,
|
||||
.event-panel,
|
||||
.scenario-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 9px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kv-grid span,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kv-grid strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.category-B { color: var(--green); }
|
||||
.category-C { color: var(--orange); }
|
||||
.category-D { color: var(--purple); }
|
||||
|
||||
.decision-tree {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 20px;
|
||||
color: #b8c8da;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.decision-tree li {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 10px;
|
||||
background: #0b1725;
|
||||
}
|
||||
|
||||
.decision-tree .pass { border-color: rgba(74, 222, 128, 0.65); }
|
||||
.decision-tree .fail { border-color: rgba(245, 158, 11, 0.7); }
|
||||
|
||||
.state-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 9px;
|
||||
color: #95a7bc;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.state span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #33485c;
|
||||
}
|
||||
|
||||
.state.active {
|
||||
color: #f4fbff;
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.11);
|
||||
}
|
||||
|
||||
.state.active span {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 14px var(--cyan);
|
||||
}
|
||||
|
||||
.sensor-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sensor-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 12px;
|
||||
background: #0b1725;
|
||||
}
|
||||
|
||||
.sensor-row.active {
|
||||
border-color: var(--cyan);
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.sensor-row strong,
|
||||
.sensor-row span,
|
||||
.sensor-meta span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sensor-row span,
|
||||
.sensor-meta span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sensor-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actuator-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actuator-grid div,
|
||||
.pid-bars div {
|
||||
padding: 9px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 12px;
|
||||
background: #081422;
|
||||
}
|
||||
|
||||
.actuator-grid span,
|
||||
.pid-bars span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.actuator-grid strong,
|
||||
.pid-bars strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: 1.1fr 0.75fr 1.3fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 78px;
|
||||
padding: 10px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #0b1725, #07111d);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.metric-card em {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.pid-bars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.speed-track {
|
||||
height: 10px;
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #102034;
|
||||
}
|
||||
|
||||
.speed-track div {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--cyan), var(--green));
|
||||
}
|
||||
|
||||
.event-table {
|
||||
overflow: auto;
|
||||
max-height: 290px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 68px 92px 94px minmax(240px, 1fr) 72px 72px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #172d42;
|
||||
color: #c8d7e8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #0d1b2b;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.event-row.warning { color: #facc15; }
|
||||
.event-row.error { color: #fb7185; }
|
||||
.event-row.success { color: #86efac; }
|
||||
|
||||
.empty-log {
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scenario-buttons {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 290px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scenario-buttons button {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scenario-buttons button strong,
|
||||
.scenario-buttons button span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scenario-buttons button span {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scenario-buttons button.active {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.bottom-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.right-rail {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user