diff --git a/README.md b/README.md index 114d495..59ad282 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,29 @@ -# DriveIndUI – Drive Industrial UI +# DriveIndUI — Drive Industrial UI -DriveIndUI (Drive Industrial UI) – интерактивный прототип цифрового двойника автомобильного производства для Qostanai Industry Hackathon. +Интерактивный прототип цифрового двойника автомобильного производства для кейса Allur на Qostanai Industry Hackathon. Помогает контролировать выполнение плана, находить узкие места и оценивать решения до изменения работы линии. -**Производственный план управляет потоком:** 2 500 Onix + 1 800 Cobalt + 500 J7 = 4 800 машин при минимуме 5 500. Разрыв 700 показан явно. Календарь (по умолчанию 22 рабочих дня — допущение), две смены по 8 часов и целочисленные квоты задают заказ на выбранную смену. Каждая машина имеет модель; годный выпуск и НЗП резервируют её заказ. Для неназначенных 700 машин заказы не создаются. Можно распределить разрыв и сравнить сценарии. +**[Открыть демо](https://zardol.github.io/DriveIndUI/)** -**Публичная демонстрация:** https://zardol.github.io/DriveIndUI/ +## Возможности по ТЗ -Публичная версия запускает тот же симулятор непосредственно в браузере. Сервер и вход в аккаунт не нужны. У каждого посетителя своя смена; обновление страницы начинает новую. Это самостоятельная демонстрация, а не подключение к заводскому оборудованию. +- **Визуализация производства:** 3D-цех и 2D-схема, оборудование, движение каждого автомобиля и очереди на конвейере. +- **Контроль показателей:** выпуск, загрузка, простои, качество и отклонения от плана. +- **Производственный план:** месячные заказы Onix, Cobalt и J7, сменные квоты и выпуск по моделям. +- **Инциденты:** журнал остановок и отображение их влияния на производственный поток. +- **Управленческие решения:** сравнение продолжения работы, обслуживания окраски и увеличения мощности сборки по выпуску и простоям. +- **ИИ-анализ:** оценка рисков простоев и узких мест через OpenAI с объяснением причин и рекомендациями. -## Публичная и офлайн-версия +## Данные и границы прототипа -Интерфейс разделён по рабочим задачам: +Использованы тестовые данные организатора: работа линий и качество за 1–2 октября, четыре остановки оборудования и месячный производственный план. В заказах распределено **4 800 из 5 500 автомобилей**; оставшиеся **700** показаны отдельно и не создаются в симуляции без назначения модели. -| Страница | Что делать | -| --- | --- | -| **Обзор** | Следить за выпуском, отклонениями, участками и инцидентами; переходить к проблемному участку | -| **Цех** | Осматривать 3D/2D, выбирать машины и оборудование, наблюдать очереди, загружать размещение | -| **План** | Распределять месячные заказы, задавать календарь и применять квоты смены | -| **Решения** | Сравнивать три варианта и оценивать результат до конца смены | -| **Данные** | Проверять показатели кейса, импортировать JSON/CSV, изучать риски и методику | -| **ИИ** | Анализировать риски простоев и узких мест через OpenAI, видеть основания и следующие действия | +Исходные показатели отделены от расчётов. Прототип не подключён к оборудованию завода; планировка и часть параметров модели условны. ИИ помогает интерпретировать риски, но не является обученной и проверенной на заводских данных моделью отказов. -Переходы сохраняют текущую смену, черновик плана, схему цеха, результаты сравнения, ИИ-анализ и выбранную запись истории. Адреса `#/overview`, `#/factory`, `#/plan`, `#/decisions`, `#/data`, `#/ai` работают на GitHub Pages и в офлайн-сборке. Перезагрузка страницы по-прежнему начинает новую браузерную сессию. +Потенциальный эффект оценивается через изменение выпуска и простоя в сценариях и возможности снижения брака. Денежный эффект не рассчитывается: необходимых исходных данных нет. -```sh -npm ci -npm run build:demo -npm run preview:demo -``` +## Материалы проекта -Открыть `http://localhost:4173`. После установки зависимостей симуляция работает без интернета. Сборка находится в `dist/demo`; пути к ресурсам относительные, поэтому её можно разместить и в подпапке сайта. Симуляция не обращается к серверу. Вкладка «ИИ» обращается к отдельно настроенному серверу OpenAI только для проверки подключения и ручного анализа. Подключение, защита ключа и ограничения бюджета: [docs/AI.md](docs/AI.md). - -В Windows команда `pwsh -NoProfile -File scripts/package-demo.ps1` упаковывает готовую сборку в `.local/releases/DriveIndUI-offline.zip`. Архив включает статические ресурсы и небольшой сервер без npm-зависимостей. Его нужно полностью распаковать на ПК с Node.js 22+ и запустить `start-demo.cmd`, затем открыть `http://localhost:4175`. Устанавливать зависимости и подключаться к интернету не требуется. - -Краткий сценарий защиты: [docs/DEMO.md](docs/DEMO.md). - -## Запуск - -Нужен Node.js 22 или новее; для команды и CI используется Node.js 24. Команды выполняются из корня репозитория. - -```sh -npm ci -npm run dev -``` - -Открыть `http://localhost:5173`. Интерфейс Vite передаёт `/api` на сервер `http://127.0.0.1:3001`. - -Готовая сборка без сервера разработки: - -```sh -npm run build -npm start -``` - -Открыть `http://localhost:3001`. По умолчанию сервер слушает `0.0.0.0`; переменные `HOST` и `PORT` меняют адрес и порт. После установки зависимостей сборка и симуляция работают без интернета. Внешние шрифты и CDN не используются. - -## Проверки - -```sh -npm run check -``` - -Команда запускает проверку типов, тесты Vitest и сборку. GitHub Actions выполняет те же проверки для Pull Request. - -## Возможности прототипа - -- Четыре производственных участка с входными буферами: сварка, окраска, сборка и контроль качества. Склад изображает источник комплектующих. -- Три сценария кейса: без остановок, замена фильтра Камеры-02 на 40 минут и обрыв цепи Конвейера-03 на 55 минут. Начало на 20-й минуте — допущение, поскольку время события в PDF отсутствует. -- Пауза, сброс, переключение сценария и ускорение времени в 1, 10 или 60 раз. -- Импорт JSON-конфигурации: циклы, вместимость буферов, подача, длительность и план смены, вероятность брака, скорость ленты. Перед применением показываются параметры; новая смена начинается на паузе. -- Отдельный режим «История производства»: проверка CSV, воспроизведение по записям, график и таблица, годный выпуск, брак, НЗП и простой. История остаётся в памяти вкладки и не отправляется серверу. -- Выпуск, доля годных изделий, незавершённое производство, загрузка оборудования, простои и журнал инцидентов. -- Оценка выпуска по текущему среднему темпу. Она не является прогнозом обученной ИИ-модели. -- SVG-иллюстрации оборудования, кузова на постах, реальные очереди и анимация только работающего оборудования. Пауза и режим reduced motion останавливают анимацию. -- «Фокус смены»: активный инцидент и самая большая входная очередь. Подробности участка открываются по нажатию или с клавиатуры. -- 3D-цех на Three.js / React Three Fiber: объёмные роботы, окраска, сборочный подъёмник, контроль, складские стеллажи, рабочие зоны и освещение. Автомобили имеют объёмный силуэт и цвет модели; это условные представления, а не CAD-модели марок. Камеру можно вращать, приближать к участку, переключать на вид сверху и разворачивать сцену. -- Каждая машина имеет постоянный ID и проходит весь конвейер: подъезд, обработку, ожидание и выход. Очереди накапливаются на самой линии без обгона; заполненный буфер блокирует предыдущий участок. Можно выбрать машину и следить за ней камерой. -- Состояния, прогресс и очереди в 3D берутся из общего симулятора. Пауза, остановка оборудования, reduced motion и скрытая вкладка останавливают соответствующую анимацию. Экономный режим уменьшает нагрузку; 2D-схема остаётся доступной. -- Загрузка локальной JSON-схемы размещения с проверкой границ и пересечений. Она не меняет производственные показатели. Форматы импорта: [docs/DATA-INTEGRATION.md](docs/DATA-INTEGRATION.md). -- Сравнение трёх решений от текущего состояния до конца смены: продолжение работы, обслуживание окраски и резерв мощности сборки. Видны выпуск, изменение простоя, остаток в линии и отклонение от плана. -- Настраиваемые длительности обслуживания и подготовки резерва. Расчёты выполняются на независимых копиях состояния; текущая смена не меняется. В браузерной версии расчёт вынесен в Web Worker. - -В серверном режиме каждый браузерный сеанс получает отдельную демонстрацию. Состояние хранится в памяти и удаляется через 30 минут без обращений; перезапуск сервера сбрасывает все сессии. Одновременно допускается до 50 сессий. После длительного сна компьютера симуляция не «догоняет» всё прошедшее время: один шаг учитывает не более пяти реальных секунд. В браузерном режиме фоновая вкладка может замедлять таймер; активный показ следует вести в открытой вкладке. - -Смена сценария и сброс обнуляют симуляцию, сохраняя конфигурацию, выбранную скорость и состояние воспроизведения. По завершении заданной смены симуляция останавливается; по умолчанию смена длится восемь часов. Обновление страницы публичного стенда возвращает конфигурацию тестового кейса. В серверном режиме конфигурация живёт до удаления сессии. Межсменный перенос НЗП пока не моделируется. - -Настройки RU / KZ / ENG, тёмная тема и управление камерой: [docs/INTERFACE.md](docs/INTERFACE.md). - -## Разработка - -| Область | Назначение | -| --- | --- | -| `apps/web` | React-интерфейс, 3D-цех, SVG-схема, графики | -| `apps/server` | Fastify API, независимые сессии, таймер | -| `packages/shared` | Общие типы и определения участков | -| `packages/simulation` | Детерминированная модель производства и тесты | - -План и распределение задач: [docs/TASKS.md](docs/TASKS.md). Архитектура и API: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - -## Размещение - -GitHub Actions публикует браузерную сборку в GitHub Pages после изменения `main` и успешного `npm run check`. Настройки: `.github/workflows/pages.yml`. Публикация не заменяет локальную резервную сборку. - -`render.yaml` подготовлен для размещения полного серверного режима с OpenAI. Наличие файла не означает, что сервер уже развёрнут. Публичная вкладка «ИИ» подключается к отдельно настроенному HTTPS-серверу; API-ключ нельзя помещать в статическую сборку. Подробности: [docs/AI.md](docs/AI.md). - -Сравнение использует заранее заданные события сценария. Обслуживание гарантированно устраняет дальнейшие отказы окраски, а резерв моделируется удвоением скорости обработки на сборке после переналадки. Это допущения учебной модели, а не подтверждённый эффект на реальном заводе. Стоимость ресурсов и экономическая окупаемость пока не рассчитываются. - -Следующие этапы: уточнение календаря, начального НЗП, идеальных циклов и качества после ОТК; калибровка при появлении этих данных и материалы защиты. Правила и сценарные расчёты работают без внешней модели. API-помощник OpenAI анализирует наблюдения и предлагает проверяемые действия; обученной и валидированной на заводской телеметрии модели отказов пока нет. +- [Соответствие ТЗ, исходные данные и ограничения](docs/CASE-DATA.md) +- [Архитектура решения](docs/ARCHITECTURE.md) +- [Сценарий демонстрации](docs/DEMO.md) +- [Устройство и настройка ИИ-анализа](docs/AI.md) diff --git a/apps/web/src/components/EquipmentIllustration.tsx b/apps/web/src/components/EquipmentIllustration.tsx index 448b611..cf02842 100644 --- a/apps/web/src/components/EquipmentIllustration.tsx +++ b/apps/web/src/components/EquipmentIllustration.tsx @@ -1,3 +1,4 @@ +import { useId } from 'react'; import type { StationId, StationStatus } from '../types'; interface EquipmentIllustrationProps { @@ -15,6 +16,7 @@ export function EquipmentIllustration({ animate = false, inProcess = false, }: EquipmentIllustrationProps) { + const clipId = useId(); const isAnimated = animate && (status === 'running' || status === 'warning') && inProcess; const cycle = Math.min(1, Math.max(0, progress)); const carX = inProcess ? Math.round(12 + cycle * 46) : 35; @@ -22,149 +24,155 @@ export function EquipmentIllustration({ return ( + + + + + - - + + + - {type === 'supply' && ( - - - - - - - - - - - - - - - )} - - {type === 'welding' && ( - - - - - - - - {isAnimated && ( - - - - - - )} - + {type === 'supply' && ( + + + + + + + + + + + + - {showCar && ( - - - - - + )} + + {type === 'welding' && ( + + + + + + + + {isAnimated && ( + + + + + + )} + - )} - - - - - + {showCar && ( + + + + + + + )} + + + + + + - - )} + )} - {type === 'painting' && ( - - - - - - - - - {showCar && ( - - - - - - )} - - )} + {type === 'painting' && ( + + + + + + + + + {showCar && ( + + + + + + )} + + )} - {type === 'assembly' && ( - - - - - - - - - - + {type === 'assembly' && ( + + + + + + + + + + + + {showCar && ( + + + + + + + + + )} - {showCar && ( - - - - - - - - - )} - - )} + )} - {type === 'quality' && ( - - - - - - - - + {type === 'quality' && ( + + + + + + + + + + {showCar && ( + + + + + + + + + + )} - {showCar && ( - - - - - - - - - - )} - - )} + )} - {type === 'finished' && ( - - - - - - - - - - - + {type === 'finished' && ( + + + + + + + + + + + + + + - - - - )} + )} + ); } diff --git a/apps/web/src/components/FlowDiagram.test.ts b/apps/web/src/components/FlowDiagram.test.ts new file mode 100644 index 0000000..5de56c1 --- /dev/null +++ b/apps/web/src/components/FlowDiagram.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, createElement as h, Fragment } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createEngine, getSnapshot } from '@driveindui/simulation'; +import { createCaseConfig } from '@driveindui/shared'; +import { FlowDiagram } from './FlowDiagram'; +import { readFileSync } from 'node:fs'; +const css = readFileSync('apps/web/src/stage2-flow.css', 'utf8'); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let host: HTMLDivElement, root: Root, styles: HTMLStyleElement; +const snapshot = getSnapshot(createEngine({ config: createCaseConfig() })); +const running = { ...snapshot, stations: snapshot.stations.map(station => ({ ...station, status: 'running' as const, inProcess: true, progress: 0.5 })) }; +const diagram = (animate = true) => h(FlowDiagram, { snapshot: running, selected: 'welding', animate, stale: false, onSelect: vi.fn() }); +beforeEach(() => { + host = document.createElement('div'); document.body.append(host); root = createRoot(host); + styles = document.createElement('style'); styles.textContent = css; document.head.append(styles); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); styles.remove(); }); + +describe('2D equipment animation boundaries', () => { + it('anchors scaling to the spark instead of the entire line viewport', async () => { + await act(async () => root.render(diagram())); + const spark = host.querySelector('.anim-spark')!; + const style = getComputedStyle(spark); + expect(style.transformBox).toBe('fill-box'); + expect(style.transformOrigin).toBe('center'); + expect(spark.closest('.equip-welding')).not.toBeNull(); + expect(host.querySelector('.equip-supply .anim-spark')).toBeNull(); + }); + + it('keeps each equipment scene clipped to its own frame even with two diagrams mounted', async () => { + await act(async () => root.render(h(Fragment, null, diagram(), diagram()))); + const clips = [...host.querySelectorAll('clipPath')]; + expect(clips).toHaveLength(12); + expect(new Set(clips.map(clip => clip.id)).size).toBe(clips.length); + for (const bay of host.querySelectorAll('.equip-bay')) { + const clip = bay.querySelector('clipPath')!; + const contents = bay.querySelector('g[clip-path]')!; + expect(contents.getAttribute('clip-path')).toBe(`url(#${clip.id})`); + expect(contents.querySelector('.equip-rail-base')).not.toBeNull(); + expect(contents.querySelector('[class^="equip-"]:not(line)')).not.toBeNull(); + const frame = bay.querySelector('.equip-bg')!; + const bounds = clip.querySelector('rect')!; + for (const dimension of ['width', 'height', 'rx']) expect(bounds.getAttribute(dimension)).toBe(frame.getAttribute(dimension)); + expect(contents.contains(frame)).toBe(false); + } + }); + + it('removes moving effects on pause without changing the clipping references', async () => { + await act(async () => root.render(diagram())); + const clips = [...host.querySelectorAll('clipPath')].map(clip => clip.id); + expect(host.querySelector('.anim-spark')).not.toBeNull(); + await act(async () => root.render(diagram(false))); + expect(host.querySelector('[class^="anim-"], .is-moving, .is-flowing')).toBeNull(); + expect([...host.querySelectorAll('clipPath')].map(clip => clip.id)).toEqual(clips); + }); +}); diff --git a/apps/web/src/stage2-flow.css b/apps/web/src/stage2-flow.css index a184158..f973e07 100644 --- a/apps/web/src/stage2-flow.css +++ b/apps/web/src/stage2-flow.css @@ -341,6 +341,8 @@ } .anim-spark { + /* Scale around the spark itself, not the full production-line SVG viewport. */ + transform-box: fill-box; transform-origin: center; animation: sparkBurst 0.3s ease-in-out infinite alternate; }