design: redesign product demo UI
This commit is contained in:
112
README.md
112
README.md
@@ -1,115 +1,47 @@
|
||||
# OZON Tech Sorter Simulation
|
||||
|
||||
Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров».
|
||||
Интеллектуальная роботизированная система сортировки товаров (Frontend MVP).
|
||||
|
||||
Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики, event log и режим защиты для жюри.
|
||||
Проект показывает полный цикл работы конвейера в формате **Product Demo**:
|
||||
- распознавание товара (Mock CV);
|
||||
- классификация по правилам (габариты, сечение);
|
||||
- маршрутизация в зоны B/C/D;
|
||||
- отработка аварийных ситуаций (Jam, Emergency Stop).
|
||||
|
||||
UI переработан под **Product Demo Landing Page**, который:
|
||||
- работает без горизонтального скролла на любых экранах;
|
||||
- адаптирован для Mobile, Tablet и Desktop;
|
||||
- включает Storyline (Detection → Classification → Command → Routing);
|
||||
- предлагает список Jury Scenarios и OZON Criteria;
|
||||
- прячет сложный Engineering Dashboard в сворачиваемый аккордеон.
|
||||
|
||||
## Стек
|
||||
|
||||
- Vite
|
||||
- React
|
||||
- TypeScript
|
||||
- SVG/CSS для инженерной 2D-сцены
|
||||
- Vite, React, TypeScript
|
||||
- CSS/SVG (адаптивная сцена)
|
||||
- Vitest для доменных тестов
|
||||
- Docker + nginx для production static hosting
|
||||
|
||||
Нет Three.js, backend, реального ML и тяжелых UI-библиотек.
|
||||
- Docker + nginx (production static hosting)
|
||||
|
||||
## Как открыть демо
|
||||
|
||||
Публично:
|
||||
|
||||
```text
|
||||
https://arhipovdan.ru/
|
||||
https://www.arhipovdan.ru/
|
||||
```
|
||||
|
||||
Локально на сервере:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3100/
|
||||
```
|
||||
|
||||
### Guided Demo
|
||||
## Структура Demo-страницы
|
||||
|
||||
Рекомендуемый путь для жюри — использовать **Guided Demo** view.
|
||||
В Header переключите режим на `Guided Demo`.
|
||||
Это чистое демо-представление для защиты (1 экран без скролла).
|
||||
|
||||
Отличия Guided Demo от Engineering Dashboard:
|
||||
- Guided Demo показывает только ключевую информацию для защиты (Proof Card, Narration, Snapshot), скрывая длинные логи и графики.
|
||||
- Engineering Mode дает полный доступ ко всем метрикам, логам, PID-графику и дереву классификации.
|
||||
|
||||
Рекомендуемый путь:
|
||||
- Start Guided Demo
|
||||
- Next
|
||||
- Oversized
|
||||
- Round
|
||||
- Low confidence
|
||||
- Jam
|
||||
- Emergency stop
|
||||
|
||||
В Guided Demo закрываются критерии: classification correctness, routing, timing, fault handling.
|
||||
|
||||
## Presentation Mode
|
||||
|
||||
В Header есть переключатель:
|
||||
|
||||
- `Engineering Mode` — полный инженерный dashboard.
|
||||
- `Presentation Mode` — режим защиты с demo narrative, подсказками докладчика, фокусом на текущую область и criteria checklist.
|
||||
|
||||
В Presentation Mode панель demo steps показывает:
|
||||
|
||||
- номер текущего шага;
|
||||
- что сейчас происходит;
|
||||
- что смотреть на экране;
|
||||
- что это доказывает для жюри;
|
||||
- связанные OZON criteria;
|
||||
- готовую presenter phrase.
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `Previous step` / `Next step`;
|
||||
- `Restart demo`;
|
||||
- `Apply scenario`;
|
||||
- `Run suggested action`.
|
||||
|
||||
Для `jam` и `emergency_stop` при `Safe Demo: ON` требуется явное повторное подтверждение fault-сценария.
|
||||
|
||||
## Demo Steps
|
||||
|
||||
1. System overview.
|
||||
2. Normal item to B.
|
||||
3. Oversized item to C.
|
||||
4. Round object to D.
|
||||
5. Boundary dimensions.
|
||||
6. Low confidence fallback.
|
||||
7. Close items queue.
|
||||
8. Jam / fault handling.
|
||||
9. Emergency stop.
|
||||
10. Performance and synchronization.
|
||||
|
||||
## OZON Criteria Coverage Panel
|
||||
|
||||
Панель `OZON criteria coverage` показывает, какие критерии закрыты и где это доказано:
|
||||
|
||||
- category correctness;
|
||||
- classification rules;
|
||||
- boundary cases;
|
||||
- physical routing;
|
||||
- manipulation logic;
|
||||
- geometry variety;
|
||||
- safety;
|
||||
- timing and throughput;
|
||||
- CV-to-actuator integration;
|
||||
- engineering realism;
|
||||
- reproducibility/docs.
|
||||
|
||||
Фильтры: `all`, `covered`, `partially covered`, `demo step available`.
|
||||
|
||||
## Event Log JSON
|
||||
|
||||
`Copy JSON` в Event Log использует browser clipboard API. Если clipboard недоступен в браузере, экспорт не критичен для защиты: используйте визуальный Event Log с фильтрами, timestamp, state, command/category и severity. DevTools открывать не требуется.
|
||||
1. **Hero Section:** краткая суть проекта и цепочки действий.
|
||||
2. **Product Demo Section:** живая сцена (`SorterScene`) и карточка текущего результата.
|
||||
3. **Scenarios Cards:** карточки для проверки нестандартных товаров (негабарит, шар, затор).
|
||||
4. **Criteria Cards:** чек-лист покрытия требований OZON.
|
||||
5. **Engineering Details:** подробные отладочные панели (Event Log, PID, метрики).
|
||||
|
||||
## Запуск локально
|
||||
|
||||
|
||||
@@ -60,18 +60,14 @@ The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pu
|
||||
|
||||
The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
|
||||
|
||||
## 16. Why is the interface divided into Guided and Engineering?
|
||||
## 16. Why is the interface a Product Demo Page now?
|
||||
|
||||
Guided Demo is focused solely on the defense presentation, showing only the Proof Card, Scene, and Narrative, ensuring clarity in a 3-minute pitch. Engineering Mode provides full access to logs, PID, and internal state.
|
||||
To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw JSON logs or PID graphs, especially on laptop projectors or mobile devices.
|
||||
|
||||
## 17. How does Guided Demo prove engineering realism?
|
||||
## 17. How does the Demo prove engineering realism?
|
||||
|
||||
Even in Guided Demo, the scene and proof card are driven by the real underlying state machine, classifier, and metric engine—not a pre-rendered video.
|
||||
Even in the Product Demo Section, the scene and proof card are driven by the real underlying state machine, classifier, and metric engine—not a pre-rendered video.
|
||||
|
||||
## 18. Where to see the full event log and PID?
|
||||
|
||||
In the Engineering Dashboard.
|
||||
|
||||
## 19. Why aren't all panels shown in Guided Demo?
|
||||
|
||||
To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw JSON logs or PID graphs.
|
||||
Scroll down and open the "Engineering Details (Engineering Mode)" accordion.
|
||||
|
||||
@@ -11,14 +11,11 @@ Before final submission:
|
||||
- [ ] Presentation Mode opens.
|
||||
- [ ] Demo steps checked.
|
||||
- [ ] Safe Demo confirmation checked for jam/emergency.
|
||||
- [ ] Guided Demo verified on 1920x1080 (no scroll).
|
||||
- [ ] Guided Demo verified on 1440x900.
|
||||
- [ ] Start Guided Demo button works.
|
||||
- [ ] Next/Previous buttons work.
|
||||
- [ ] Reset Demo button works.
|
||||
- [ ] Fault confirmation works in Guided Demo.
|
||||
- [ ] Engineering Mode is not broken.
|
||||
- [ ] OZON Criteria Coverage Panel checked.
|
||||
- [ ] App is responsive on Desktop (1920x1080), Laptop (1440x900) and Mobile (390x844).
|
||||
- [ ] NO horizontal scroll on body/app container.
|
||||
- [ ] Product Demo layout shows step, scene, and result.
|
||||
- [ ] Scenarios are displayed as cards.
|
||||
- [ ] Engineering Details accordion works and shows all technical panels.
|
||||
- [ ] Engineering Mode still works.
|
||||
- [ ] Scenarios checked: normal_flow, oversized_item, round_object, boundary_dimensions, close_items, low_confidence, jam, emergency_stop.
|
||||
- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, SUBMISSION_CHECKLIST.
|
||||
|
||||
178
src/App.tsx
178
src/App.tsx
@@ -1,28 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Header from './components/Header';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import ProductDemoSection from './components/ProductDemoSection';
|
||||
import ScenarioCards from './components/ScenarioCards';
|
||||
import CriteriaCards from './components/CriteriaCards';
|
||||
import EngineeringDetails from './components/EngineeringDetails';
|
||||
import { DEMO_STEPS } from './data/demoSteps';
|
||||
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 {
|
||||
return step.scenarioId === 'jam' || step.scenarioId === 'emergency_stop';
|
||||
}
|
||||
import { createSimulation, setRunning, stepSimulationToNextState } from './domain/simulation';
|
||||
import type { ScenarioId, SimulationState } from './domain/types';
|
||||
|
||||
export default function App() {
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
|
||||
const [presentationMode, setPresentationMode] = useState<PresentationMode>('guided');
|
||||
const [safeDemoEnabled, setSafeDemoEnabled] = useState(true);
|
||||
const [demoStepIndex, setDemoStepIndex] = useState(0);
|
||||
const [pendingFaultStepId, setPendingFaultStepId] = useState<string | undefined>();
|
||||
|
||||
const activeScenario = useMemo(
|
||||
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
|
||||
() => SCENARIOS.find((s) => s.id === activeScenarioId) ?? SCENARIOS[0],
|
||||
[activeScenarioId],
|
||||
);
|
||||
|
||||
const currentDemoStep = DEMO_STEPS[demoStepIndex];
|
||||
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
|
||||
|
||||
@@ -30,110 +26,70 @@ export default function App() {
|
||||
setSimulation(createSimulation(activeScenario));
|
||||
}, [activeScenario]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!simulation.running) {
|
||||
return undefined;
|
||||
// We are not using automatic interval in the new product demo by default to keep it step-by-step
|
||||
// but if the user wants it to run automatically, we can preserve it if needed.
|
||||
// Let's use handleNext to just step through.
|
||||
|
||||
const handleStartDemo = () => {
|
||||
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (!simulation.currentItem && demoStepIndex === 0) {
|
||||
setSimulation(stepSimulationToNextState(simulation));
|
||||
}
|
||||
|
||||
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) => stepSimulationToNextState(current));
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setActiveScenarioId(scenarioId);
|
||||
};
|
||||
|
||||
const handleDemoStepChange = (nextIndex: number) => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setDemoStepIndex(Math.max(0, Math.min(nextIndex, DEMO_STEPS.length - 1)));
|
||||
};
|
||||
|
||||
const handleApplyDemoStepScenario = (step: DemoStep) => {
|
||||
if (presentationMode === 'presentation' && safeDemoEnabled && isFaultDemoStep(step) && pendingFaultStepId !== step.id) {
|
||||
setPendingFaultStepId(step.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingFaultStepId(undefined);
|
||||
setActiveScenarioId(step.scenarioId);
|
||||
};
|
||||
|
||||
const handleRunPreferredAction = (step: DemoStep) => {
|
||||
if (activeScenarioId !== step.scenarioId) {
|
||||
handleApplyDemoStepScenario(step);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.preferredAction === 'start') {
|
||||
handleStart();
|
||||
} else if (step.preferredAction === 'pause') {
|
||||
handlePause();
|
||||
} else if (step.preferredAction === 'reset') {
|
||||
handleReset();
|
||||
const handleNext = () => {
|
||||
// If simulation is finished or errored, we might need to reset or move to next scenario
|
||||
const isFinished = simulation.machineState === 'RETURN_HOME' || simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
if (isFinished) {
|
||||
const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length;
|
||||
setDemoStepIndex(nextIndex);
|
||||
setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId);
|
||||
} else {
|
||||
handleStep();
|
||||
setSimulation((current) => stepSimulationToNextState(current));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartDemo = () => {
|
||||
setPendingFaultStepId(undefined);
|
||||
setDemoStepIndex(0);
|
||||
setActiveScenarioId(DEMO_STEPS[0].scenarioId);
|
||||
const handleReset = () => {
|
||||
setSimulation(createSimulation(activeScenario));
|
||||
};
|
||||
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => {
|
||||
setActiveScenarioId(scenarioId);
|
||||
// Find first step that matches this scenario
|
||||
const stepIdx = DEMO_STEPS.findIndex(s => s.scenarioId === scenarioId);
|
||||
if (stepIdx >= 0) setDemoStepIndex(stepIdx);
|
||||
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
<Header simulation={simulation} />
|
||||
|
||||
<main className="product-main">
|
||||
<HeroSection onStartDemo={handleStartDemo} />
|
||||
|
||||
<ProductDemoSection
|
||||
simulation={simulation}
|
||||
onNext={handleNext}
|
||||
onReset={handleReset}
|
||||
demoStepTitle={currentDemoStep.title}
|
||||
/>
|
||||
|
||||
<ScenarioCards
|
||||
scenarios={SCENARIOS}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
/>
|
||||
|
||||
<CriteriaCards />
|
||||
|
||||
<EngineeringDetails simulation={simulation} />
|
||||
</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>OZON Tech Hackathon 2026. Sorter Simulation.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/CriteriaCards.tsx
Normal file
29
src/components/CriteriaCards.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { OZON_CRITERIA } from '../data/criteria';
|
||||
|
||||
export default function CriteriaCards() {
|
||||
return (
|
||||
<section className="criteria-cards-section" id="criteria">
|
||||
<div className="section-header">
|
||||
<h2>Покрытие критериев OZON</h2>
|
||||
<p>Каждый инженерный критерий хакатона покрывается симуляцией и тестами.</p>
|
||||
</div>
|
||||
|
||||
<div className="criteria-grid">
|
||||
{OZON_CRITERIA.map((criterion) => (
|
||||
<div key={criterion.id} className="criteria-card">
|
||||
<div className="criteria-card-header">
|
||||
<h3>{criterion.title}</h3>
|
||||
<span className={`status-badge ${criterion.status.replace(/ /g, '-')}`}>
|
||||
{criterion.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="criteria-evidence">{criterion.evidence}</p>
|
||||
<div className="criteria-footer">
|
||||
<span className="related-docs">Docs: {criterion.relatedDoc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,88 +11,60 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
|
||||
|
||||
if (!current) {
|
||||
return (
|
||||
<section className="proof-card proof-card-empty">
|
||||
<div 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>
|
||||
<h3>Ready to demonstrate sorting cycle</h3>
|
||||
<p>Нажмите «Запустить демо» для начала или переключите сценарий ниже.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
|
||||
<div className="proof-card-header">
|
||||
<p className="eyebrow">Результат классификации</p>
|
||||
<h3>{item?.name} (ID: {item?.id})</h3>
|
||||
</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>
|
||||
<span className="proof-label">Категория</span>
|
||||
<strong className={`category-${result?.category || 'default'}`}>{result?.category || '-'}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Roundness</span>
|
||||
<span className="proof-label">Маршрут</span>
|
||||
<strong>{result?.category ? `Зона ${result.category}` : '-'}</strong>
|
||||
</div>
|
||||
<div className="proof-item">
|
||||
<span className="proof-label">Команда</span>
|
||||
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proof-details">
|
||||
<div className="detail-row">
|
||||
<span>Габариты:</span>
|
||||
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
|
||||
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} мм
|
||||
</strong>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span>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>}
|
||||
<strong>Почему такое решение:</strong> {result?.reason}
|
||||
{lowConfidence && <p className="warning-note">Низкая уверенность CV — использован rule-based fallback</p>}
|
||||
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Конвейер остановлен</p>}
|
||||
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: Аварийная остановка</p>}
|
||||
{simulation.metrics.queueLength > 1 && <p className="warning-note">Внимание: очередь товаров</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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 TimelinePanel from './TimelinePanel';
|
||||
import PresentationModePanel from './PresentationModePanel';
|
||||
import OzonCriteriaPanel from './OzonCriteriaPanel';
|
||||
import type { DemoFocusArea, DemoStep, PresentationMode, Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
|
||||
interface DashboardProps {
|
||||
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: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
onDemoStepChange: (index: number) => void;
|
||||
onApplyDemoStepScenario: (step: DemoStep) => void;
|
||||
onRunPreferredAction: (step: DemoStep) => void;
|
||||
onRestartDemo: () => void;
|
||||
}
|
||||
|
||||
function focusClass(mode: PresentationMode, activeFocus: DemoFocusArea, panelFocus: DemoFocusArea | DemoFocusArea[]): string {
|
||||
if (mode !== 'presentation') {
|
||||
return '';
|
||||
}
|
||||
const focusList = Array.isArray(panelFocus) ? panelFocus : [panelFocus];
|
||||
return focusList.includes(activeFocus) || (activeFocus === 'safety' && focusList.includes('scene')) ? 'focus-highlight' : 'presentation-muted';
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
simulation,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
demoSteps,
|
||||
demoStepIndex,
|
||||
currentDemoStep,
|
||||
pendingFaultStepId,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onScenarioChange,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
onDemoStepChange,
|
||||
onApplyDemoStepScenario,
|
||||
onRunPreferredAction,
|
||||
onRestartDemo,
|
||||
}: DashboardProps) {
|
||||
const focusArea = currentDemoStep.focusArea;
|
||||
|
||||
return (
|
||||
<div className={`app-shell mode-${presentationMode}`}>
|
||||
<Header
|
||||
simulation={simulation}
|
||||
presentationMode={presentationMode}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
onStart={onStart}
|
||||
onPause={onPause}
|
||||
onReset={onReset}
|
||||
onStep={onStep}
|
||||
onPresentationModeChange={onPresentationModeChange}
|
||||
onSafeDemoChange={onSafeDemoChange}
|
||||
/>
|
||||
{presentationMode === 'presentation' ? (
|
||||
<PresentationModePanel
|
||||
steps={demoSteps}
|
||||
currentStep={currentDemoStep}
|
||||
currentIndex={demoStepIndex}
|
||||
activeScenarioId={activeScenarioId}
|
||||
safeDemoEnabled={safeDemoEnabled}
|
||||
pendingFaultStepId={pendingFaultStepId}
|
||||
onPrevious={() => onDemoStepChange(demoStepIndex - 1)}
|
||||
onNext={() => onDemoStepChange(demoStepIndex + 1)}
|
||||
onRestart={onRestartDemo}
|
||||
onApplyScenario={() => onApplyDemoStepScenario(currentDemoStep)}
|
||||
onRunPreferredAction={() => onRunPreferredAction(currentDemoStep)}
|
||||
/>
|
||||
) : null}
|
||||
<main className="dashboard-grid">
|
||||
<section className={`scene-column panel panel-scene ${focusClass(presentationMode, focusArea, ['scene', 'safety'])}`}>
|
||||
<SorterScene simulation={simulation} />
|
||||
</section>
|
||||
<aside className="right-rail">
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><CurrentItemPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'classification')}><ClassificationPanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><StateMachinePanel currentState={simulation.machineState} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'scene')}><SensorPanel simulation={simulation} /></div>
|
||||
</aside>
|
||||
<section className="bottom-row">
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><MetricsPanel metrics={simulation.metrics} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'timeline')}><TimelinePanel simulation={simulation} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, ['pid', 'safety'])}><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'eventLog')}><EventLog events={simulation.events} /></div>
|
||||
<div className={focusClass(presentationMode, focusArea, 'criteria')}>
|
||||
<OzonCriteriaPanel currentStep={currentDemoStep} />
|
||||
</div>
|
||||
<div className="scenario-panel-wrap">
|
||||
<ScenarioPanel
|
||||
scenarios={scenarios}
|
||||
activeScenarioId={activeScenarioId}
|
||||
onScenarioChange={onScenarioChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/EngineeringDetails.tsx
Normal file
36
src/components/EngineeringDetails.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import StateMachinePanel from './StateMachinePanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import EventLog from './EventLog';
|
||||
import MetricsPanel from './MetricsPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function EngineeringDetails({ simulation }: { simulation: SimulationState }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="engineering-details-section" id="engineering">
|
||||
<div className="engineering-toggle" onClick={() => setIsOpen(!isOpen)}>
|
||||
<h2>Инженерные детали (Engineering Mode)</h2>
|
||||
<button className="btn-secondary">
|
||||
{isOpen ? 'Свернуть' : 'Развернуть'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="engineering-content">
|
||||
<div className="engineering-grid">
|
||||
<div className="eng-panel"><StateMachinePanel currentState={simulation.machineState} /></div>
|
||||
<div className="eng-panel"><MetricsPanel metrics={simulation.metrics} /></div>
|
||||
<div className="eng-panel"><SensorPanel simulation={simulation} /></div>
|
||||
<div className="eng-panel"><TimelinePanel simulation={simulation} /></div>
|
||||
<div className="eng-panel"><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
|
||||
<div className="eng-panel"><EventLog events={simulation.events} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +1,31 @@
|
||||
import type { PresentationMode, SimulationState } from '../domain/types';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
interface HeaderProps {
|
||||
simulation: SimulationState;
|
||||
presentationMode: PresentationMode;
|
||||
safeDemoEnabled: boolean;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onStep: () => void;
|
||||
onPresentationModeChange: (mode: PresentationMode) => void;
|
||||
onSafeDemoChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
simulation,
|
||||
presentationMode,
|
||||
safeDemoEnabled,
|
||||
onStart,
|
||||
onPause,
|
||||
onReset,
|
||||
onStep,
|
||||
onPresentationModeChange,
|
||||
onSafeDemoChange,
|
||||
}: HeaderProps) {
|
||||
const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
export default function Header({ simulation }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div>
|
||||
<p className="eyebrow">Intelligent robotic sorting line</p>
|
||||
<h1>OZON Tech Sorter Simulation</h1>
|
||||
<header className="site-header">
|
||||
<div className="header-logo">
|
||||
<span className="logo-icon">O</span>
|
||||
<div className="logo-text">
|
||||
<span className="logo-title">OZON Tech</span>
|
||||
<span className="logo-subtitle">Sorter Simulation</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 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)}>
|
||||
Safe Demo: {safeDemoEnabled ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
) : null}
|
||||
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
|
||||
<span />
|
||||
{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} disabled={locked}>Start</button>
|
||||
<button onClick={onPause}>Pause</button>
|
||||
<button onClick={onReset}>Reset</button>
|
||||
<button onClick={onStep} disabled={locked}>Step state</button>
|
||||
</div>
|
||||
|
||||
<div className="header-nav">
|
||||
<a href="#demo" className="nav-link">Демо</a>
|
||||
<a href="#scenarios" className="nav-link">Сценарии</a>
|
||||
<a href="#criteria" className="nav-link">Критерии</a>
|
||||
<a href="#engineering" className="nav-link">Инженерный режим</a>
|
||||
</div>
|
||||
|
||||
<div className="header-status-compact">
|
||||
<div className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></div>
|
||||
<span>{simulation.systemStatus}</span>
|
||||
<span className="sim-time">t+{(simulation.simTimeMs / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
51
src/components/HeroSection.tsx
Normal file
51
src/components/HeroSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }) {
|
||||
return (
|
||||
<section className="hero-section">
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-title">Интеллектуальная система сортировки товаров</h1>
|
||||
<p className="hero-subtitle">
|
||||
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
|
||||
управляющая команда и маршрутизация в зоны B, C, D.
|
||||
</p>
|
||||
|
||||
<div className="hero-badges">
|
||||
<span className="hero-badge">Computer Vision</span>
|
||||
<span className="hero-badge">Rule-based classification</span>
|
||||
<span className="hero-badge">Actuator routing</span>
|
||||
<span className="hero-badge">Cycle time</span>
|
||||
<span className="hero-badge">Fault handling</span>
|
||||
</div>
|
||||
|
||||
<div className="hero-actions">
|
||||
<button className="btn-primary" onClick={onStartDemo}>Запустить демо</button>
|
||||
<a href="#scenarios" className="btn-secondary">Показать сценарии</a>
|
||||
<a href="#engineering" className="btn-secondary">Инженерный режим</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-visual">
|
||||
<div className="hero-chain">
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">📷</div>
|
||||
<span>Detection</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">🧠</div>
|
||||
<span>Classification</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">⚡</div>
|
||||
<span>Command</span>
|
||||
</div>
|
||||
<div className="chain-arrow">→</div>
|
||||
<div className="chain-step">
|
||||
<div className="chain-icon">📦</div>
|
||||
<span>Routing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { DemoStep, ScenarioId } from '../domain/types';
|
||||
|
||||
interface PresentationModePanelProps {
|
||||
steps: DemoStep[];
|
||||
currentStep: DemoStep;
|
||||
currentIndex: number;
|
||||
activeScenarioId: ScenarioId;
|
||||
safeDemoEnabled: boolean;
|
||||
pendingFaultStepId?: string;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onRestart: () => void;
|
||||
onApplyScenario: () => void;
|
||||
onRunPreferredAction: () => void;
|
||||
}
|
||||
|
||||
export default function PresentationModePanel({
|
||||
steps,
|
||||
currentStep,
|
||||
currentIndex,
|
||||
activeScenarioId,
|
||||
safeDemoEnabled,
|
||||
pendingFaultStepId,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onRestart,
|
||||
onApplyScenario,
|
||||
onRunPreferredAction,
|
||||
}: PresentationModePanelProps) {
|
||||
const scenarioApplied = activeScenarioId === currentStep.scenarioId;
|
||||
const pendingFaultConfirmation = pendingFaultStepId === currentStep.id;
|
||||
const isFaultStep = currentStep.scenarioId === 'jam' || currentStep.scenarioId === 'emergency_stop';
|
||||
|
||||
return (
|
||||
<section className="presentation-panel panel">
|
||||
<div className="presentation-step-index">Step {currentIndex + 1} / {steps.length}</div>
|
||||
<div className="presentation-copy">
|
||||
<p className="eyebrow">Presentation Mode</p>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p>{currentStep.explanation}</p>
|
||||
</div>
|
||||
<div className="presentation-proof">
|
||||
<div>
|
||||
<strong>What to watch</strong>
|
||||
<span>{currentStep.whatToWatch}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>What it proves for jury</strong>
|
||||
<span>{currentStep.juryValue}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Current proof</strong>
|
||||
<span>classification decision {'->'} control command {'->'} physical route {'->'} logged result</span>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{currentStep.presenterPhrase}</blockquote>
|
||||
<div className="presentation-meta">
|
||||
<span>Recommended scenario: <strong>{currentStep.scenarioId}</strong></span>
|
||||
<span>Action: <strong>{currentStep.preferredAction}</strong></span>
|
||||
<span>Focus: <strong>{currentStep.focusArea}</strong></span>
|
||||
<span>Safe Demo: <strong>{safeDemoEnabled ? 'ON' : 'OFF'}</strong></span>
|
||||
</div>
|
||||
<div className="criteria-tags">
|
||||
{currentStep.relatedCriteria.map((criterion) => <span key={criterion}>{criterion}</span>)}
|
||||
</div>
|
||||
{safeDemoEnabled && isFaultStep && pendingFaultConfirmation ? (
|
||||
<p className="confirm-note">Click “Confirm fault scenario” again to intentionally switch to {currentStep.scenarioId}.</p>
|
||||
) : null}
|
||||
<div className="presentation-actions">
|
||||
<button onClick={onPrevious} disabled={currentIndex === 0}>Previous step</button>
|
||||
<button onClick={onNext} disabled={currentIndex === steps.length - 1}>Next step</button>
|
||||
<button onClick={onRestart}>Restart demo</button>
|
||||
<button className={scenarioApplied ? 'active' : ''} onClick={onApplyScenario}>
|
||||
{pendingFaultConfirmation ? 'Confirm fault scenario' : scenarioApplied ? 'Scenario applied' : 'Apply scenario'}
|
||||
</button>
|
||||
<button onClick={onRunPreferredAction}>{scenarioApplied ? 'Run suggested action' : 'Apply then run'}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
src/components/ProductDemoSection.tsx
Normal file
41
src/components/ProductDemoSection.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import SorterScene from './SorterScene';
|
||||
import CurrentProofCard from './CurrentProofCard';
|
||||
import StorylineStepper from './StorylineStepper';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
interface Props {
|
||||
simulation: SimulationState;
|
||||
onNext: () => void;
|
||||
onReset: () => void;
|
||||
demoStepTitle: string;
|
||||
}
|
||||
|
||||
export default function ProductDemoSection({ simulation, onNext, onReset, demoStepTitle }: Props) {
|
||||
return (
|
||||
<section className="product-demo-section" id="demo">
|
||||
<div className="demo-header">
|
||||
<h2>Live Demo</h2>
|
||||
<div className="demo-status">
|
||||
Текущий шаг: <strong>{demoStepTitle}</strong>
|
||||
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorylineStepper simulation={simulation} />
|
||||
|
||||
<div className="demo-layout">
|
||||
<div className="demo-scene-container">
|
||||
<SorterScene simulation={simulation} />
|
||||
</div>
|
||||
|
||||
<div className="demo-proof-container">
|
||||
<CurrentProofCard simulation={simulation} />
|
||||
<div className="demo-controls">
|
||||
<button className="btn-primary" onClick={onNext}>Следующий шаг (Next)</button>
|
||||
<button className="btn-secondary" onClick={onReset}>Сброс (Reset)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
45
src/components/ScenarioCards.tsx
Normal file
45
src/components/ScenarioCards.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Scenario, ScenarioId } from '../domain/types';
|
||||
|
||||
interface Props {
|
||||
scenarios: Scenario[];
|
||||
activeScenarioId: ScenarioId;
|
||||
onScenarioChange: (id: ScenarioId) => void;
|
||||
}
|
||||
|
||||
export default function ScenarioCards({ scenarios, activeScenarioId, onScenarioChange }: Props) {
|
||||
return (
|
||||
<section className="scenario-cards-section" id="scenarios">
|
||||
<div className="section-header">
|
||||
<h2>Сценарии тестирования (Jury Test Cases)</h2>
|
||||
<p>Выберите сценарий для проверки поведения системы в разных условиях.</p>
|
||||
</div>
|
||||
|
||||
<div className="scenario-grid">
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={`scenario-card ${scenario.id === activeScenarioId ? 'active' : ''}`}
|
||||
onClick={() => onScenarioChange(scenario.id)}
|
||||
>
|
||||
<div className="scenario-card-header">
|
||||
<h3>{scenario.name}</h3>
|
||||
{scenario.id === activeScenarioId && <span className="active-badge">Выбран</span>}
|
||||
</div>
|
||||
<p className="scenario-desc">{scenario.description}</p>
|
||||
<div className="scenario-meta">
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Цель:</span>
|
||||
<span className="meta-value">{scenario.goal}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Результат:</span>
|
||||
<span className="meta-value">{scenario.expectedCategorySummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-secondary btn-small mt-auto">Применить</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
65
src/components/StorylineStepper.tsx
Normal file
65
src/components/StorylineStepper.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function StorylineStepper({ simulation }: { simulation: SimulationState }) {
|
||||
const state = simulation.machineState;
|
||||
|
||||
const isDetection = state === 'MOVING_TO_CAMERA' || state === 'DETECTING';
|
||||
const isClassification = state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE' || state === 'CLASSIFYING';
|
||||
const isCommand = state.startsWith('ROUTE_TO_');
|
||||
const isRouting = state === 'RETURN_HOME';
|
||||
|
||||
const item = simulation.currentItem;
|
||||
|
||||
return (
|
||||
<div className="storyline-stepper">
|
||||
<Step
|
||||
label="1. Detection"
|
||||
desc="Распознавание габаритов"
|
||||
active={isDetection}
|
||||
done={Boolean(item) && !isDetection}
|
||||
/>
|
||||
<Step
|
||||
label="2. Classification"
|
||||
desc="Дерево решений"
|
||||
active={isClassification}
|
||||
done={Boolean(item?.classification) && !isClassification && !isDetection}
|
||||
/>
|
||||
<Step
|
||||
label="3. Decision"
|
||||
desc="Выбор категории"
|
||||
active={isClassification && state === 'CLASSIFYING'}
|
||||
done={Boolean(item?.classification)}
|
||||
/>
|
||||
<Step
|
||||
label="4. Command"
|
||||
desc="Сигнал механизмам"
|
||||
active={isCommand}
|
||||
done={isRouting}
|
||||
/>
|
||||
<Step
|
||||
label="5. Routing"
|
||||
desc="Движение в зону"
|
||||
active={isCommand || isRouting}
|
||||
done={isRouting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ label, desc, active, done }: { label: string, desc: string, active: boolean, done: boolean }) {
|
||||
let statusClass = 'step-pending';
|
||||
if (active) statusClass = 'step-active';
|
||||
else if (done) statusClass = 'step-done';
|
||||
|
||||
return (
|
||||
<div className={`story-step ${statusClass}`}>
|
||||
<div className="step-indicator">
|
||||
<div className="step-dot"></div>
|
||||
</div>
|
||||
<div className="step-content">
|
||||
<strong>{label}</strong>
|
||||
<span>{desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
739
src/styles.css
739
src/styles.css
@@ -1423,3 +1423,742 @@ button:disabled {
|
||||
.criterion-mini-header strong { font-size: 13px; }
|
||||
.criterion-mini p { font-size: 12px; color: var(--muted); margin: 0; line-height: 1.4; }
|
||||
|
||||
|
||||
/* ====== NEW PRODUCT DEMO STYLES ====== */
|
||||
|
||||
.product-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: rgba(7, 14, 23, 0.9);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
font-weight: 900;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.header-status-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
}
|
||||
.status-dot.paused { background: var(--orange); box-shadow: 0 0 10px var(--orange); }
|
||||
.status-dot.fault, .status-dot.emergency_stop { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||
|
||||
/* Hero */
|
||||
.hero-section {
|
||||
padding: 60px 0 80px;
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(36px, 4vw, 56px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
padding: 6px 12px;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
color: var(--cyan);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 14px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--cyan-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #0e1b2b;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a4a66;
|
||||
padding: 14px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--cyan);
|
||||
background: #11273b;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--panel);
|
||||
padding: 32px 40px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.chain-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chain-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.chain-step span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chain-arrow {
|
||||
color: #334155;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Demo Section */
|
||||
.product-demo-section {
|
||||
padding: 40px 0 80px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.demo-header h2 {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.demo-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
background: var(--panel);
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.demo-status strong {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.storyline-stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
background: var(--panel);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.story-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #2a4a66;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #050910;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.step-active .step-indicator { border-color: var(--cyan); }
|
||||
.step-active .step-dot { background: var(--cyan); box-shadow: 0 0 8px var(--cyan); }
|
||||
.step-done .step-indicator { border-color: var(--green); background: var(--green); }
|
||||
.step-done .step-dot { background: transparent; }
|
||||
.step-pending { opacity: 0.5; }
|
||||
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-content strong { font-size: 14px; margin-bottom: 4px; }
|
||||
.step-content span { font-size: 11px; color: var(--muted); }
|
||||
|
||||
.demo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.demo-scene-container {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Sorter scene sizing fix */
|
||||
.demo-scene-container .scene-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
.demo-scene-container .sorter-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
.demo-scene-container .scene-title-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.demo-proof-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Redesigned Proof Card */
|
||||
.proof-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.proof-card-stopped {
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 0 20px rgba(251, 61, 78, 0.1);
|
||||
}
|
||||
|
||||
.proof-card-empty {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.proof-card-header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.proof-card-header h3 { margin: 4px 0 0; font-size: 20px; }
|
||||
|
||||
.proof-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.proof-item {
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.proof-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.proof-item strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.proof-details {
|
||||
background: #0d1b2a;
|
||||
border: 1px solid #1a2f44;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.detail-row span { color: var(--muted); }
|
||||
|
||||
.proof-reason {
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
border-left: 4px solid var(--cyan);
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Scenarios */
|
||||
.scenario-cards-section {
|
||||
padding: 60px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-card:hover {
|
||||
border-color: var(--cyan);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.scenario-card.active {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
box-shadow: 0 0 0 1px var(--cyan);
|
||||
}
|
||||
|
||||
.scenario-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scenario-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
background: var(--cyan);
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.scenario-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
background: #0d1b2a;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-label { color: #64748b; }
|
||||
.meta-value { font-weight: 600; color: #cbd5e1; }
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
.mt-auto { margin-top: auto; }
|
||||
|
||||
/* Criteria */
|
||||
.criteria-cards-section {
|
||||
padding: 60px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.criteria-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.criteria-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.criteria-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.criteria-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.criteria-evidence {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.criteria-footer {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Engineering Details */
|
||||
.engineering-details-section {
|
||||
padding: 40px 0 80px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.engineering-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.engineering-toggle h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.engineering-content {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.engineering-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.eng-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eng-panel .panel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.eng-panel .event-table {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Responsive Overrides */
|
||||
body {
|
||||
min-width: 0 !important; /* Remove old 1180px limit */
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.hero-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.hero-subtitle {
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
.hero-badges {
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-chain {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.demo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.story-step {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.step-content {
|
||||
align-items: center;
|
||||
}
|
||||
.engineering-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
.demo-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.storyline-stepper {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
.hero-chain {
|
||||
flex-wrap: wrap;
|
||||
padding: 20px;
|
||||
}
|
||||
.chain-arrow {
|
||||
display: none;
|
||||
}
|
||||
.proof-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.demo-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.engineering-toggle h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.site-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user