feat: enhance sorter simulation demo
This commit is contained in:
@@ -1,26 +1,48 @@
|
||||
import { DIMENSION_LIMITS } from '../domain/classifier';
|
||||
import type { SimulatedItem } from '../domain/types';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) {
|
||||
const result = currentItem?.classification;
|
||||
function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) {
|
||||
if (skipped) {
|
||||
return <span className="verdict skipped">SKIPPED</span>;
|
||||
}
|
||||
if (pass === undefined) {
|
||||
return <span className="verdict pending">PENDING</span>;
|
||||
}
|
||||
return <span className={pass ? 'verdict pass' : 'verdict fail'}>{pass ? 'PASS' : 'FAIL'}</span>;
|
||||
}
|
||||
|
||||
export default function ClassificationPanel({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const result = current?.classification;
|
||||
const item = current?.item;
|
||||
const dimensions = item?.dimensionsMm;
|
||||
const isOversizedAndRound = Boolean(result && !result.dimensionsPass && !result.roundnessPass);
|
||||
const lowConfidence = Boolean(item && item.confidence < 0.65);
|
||||
|
||||
return (
|
||||
<section className="panel compact-panel">
|
||||
<section className="panel compact-panel classification-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Classification rules</p>
|
||||
<h2>Decision tree</h2>
|
||||
<h2>Decision proof</h2>
|
||||
</div>
|
||||
<div className="rule-stack">
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 1: Dimensions check</strong><Verdict pass={result?.dimensionsPass} /></div>
|
||||
<p>Allowed: W {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} mm, D {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} mm, H {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} mm.</p>
|
||||
<p>Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}</p>
|
||||
</div>
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 2: Roundness check</strong><Verdict pass={result?.roundnessPass} skipped={result ? !result.dimensionsPass : false} /></div>
|
||||
<p>Roundness threshold for D: value >= {DIMENSION_LIMITS.roundnessThreshold}.</p>
|
||||
<p>Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}</p>
|
||||
</div>
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 3: Category decision</strong><span className={result ? `decision-badge category-${result.category}` : 'decision-badge'}>{result ? result.category : '-'}</span></div>
|
||||
<p>{result ? result.reason : 'Run a scenario to classify the current item.'}</p>
|
||||
{isOversizedAndRound ? <p className="warning-note">Приоритет: C, потому что габариты проверяются первыми.</p> : null}
|
||||
{lowConfidence ? <p className="warning-note">CV confidence низкий, решение подтверждено rule-based fallback.</p> : null}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const camera = simulation.sensors.camera;
|
||||
const laser = simulation.sensors.laser;
|
||||
|
||||
return (
|
||||
<section className="panel compact-panel">
|
||||
@@ -10,15 +12,27 @@ export default function CurrentItemPanel({ simulation }: { simulation: Simulatio
|
||||
<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>
|
||||
<>
|
||||
<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>Shape</span><strong>{current.item.shape}</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>
|
||||
<div className="pseudo-cv-block">
|
||||
<h3>Pseudo-CV / sensor packet</h3>
|
||||
<div className="kv-grid compact-kv">
|
||||
<span>bbox</span><strong>{camera.bbox ? `${camera.bbox.width} x ${camera.bbox.height} mm` : 'pending'}</strong>
|
||||
<span>confidence</span><strong>{camera.confidence ? `${Math.round(camera.confidence * 100)}%` : 'pending'}</strong>
|
||||
<span>cv latency</span><strong>{camera.cvLatencyMs} ms</strong>
|
||||
<span>detected dimensions</span><strong>{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm</strong>
|
||||
<span>measured height</span><strong>{laser.measuredHeightMm ?? current.item.dimensionsMm.height} mm</strong>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Start or step the simulation to feed an item into zone A.</p>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import EventLog from './EventLog';
|
||||
import ScenarioPanel from './ScenarioPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import type { Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -46,12 +47,13 @@ export default function Dashboard({
|
||||
</section>
|
||||
<aside className="right-rail">
|
||||
<CurrentItemPanel simulation={simulation} />
|
||||
<ClassificationPanel currentItem={simulation.currentItem} />
|
||||
<ClassificationPanel simulation={simulation} />
|
||||
<StateMachinePanel currentState={simulation.machineState} />
|
||||
<SensorPanel simulation={simulation} />
|
||||
</aside>
|
||||
<section className="bottom-row">
|
||||
<MetricsPanel metrics={simulation.metrics} />
|
||||
<TimelinePanel simulation={simulation} />
|
||||
<PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} />
|
||||
<EventLog events={simulation.events} />
|
||||
<ScenarioPanel
|
||||
|
||||
@@ -1,29 +1,63 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { EventLogEntry } from '../domain/types';
|
||||
|
||||
type LogFilter = 'all' | 'sensor' | 'classification' | 'actuator' | 'warning' | 'error' | 'success';
|
||||
|
||||
const filters: LogFilter[] = ['all', 'sensor', 'classification', 'actuator', 'warning', 'error', 'success'];
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return `t+${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function matchesFilter(entry: EventLogEntry, filter: LogFilter): boolean {
|
||||
if (filter === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (filter === 'error' || filter === 'success' || filter === 'warning') {
|
||||
return entry.status === filter;
|
||||
}
|
||||
return entry.type === filter;
|
||||
}
|
||||
|
||||
export default function EventLog({ events }: { events: EventLogEntry[] }) {
|
||||
const [filter, setFilter] = useState<LogFilter>('all');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const visibleEvents = useMemo(() => events.filter((entry) => matchesFilter(entry, filter)), [events, filter]);
|
||||
|
||||
const copyJson = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(events, null, 2));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel event-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Event log</p>
|
||||
<h2>Cycle trace</h2>
|
||||
<div>
|
||||
<p className="eyebrow">Event log</p>
|
||||
<h2>Cycle trace</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void copyJson()}>{copied ? 'Copied' : 'Copy JSON'}</button>
|
||||
</div>
|
||||
<div className="log-filters">
|
||||
{filters.map((item) => (
|
||||
<button key={item} className={filter === item ? 'active' : ''} onClick={() => setFilter(item)}>{item}</button>
|
||||
))}
|
||||
</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>
|
||||
<span>time</span><span>item</span><span>state</span><span>type</span><span>command/category</span><span>message</span><span>status</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="empty-log">No events yet. Press Start or Step.</div>
|
||||
) : events.map((entry) => (
|
||||
{visibleEvents.length === 0 ? (
|
||||
<div className="empty-log">No events for this filter.</div>
|
||||
) : visibleEvents.map((entry) => (
|
||||
<div className={`event-row ${entry.status}`} role="row" key={entry.id}>
|
||||
<span>{formatTime(entry.timestampMs)}</span>
|
||||
<span>{entry.itemId ?? '-'}</span>
|
||||
<span>{entry.state ?? '-'}</span>
|
||||
<span>{entry.type}</span>
|
||||
<span>{entry.command ?? entry.category ?? '-'}</span>
|
||||
<span>{entry.message}</span>
|
||||
<span>{entry.category ?? '-'}</span>
|
||||
<span>{entry.status}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) {
|
||||
const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div>
|
||||
@@ -21,11 +23,12 @@ export default function Header({ simulation, onStart, onPause, onReset, onStep }
|
||||
{simulation.systemStatus}
|
||||
</div>
|
||||
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
|
||||
<div className="scenario-label">Sim t+{(simulation.simTimeMs / 1000).toFixed(1)}s</div>
|
||||
<div className="header-actions">
|
||||
<button onClick={onStart}>Start</button>
|
||||
<button onClick={onStart} disabled={locked}>Start</button>
|
||||
<button onClick={onPause}>Pause</button>
|
||||
<button onClick={onReset}>Reset</button>
|
||||
<button onClick={onStep}>Step</button>
|
||||
<button onClick={onStep} disabled={locked}>Step state</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,6 +9,7 @@ const metricLabels: Array<[keyof Metrics, string, string]> = [
|
||||
['cvLatencyMs', 'CV latency', 'ms'],
|
||||
['actuatorLatencyMs', 'Actuator latency', 'ms'],
|
||||
['queueLength', 'Queue length', 'items'],
|
||||
['queueDelayMs', 'Queue delay', 'ms'],
|
||||
['conveyorSpeedMps', 'Conveyor speed', 'm/s'],
|
||||
['pidTargetSpeedMps', 'PID target', 'm/s'],
|
||||
['pidActualSpeedMps', 'PID actual', 'm/s'],
|
||||
|
||||
@@ -6,37 +6,53 @@ interface PidPanelProps {
|
||||
status: SystemStatus;
|
||||
}
|
||||
|
||||
function pidState(pid: PidState, status: SystemStatus): string {
|
||||
if (status === 'EMERGENCY_STOP') {
|
||||
return 'emergency stop';
|
||||
}
|
||||
if (status === 'FAULT') {
|
||||
return 'stopped by fault';
|
||||
}
|
||||
if (Math.abs(pid.pidError) <= 0.03 && pid.actualSpeedMps > 0) {
|
||||
return 'stable';
|
||||
}
|
||||
return 'stabilizing';
|
||||
}
|
||||
|
||||
function sparkline(history: number[]): string {
|
||||
const values = history.length > 1 ? history : [0, 0];
|
||||
const max = Math.max(...values, 0.5);
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const x = (index / Math.max(values.length - 1, 1)) * 220;
|
||||
const y = 58 - (value / max) * 48;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export default function PidPanel({ pid, status }: PidPanelProps) {
|
||||
const fill = Math.min((pid.actualSpeedMps / Math.max(pid.targetSpeedMps, 0.01)) * 100, 100);
|
||||
const state = pidState(pid, status);
|
||||
|
||||
return (
|
||||
<section className="panel pid-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Conveyor control loop</p>
|
||||
<h2>PID imitation</h2>
|
||||
<h2>{state}</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><span>target speed</span><strong>{pid.targetSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>actual speed</span><strong>{pid.actualSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>error</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>
|
||||
<div className="speed-track"><div style={{ width: `${fill}%` }} /></div>
|
||||
<svg className="pid-sparkline" viewBox="0 0 220 64" role="img" aria-label="PID speed history">
|
||||
<path d="M0 58 H220" />
|
||||
<polyline points={sparkline(pid.speedHistoryMps)} />
|
||||
</svg>
|
||||
<p className="muted">PID shown as simplified control-loop simulation for conveyor speed stabilization.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC
|
||||
<section className="panel scenario-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Scenario panel</p>
|
||||
<h2>Test cases</h2>
|
||||
<h2>Jury test cases</h2>
|
||||
</div>
|
||||
<div className="scenario-buttons">
|
||||
{scenarios.map((scenario) => (
|
||||
@@ -22,6 +22,9 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC
|
||||
>
|
||||
<strong>{scenario.name}</strong>
|
||||
<span>{scenario.description}</span>
|
||||
<em>Goal: {scenario.goal}</em>
|
||||
<em>Expected: {scenario.expectedCategorySummary}</em>
|
||||
<em>Shows: {scenario.demonstrates}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,15 @@ const routeColors: Record<Category, string> = {
|
||||
|
||||
function progressForState(state: MachineState, elapsedMs: number): number {
|
||||
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
|
||||
MOVING_TO_CAMERA: [0.08, 0.34, 1200],
|
||||
MOVING_TO_CAMERA: [0.06, 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],
|
||||
ROUTE_TO_B: [0.66, 0.93, 1200],
|
||||
ROUTE_TO_C: [0.66, 0.78, 1200],
|
||||
ROUTE_TO_D: [0.66, 0.78, 1200],
|
||||
RETURN_HOME: [0.93, 0.94, 700],
|
||||
};
|
||||
const segment = ratios[state];
|
||||
if (!segment) {
|
||||
@@ -28,20 +28,42 @@ function progressForState(state: MachineState, elapsedMs: number): number {
|
||||
|
||||
function itemPosition(simulation: SimulationState): { x: number; y: number } {
|
||||
const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
|
||||
const baseX = 120 + progress * 760;
|
||||
const beltY = 315;
|
||||
const baseX = 126 + progress * 790;
|
||||
const beltY = 360;
|
||||
|
||||
if (simulation.machineState === 'ROUTE_TO_C') {
|
||||
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
|
||||
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
|
||||
}
|
||||
|
||||
if (simulation.machineState === 'ROUTE_TO_D') {
|
||||
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
|
||||
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
|
||||
}
|
||||
|
||||
return { x: baseX, y: beltY };
|
||||
}
|
||||
|
||||
function DimensionLine({ x1, y1, x2, y2, label }: { x1: number; y1: number; x2: number; y2: number; label: string }) {
|
||||
const labelX = (x1 + x2) / 2;
|
||||
const labelY = (y1 + y2) / 2;
|
||||
return (
|
||||
<g className="dimension-line">
|
||||
<line x1={x1} y1={y1} x2={x2} y2={y2} />
|
||||
<circle cx={x1} cy={y1} r="3" />
|
||||
<circle cx={x2} cy={y2} r="3" />
|
||||
<text x={labelX} y={labelY - 8}>{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendItem({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<g className="legend-item">
|
||||
<rect width="10" height="10" fill={color} rx="2" />
|
||||
<text x="16" y="10">{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SorterScene({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const category = current?.classification.category;
|
||||
@@ -49,114 +71,144 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
|
||||
const position = itemPosition(simulation);
|
||||
const detecting = simulation.machineState === 'DETECTING';
|
||||
const routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
|
||||
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56;
|
||||
const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48;
|
||||
|
||||
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>
|
||||
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
|
||||
<h2>Engineering layout, sensors and routing commands</h2>
|
||||
</div>
|
||||
<div className="machine-state-chip">{simulation.machineState}</div>
|
||||
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 1000 620" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
|
||||
<svg viewBox="0 0 1120 720" 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 id="gridMinor" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#12283b" 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>
|
||||
<pattern id="gridMajor" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||
<rect width="120" height="120" fill="url(#gridMinor)" />
|
||||
<path d="M 120 0 L 0 0 0 120" fill="none" stroke="#244863" strokeWidth="1.4" />
|
||||
</pattern>
|
||||
{(['B', 'C', 'D'] as Category[]).map((route) => (
|
||||
<marker key={route} id={`arrow${route}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill={routeColors[route]} />
|
||||
</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" />
|
||||
<rect x="24" y="26" width="1072" height="632" rx="12" fill="#07111d" stroke="#244863" />
|
||||
<rect x="64" y="82" width="928" height="520" fill="url(#gridMajor)" opacity="0.9" />
|
||||
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
|
||||
|
||||
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
|
||||
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
|
||||
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
|
||||
|
||||
<g className="zone zone-a">
|
||||
<rect x="70" y="246" width="120" height="138" rx="14" />
|
||||
<text x="130" y="238">A feed</text>
|
||||
<rect x="88" y="292" width="128" height="136" rx="8" />
|
||||
<text x="152" y="282">A feed zone</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>
|
||||
<rect x="842" y="292" width="136" height="136" rx="8" />
|
||||
<text x="910" y="282">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>
|
||||
<rect x="642" y="112" width="210" height="124" rx="8" />
|
||||
<text x="747" y="102">D roll-cage 1200 x 800 x 800 mm</text>
|
||||
</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>
|
||||
<rect x="642" y="486" width="210" height="124" rx="8" />
|
||||
<text x="747" y="632">C roll-cage 1200 x 800 x 800 mm</text>
|
||||
</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" />
|
||||
<rect className={stopped ? 'conveyor stopped' : 'conveyor'} x="106" y="314" width="850" height="92" rx="6" />
|
||||
<line x1="126" y1="360" x2="936" y2="360" className="belt-center" />
|
||||
{Array.from({ length: 18 }).map((_, index) => (
|
||||
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
|
||||
))}
|
||||
|
||||
<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>
|
||||
<rect x="350" y="218" width="76" height="50" rx="6" />
|
||||
<line x1="388" y1="268" x2="388" y2="314" />
|
||||
<text x="388" y="208">Camera / bbox</text>
|
||||
</g>
|
||||
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
|
||||
<rect x="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>
|
||||
<rect x="474" y="218" width="76" height="50" rx="6" />
|
||||
<line x1="512" y1="268" x2="512" y2="314" />
|
||||
<text x="512" y="208">Laser height</text>
|
||||
</g>
|
||||
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
|
||||
<circle cx="620" cy="215" r="26" />
|
||||
<line x1="620" y1="241" x2="620" y2="275" />
|
||||
<text x="620" y="180">Ultrasound</text>
|
||||
<circle cx="646" cy="243" r="28" />
|
||||
<line x1="646" y1="271" x2="646" y2="314" />
|
||||
<text x="646" y="208">Ultrasonic gate</text>
|
||||
</g>
|
||||
|
||||
<g className={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>
|
||||
<line x1="708" y1="296" x2="708" y2="424" />
|
||||
<text x="746" y="300">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>
|
||||
<rect x="625" y="424" width="174" height="34" rx="6" />
|
||||
<text x="712" y="476">Pusher C command</text>
|
||||
</g>
|
||||
<g className={`pusher ${simulation.actuators.pusherD}`}>
|
||||
<rect x="600" y="226" width="160" height="32" rx="8" />
|
||||
<text x="680" y="220">Pusher D</text>
|
||||
<rect x="625" y="262" width="174" height="34" rx="6" />
|
||||
<text x="712" y="256">Pusher D command</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}
|
||||
<line x1="706" y1="360" x2="928" y2="360" className="route-guide route-b" markerEnd="url(#arrowB)" />
|
||||
<line x1="706" y1="376" x2="748" y2="548" className="route-guide route-c" markerEnd="url(#arrowC)" />
|
||||
<line x1="706" y1="344" x2="748" y2="174" className="route-guide route-d" markerEnd="url(#arrowD)" />
|
||||
{routeVisible && category ? (
|
||||
<g className="route-command">
|
||||
<rect x="788" y="326" width="156" height="34" rx="8" fill={routeColors[category]} />
|
||||
<text x="866" y="348">{simulation.machineState}</text>
|
||||
</g>
|
||||
) : 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}
|
||||
x={position.x - itemWidth / 2}
|
||||
y={position.y - itemHeight / 2}
|
||||
width={itemWidth}
|
||||
height={itemHeight}
|
||||
rx={current.item.shape.includes('round') || current.item.shape.includes('cylinder') ? Math.min(itemWidth, itemHeight) / 2 : 5}
|
||||
fill={color}
|
||||
opacity="0.92"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
<text x={position.x} y={position.y + 44} className="item-label">{current.item.id}</text>
|
||||
<text x={position.x} y={position.y + itemHeight / 2 + 18} 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" />
|
||||
<g className="bbox">
|
||||
<rect x={position.x - itemWidth / 2 - 10} y={position.y - itemHeight / 2 - 10} width={itemWidth + 20} height={itemHeight + 20} />
|
||||
<text x={position.x} y={position.y - itemHeight / 2 - 18}>bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm</text>
|
||||
</g>
|
||||
) : 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>
|
||||
{stopped ? (
|
||||
<g className="fault-overlay">
|
||||
<rect x="610" y="286" width="210" height="148" rx="10" />
|
||||
<text x="715" y="350">{simulation.machineState}</text>
|
||||
<text x="715" y="376">Conveyor stopped, reset required</text>
|
||||
</g>
|
||||
) : null}
|
||||
|
||||
<g className="scene-legend" transform="translate(78 664)">
|
||||
<LegendItem color="#4ade80" label="B main sorter" />
|
||||
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
|
||||
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
|
||||
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
|
||||
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
39
src/components/TimelinePanel.tsx
Normal file
39
src/components/TimelinePanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { buildCycleTimeline } from '../domain/simulation';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
function formatMs(ms?: number): string {
|
||||
return ms === undefined ? '-' : `t+${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function TimelinePanel({ simulation }: { simulation: SimulationState }) {
|
||||
const timeline = buildCycleTimeline(simulation);
|
||||
const current = simulation.currentItem;
|
||||
const totalCycleTime = current ? simulation.simTimeMs - current.startedAtMs : 0;
|
||||
|
||||
return (
|
||||
<section className="panel timeline-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Cycle timeline</p>
|
||||
<h2>{current ? current.item.id : 'No active cycle'}</h2>
|
||||
</div>
|
||||
<div className="timeline-summary">
|
||||
<div><span>total cycle</span><strong>{totalCycleTime} ms</strong></div>
|
||||
<div><span>cv latency</span><strong>{simulation.metrics.cvLatencyMs} ms</strong></div>
|
||||
<div><span>actuator latency</span><strong>{simulation.metrics.actuatorLatencyMs} ms</strong></div>
|
||||
<div><span>conveyor</span><strong>{simulation.metrics.conveyorSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>queue delay</span><strong>{simulation.metrics.queueDelayMs} ms</strong></div>
|
||||
</div>
|
||||
<div className="timeline-list">
|
||||
{timeline.map((entry) => (
|
||||
<div className={`timeline-row ${entry.status}`} key={entry.state}>
|
||||
<span className="timeline-dot" />
|
||||
<strong>{entry.state}</strong>
|
||||
<span>{formatMs(entry.startedAtMs)}</span>
|
||||
<span>{entry.durationMs} ms</span>
|
||||
<em>{entry.status}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user