feat: add guided demo view

This commit is contained in:
root
2026-07-04 01:21:59 +02:00
parent f3e2d16059
commit 2d50cdb454
10 changed files with 685 additions and 54 deletions

View File

@@ -5,6 +5,8 @@ import { SCENARIOS } from './data/scenarios';
import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation';
import type { DemoStep, PresentationMode, ScenarioId, SimulationState } from './domain/types';
import GuidedDemoView from './components/GuidedDemoView';
const TICK_MS = 250;
function isFaultDemoStep(step: DemoStep): boolean {
@@ -13,7 +15,7 @@ function isFaultDemoStep(step: DemoStep): boolean {
export default function App() {
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [presentationMode, setPresentationMode] = useState<PresentationMode>('engineering');
const [presentationMode, setPresentationMode] = useState<PresentationMode>('guided');
const [safeDemoEnabled, setSafeDemoEnabled] = useState(true);
const [demoStepIndex, setDemoStepIndex] = useState(0);
const [pendingFaultStepId, setPendingFaultStepId] = useState<string | undefined>();
@@ -87,7 +89,30 @@ export default function App() {
setActiveScenarioId(DEMO_STEPS[0].scenarioId);
};
return (
return presentationMode === 'guided' ? (
<GuidedDemoView
simulation={simulation}
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
presentationMode={presentationMode}
safeDemoEnabled={safeDemoEnabled}
demoSteps={DEMO_STEPS}
demoStepIndex={demoStepIndex}
currentDemoStep={currentDemoStep}
pendingFaultStepId={pendingFaultStepId}
onStart={handleStart}
onPause={handlePause}
onReset={handleReset}
onStep={handleStep}
onScenarioChange={handleScenarioChange}
onPresentationModeChange={setPresentationMode}
onSafeDemoChange={setSafeDemoEnabled}
onDemoStepChange={handleDemoStepChange}
onApplyDemoStepScenario={handleApplyDemoStepScenario}
onRunPreferredAction={handleRunPreferredAction}
onRestartDemo={handleRestartDemo}
/>
) : (
<Dashboard
simulation={simulation}
scenarios={SCENARIOS}

View File

@@ -0,0 +1,98 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
import type { SimulationState } from '../domain/types';
export default function CurrentProofCard({ simulation }: { simulation: SimulationState }) {
const current = simulation.currentItem;
const result = current?.classification;
const item = current?.item;
const dimensions = item?.dimensionsMm;
const lowConfidence = Boolean(item && item.confidence < 0.65);
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
if (!current) {
return (
<section className="proof-card proof-card-empty">
<div className="proof-card-inner">
<p className="eyebrow">Awaiting cycle</p>
<h2>Ready to demonstrate sorting cycle</h2>
<p>The demo will show detection, classification, control command and routing to B/C/D.</p>
<div className="evidence-chain empty-chain">
<span>Detection</span>
<span className="arrow">→</span>
<span>Classification</span>
<span className="arrow">→</span>
<span>Command</span>
<span className="arrow">→</span>
<span>Actuator</span>
<span className="arrow">→</span>
<span>Route</span>
<span className="arrow">→</span>
<span>Log</span>
</div>
<p className="start-prompt">Click <strong>Start Guided Demo</strong> or Next to begin.</p>
</div>
</section>
);
}
return (
<section className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
<div className="panel-heading">
<p className="eyebrow">Current Item Proof</p>
<h2>{item?.name} (ID: {item?.id})</h2>
</div>
<div className="proof-grid">
<div className="proof-item">
<span className="proof-label">Dimensions</span>
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} mm
</strong>
</div>
<div className="proof-item">
<span className="proof-label">Roundness</span>
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : ''}>
{item?.roundness.toFixed(2)}
</strong>
</div>
<div className="proof-item">
<span className="proof-label">Confidence</span>
<strong className={lowConfidence ? 'warn-text' : ''}>
{(item?.confidence ?? 0 * 100).toFixed(0)}%
</strong>
</div>
<div className="proof-item">
<span className="proof-label">Category</span>
<strong className={`category-${result?.category}`}>{result?.category}</strong>
</div>
<div className="proof-item">
<span className="proof-label">Command</span>
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
</div>
<div className="proof-item">
<span className="proof-label">Target zone</span>
<strong>{result?.category ? `Zone ${result.category}` : '-'}</strong>
</div>
</div>
<div className="proof-reason">
<strong>Decision reason:</strong> {result?.reason}
{lowConfidence && <p className="warning-note">Low CV confidence — rule-based fallback used</p>}
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Conveyor stopped, reset required</p>}
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: All actuators stopped</p>}
{simulation.metrics.queueLength > 1 && <p className="warning-note">Spacing warning: sequential processing enforced</p>}
</div>
<div className="evidence-chain active-chain">
<span className={simulation.machineState === 'DETECTING' ? 'active-step' : 'done-step'}>Detection</span>
<span className="arrow">→</span>
<span className={simulation.machineState === 'CLASSIFYING' ? 'active-step' : (result ? 'done-step' : '')}>Classification</span>
<span className="arrow">→</span>
<span className={simulation.machineState.startsWith('ROUTE_TO_') ? 'active-step' : (result ? 'done-step' : '')}>Command</span>
<span className="arrow">→</span>
<span className={simulation.actuators.pusherC !== 'idle' || simulation.actuators.pusherD !== 'idle' ? 'active-step' : (result ? 'done-step' : '')}>Actuator</span>
<span className="arrow">→</span>
<span className={simulation.machineState.startsWith('ROUTE_TO_') ? 'active-step' : ''}>Route</span>
<span className="arrow">→</span>
<span className={simulation.machineState === 'RETURN_HOME' ? 'active-step' : ''}>Log</span>
</div>
</section>
);
}

View File

@@ -0,0 +1,182 @@
import { useEffect } from 'react';
import SorterScene from './SorterScene';
import CurrentProofCard from './CurrentProofCard';
import { OZON_CRITERIA } from '../data/criteria';
import type { DemoStep, Scenario, ScenarioId, SimulationState, PresentationMode } from '../domain/types';
interface GuidedDemoViewProps {
simulation: SimulationState;
scenarios: Scenario[];
activeScenarioId: ScenarioId;
presentationMode: PresentationMode;
safeDemoEnabled: boolean;
demoSteps: DemoStep[];
demoStepIndex: number;
currentDemoStep: DemoStep;
pendingFaultStepId?: string;
onStart: () => void;
onPause: () => void;
onReset: () => void;
onStep: () => void;
onScenarioChange: (scenarioId: ScenarioId) => void;
onPresentationModeChange: (mode: any) => void;
onSafeDemoChange: (enabled: boolean) => void;
onDemoStepChange: (index: number) => void;
onApplyDemoStepScenario: (step: DemoStep) => void;
onRunPreferredAction: (step: DemoStep) => void;
onRestartDemo: () => void;
}
export default function GuidedDemoView({
simulation,
scenarios,
activeScenarioId,
demoSteps,
demoStepIndex,
currentDemoStep,
pendingFaultStepId,
safeDemoEnabled,
onStart,
onPause,
onReset,
onStep,
onPresentationModeChange,
onDemoStepChange,
onApplyDemoStepScenario,
onRunPreferredAction,
onRestartDemo,
}: GuidedDemoViewProps) {
const isFaultStep = currentDemoStep.scenarioId === 'jam' || currentDemoStep.scenarioId === 'emergency_stop';
const pendingFaultConfirmation = pendingFaultStepId === currentDemoStep.id;
const handleStartDemo = () => {
if (!simulation.currentItem && demoStepIndex === 0 && !simulation.running) {
onRunPreferredAction(currentDemoStep);
} else if (demoStepIndex === 0 && simulation.running) {
onRestartDemo();
} else {
onRunPreferredAction(currentDemoStep);
}
};
const handleNext = () => {
onDemoStepChange(demoStepIndex + 1);
};
const handlePrevious = () => {
onDemoStepChange(demoStepIndex - 1);
};
const relatedCriteriaList = OZON_CRITERIA.filter(c => currentDemoStep.relatedCriteria.includes(c.id));
return (
<div className="guided-demo-layout">
{/* 1. Top Demo Header */}
<header className="guided-header">
<div className="guided-header-left">
<p className="eyebrow">Guided Sorting Demo</p>
<h1>Step {demoStepIndex + 1} / {demoSteps.length}: {currentDemoStep.title}</h1>
</div>
<div className="guided-header-center">
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
<span />
{simulation.systemStatus}
</div>
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
</div>
<div className="guided-header-right">
<button className="primary-action" onClick={handleStartDemo}>
{simulation.currentItem ? 'Restart Guided Demo' : 'Start Guided Demo'}
</button>
<div className="nav-actions">
<button onClick={handlePrevious} disabled={demoStepIndex === 0}>Previous</button>
<button onClick={handleNext} disabled={demoStepIndex === demoSteps.length - 1}>Next</button>
</div>
<button onClick={onReset}>Reset</button>
<button onClick={() => onPresentationModeChange('engineering')}>Back to Engineering</button>
</div>
</header>
{/* Main Grid */}
<main className="guided-grid">
{/* 2. Large Scene Area */}
<section className="guided-left">
<div className="panel panel-scene guided-scene-panel">
<SorterScene simulation={simulation} />
<div className="guided-metrics-strip">
<div className="guided-metric">
<span>Cycle Time</span>
<strong>{simulation.metrics.avgCycleTimeMs} ms</strong>
</div>
<div className="guided-metric">
<span>CV Latency</span>
<strong>{simulation.metrics.cvLatencyMs} ms</strong>
</div>
<div className="guided-metric">
<span>Actuator Latency</span>
<strong>{simulation.metrics.actuatorLatencyMs} ms</strong>
</div>
<div className="guided-metric">
<span>Throughput</span>
<strong>{simulation.metrics.throughputItemsPerMin} items/min</strong>
</div>
</div>
</div>
</section>
{/* Right Column */}
<aside className="guided-right">
{/* 3. Current Proof Card */}
<CurrentProofCard simulation={simulation} />
{/* 4. Demo Narration Card */}
<section className="panel guided-narration">
<div className="panel-heading">
<p className="eyebrow">Demo Narration</p>
<h2>{currentDemoStep.title}</h2>
</div>
<p className="explanation">{currentDemoStep.explanation}</p>
<div className="narration-details">
<div>
<strong>Watch:</strong> {currentDemoStep.whatToWatch}
</div>
<div>
<strong>Jury Value:</strong> {currentDemoStep.juryValue}
</div>
</div>
<blockquote className="presenter-phrase">
"{currentDemoStep.presenterPhrase}"
</blockquote>
{isFaultStep && safeDemoEnabled && (
<button
className={`fault-confirm-btn ${pendingFaultConfirmation ? 'pending' : ''}`}
onClick={() => onApplyDemoStepScenario(currentDemoStep)}
>
{pendingFaultConfirmation ? 'Click again to confirm fault scenario' : 'Apply fault scenario'}
</button>
)}
</section>
{/* 5. Criteria Snapshot */}
<section className="panel guided-criteria">
<div className="panel-heading">
<p className="eyebrow">Criteria Snapshot</p>
<h2>This step covers</h2>
</div>
<div className="criteria-list-mini">
{relatedCriteriaList.map(c => (
<div key={c.id} className="criterion-mini">
<div className="criterion-mini-header">
<strong>{c.title}</strong>
<span className={`criterion-status status-${c.status.replace(/ /g, '-')}`}>{c.status}</span>
</div>
<p>{c.evidence}</p>
</div>
))}
</div>
</section>
</aside>
</main>
</div>
);
}

View File

@@ -34,7 +34,8 @@ export default function Header({
<div className="header-status">
<div className="mode-toggle" role="group" aria-label="Dashboard mode">
<button className={presentationMode === 'engineering' ? 'active' : ''} onClick={() => onPresentationModeChange('engineering')}>Engineering Mode</button>
<button className={presentationMode === 'presentation' ? 'active' : ''} onClick={() => onPresentationModeChange('presentation')}>Presentation Mode</button>
<button className={presentationMode === 'presentation' ? 'active' : ''} onClick={() => onPresentationModeChange('presentation')}>Presentation Dashboard</button>
<button className={presentationMode === 'guided' ? 'active' : ''} onClick={() => onPresentationModeChange('guided')}>Guided Demo</button>
</div>
{presentationMode === 'presentation' ? (
<button className={safeDemoEnabled ? 'safe-demo-toggle active' : 'safe-demo-toggle'} onClick={() => onSafeDemoChange(!safeDemoEnabled)}>

View File

@@ -188,7 +188,7 @@ export interface SimulationState {
}
export type PresentationMode = 'engineering' | 'presentation';
export type PresentationMode = 'engineering' | 'presentation' | 'guided';
export type DemoPreferredAction = 'start' | 'step' | 'reset' | 'pause';
export type DemoFocusArea = 'scene' | 'classification' | 'timeline' | 'pid' | 'eventLog' | 'criteria' | 'safety';
export type CriteriaStatus = 'covered' | 'partially covered' | 'demo step available';

View File

@@ -1123,3 +1123,303 @@ button:disabled {
grid-template-columns: 1fr;
}
}
/* Guided Demo View */
.guided-demo-layout {
display: flex;
flex-direction: column;
height: 100vh;
padding: 16px;
overflow: hidden;
background: var(--bg);
}
.guided-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
background: rgba(7, 14, 23, 0.95);
border: 1px solid var(--line);
border-radius: 16px;
margin-bottom: 16px;
flex-shrink: 0;
}
.guided-header h1 {
font-size: 24px;
}
.guided-header-center {
display: flex;
gap: 12px;
}
.guided-header-right {
display: flex;
gap: 10px;
align-items: center;
}
.primary-action {
background: var(--cyan);
color: #000;
border-color: var(--cyan);
font-size: 14px;
padding: 0 16px;
}
.primary-action:hover {
background: #7dd3fc;
color: #000;
}
.nav-actions {
display: flex;
gap: 6px;
}
.guided-grid {
display: grid;
grid-template-columns: 1fr 420px;
gap: 16px;
flex-grow: 1;
min-height: 0;
}
.guided-left {
display: flex;
flex-direction: column;
min-height: 0;
}
.guided-scene-panel {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 16px;
min-height: 0;
}
.guided-scene-panel .scene-wrap {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.guided-scene-panel svg {
flex-grow: 1;
height: auto;
max-height: 100%;
}
.guided-metrics-strip {
display: flex;
justify-content: space-around;
gap: 12px;
margin-top: 16px;
padding: 12px;
background: rgba(0,0,0,0.2);
border-radius: 12px;
border: 1px solid var(--line);
flex-shrink: 0;
}
.guided-metric {
text-align: center;
}
.guided-metric span {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.guided-metric strong {
font-size: 18px;
color: var(--text);
}
.guided-right {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding-right: 4px;
}
/* Proof Card */
.proof-card {
padding: 16px;
background: #081422;
border: 1px solid var(--cyan);
border-radius: 16px;
box-shadow: 0 0 20px rgba(56, 189, 248, 0.1);
flex-shrink: 0;
}
.proof-card-stopped {
border-color: var(--red);
box-shadow: 0 0 20px rgba(251, 61, 78, 0.15);
}
.proof-card-empty {
border-color: #243e57;
box-shadow: none;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.proof-card-empty h2 {
font-size: 20px;
margin: 12px 0;
}
.proof-card-empty p {
color: var(--muted);
margin-bottom: 16px;
}
.proof-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.proof-item {
background: #0d1b2a;
padding: 10px;
border-radius: 10px;
border: 1px solid #1a2f44;
}
.proof-label {
display: block;
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.proof-item strong {
font-size: 16px;
}
.fail-text { color: var(--red); }
.warn-text { color: var(--orange); }
.proof-reason {
background: #0d1b2a;
padding: 12px;
border-radius: 10px;
border: 1px solid #1a2f44;
font-size: 13px;
margin-bottom: 16px;
}
.proof-reason .warning-note { margin-top: 8px; font-weight: bold; }
.proof-reason .error-note { margin-top: 8px; color: var(--red); font-weight: bold; }
.evidence-chain {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: bold;
background: #050a10;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #1e3a54;
}
.evidence-chain span { color: #475569; }
.evidence-chain .arrow { color: #334155; }
.evidence-chain .done-step { color: var(--green); }
.evidence-chain .active-step { color: var(--cyan); text-shadow: 0 0 8px rgba(56, 189, 248, 0.5); }
/* Narration */
.guided-narration {
padding: 16px;
flex-shrink: 0;
}
.guided-narration .explanation {
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
}
.narration-details {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
background: #0d1b2a;
padding: 12px;
border-radius: 10px;
border: 1px solid #1a2f44;
}
.narration-details strong { color: var(--cyan); }
.presenter-phrase {
margin: 0;
padding: 14px;
background: rgba(56, 189, 248, 0.1);
border-left: 4px solid var(--cyan);
border-radius: 0 10px 10px 0;
font-size: 15px;
font-weight: 600;
font-style: italic;
line-height: 1.5;
}
.fault-confirm-btn {
margin-top: 12px;
width: 100%;
padding: 12px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid var(--orange);
color: var(--orange);
}
.fault-confirm-btn.pending {
background: var(--orange);
color: #000;
}
/* Criteria Snapshot */
.guided-criteria {
padding: 16px;
flex-shrink: 0;
}
.criteria-list-mini {
display: flex;
flex-direction: column;
gap: 10px;
}
.criterion-mini {
background: #0d1b2a;
border: 1px solid #1a2f44;
padding: 12px;
border-radius: 10px;
}
.criterion-mini-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.criterion-mini-header strong { font-size: 13px; }
.criterion-mini p { font-size: 12px; color: var(--muted); margin: 0; line-height: 1.4; }